From 78c1b8674c3f5e9daab19a7d1d62c60951a81202 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 23 Feb 2026 23:25:25 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/addteam/_domain/apis/image.ts | 1 + .../components/AddTeamSidebarWrapper.tsx | 10 ++ .../components/CreateTeamCard.module.css | 85 ++++++++++++++++ .../_domain/components/CreateTeamCard.tsx | 64 ++++++++++++ .../components/FeedbackMessage.module.css | 37 +++++++ .../_domain/components/FeedbackMessage.tsx | 31 ++++++ .../components/JoinTeamCard.module.css | 99 +++++++++++++++++++ .../_domain/components/JoinTeamCard.tsx | 86 ++++++++++++++++ .../_domain/components/NoTeamState.module.css | 58 +++++++++++ .../_domain/components/NoTeamState.tsx | 44 +++++++++ .../addteam/_domain/constants/createTeam.ts | 9 ++ .../addteam/_domain/hooks/useCreateTeam.ts | 40 ++++++++ .../addteam/_domain/hooks/useJoinTeam.ts | 36 +++++++ .../addteam/_domain/interfaces/feedback.ts | 4 + .../_domain/queries/useUploadImageMutation.ts | 8 ++ .../addteam/_domain/styles/common.module.css | 16 +++ .../(root)/addteam/_domain/svg/noTeamImg.svg | 27 +++++ .../_domain/utils/duplicationCalculator.ts | 12 +++ .../utils/getCreateTeamFailureMessage.ts | 38 +++++++ src/app/(root)/addteam/create/page.tsx | 46 +++++++++ src/app/(root)/addteam/join/page.tsx | 56 +++++++++++ src/app/(root)/addteam/layout.tsx | 15 +++ src/app/(root)/addteam/page.module.css | 41 ++++++++ src/app/(root)/addteam/page.tsx | 5 + 24 files changed, 868 insertions(+) create mode 100644 src/app/(root)/addteam/_domain/apis/image.ts create mode 100644 src/app/(root)/addteam/_domain/components/AddTeamSidebarWrapper.tsx create mode 100644 src/app/(root)/addteam/_domain/components/CreateTeamCard.module.css create mode 100644 src/app/(root)/addteam/_domain/components/CreateTeamCard.tsx create mode 100644 src/app/(root)/addteam/_domain/components/FeedbackMessage.module.css create mode 100644 src/app/(root)/addteam/_domain/components/FeedbackMessage.tsx create mode 100644 src/app/(root)/addteam/_domain/components/JoinTeamCard.module.css create mode 100644 src/app/(root)/addteam/_domain/components/JoinTeamCard.tsx create mode 100644 src/app/(root)/addteam/_domain/components/NoTeamState.module.css create mode 100644 src/app/(root)/addteam/_domain/components/NoTeamState.tsx create mode 100644 src/app/(root)/addteam/_domain/constants/createTeam.ts create mode 100644 src/app/(root)/addteam/_domain/hooks/useCreateTeam.ts create mode 100644 src/app/(root)/addteam/_domain/hooks/useJoinTeam.ts create mode 100644 src/app/(root)/addteam/_domain/interfaces/feedback.ts create mode 100644 src/app/(root)/addteam/_domain/queries/useUploadImageMutation.ts create mode 100644 src/app/(root)/addteam/_domain/styles/common.module.css create mode 100644 src/app/(root)/addteam/_domain/svg/noTeamImg.svg create mode 100644 src/app/(root)/addteam/_domain/utils/duplicationCalculator.ts create mode 100644 src/app/(root)/addteam/_domain/utils/getCreateTeamFailureMessage.ts create mode 100644 src/app/(root)/addteam/create/page.tsx create mode 100644 src/app/(root)/addteam/join/page.tsx create mode 100644 src/app/(root)/addteam/layout.tsx create mode 100644 src/app/(root)/addteam/page.module.css create mode 100644 src/app/(root)/addteam/page.tsx diff --git a/src/app/(root)/addteam/_domain/apis/image.ts b/src/app/(root)/addteam/_domain/apis/image.ts new file mode 100644 index 0000000..bb96b66 --- /dev/null +++ b/src/app/(root)/addteam/_domain/apis/image.ts @@ -0,0 +1 @@ +export { uploadImage } from '@/shared/apis/images'; diff --git a/src/app/(root)/addteam/_domain/components/AddTeamSidebarWrapper.tsx b/src/app/(root)/addteam/_domain/components/AddTeamSidebarWrapper.tsx new file mode 100644 index 0000000..ffa80de --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/AddTeamSidebarWrapper.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { Sidebar } from '@/components/sidebar'; + +export default function AddTeamSidebarWrapper() { + const router = useRouter(); + + return router.push('/mypage')} />; +} diff --git a/src/app/(root)/addteam/_domain/components/CreateTeamCard.module.css b/src/app/(root)/addteam/_domain/components/CreateTeamCard.module.css new file mode 100644 index 0000000..89802e6 --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/CreateTeamCard.module.css @@ -0,0 +1,85 @@ +.title { + margin: 0; + color: var(--color-text-primary); + font-size: 24px; + font-weight: 700; + line-height: 28px; +} + +.label { + margin: 0; + color: var(--color-text-secondary); + font-size: 16px; + font-weight: 500; + line-height: 19px; +} + +.card { + position: relative; + width: var(--create-card-width-desktop); + padding: 32px 28px; + gap: 20px; + border-radius: 24px; + box-shadow: 4px 4px 10px 0 #24242440; + background-color: var(--color-background-inverse); +} + +.profileSection { + gap: 12px; +} + +.inputSection { + gap: 8px; +} + +.teamNameInput { + height: var(--control-height-desktop); +} + +.submitButton { + margin-top: 40px; + height: var(--control-height-desktop); +} + +@media (max-width: 767px) { + .title { + font-size: 20px; + line-height: 24px; + } + + .label { + color: var(--color-text-primary); + font-size: 14px; + line-height: 17px; + } + + .card { + width: var(--create-card-width-mobile); + min-height: 464px; + padding: 24px 22px; + gap: 0; + border-radius: 24px; + } + + .profileSection { + margin-top: 34px; + } + + .inputSection { + margin-top: 24px; + gap: 8px; + } + + .teamNameInput { + height: var(--control-height-mobile); + padding: 12px; + font-size: 14px; + line-height: 17px; + } + + .submitButton { + margin-top: 40px; + height: var(--control-height-mobile); + font-size: 14px; + } +} diff --git a/src/app/(root)/addteam/_domain/components/CreateTeamCard.tsx b/src/app/(root)/addteam/_domain/components/CreateTeamCard.tsx new file mode 100644 index 0000000..ba39122 --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/CreateTeamCard.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { type FormEvent } from 'react'; +import { BaseButton } from '@/components/Button/base'; +import { Input } from '@/components/input'; +import { ProfileImage } from '@/components/profile-img'; +import type { CreateTeamFeedback } from '../interfaces/feedback'; +import FeedbackMessage from './FeedbackMessage'; +import cardStyles from './CreateTeamCard.module.css'; +import clsx from 'clsx'; +import commonStyles from '../styles/common.module.css'; + +const CREATE_TEAM_FEEDBACK_ID = 'create-team-feedback'; + +interface CreateTeamCardProps { + value: string; + disabled: boolean; + feedback: CreateTeamFeedback | null; + onChange: (value: string) => void; + onSubmit: () => void | Promise; +} + +export default function CreateTeamCard({ + value, + disabled, + feedback, + onChange, + onSubmit, +}: CreateTeamCardProps) { + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + void onSubmit(); + }; + + return ( +
+

팀 생성하기

+ +
+ +
+ +
+ + onChange(event.target.value)} + aria-describedby={CREATE_TEAM_FEEDBACK_ID} + placeholder="팀 이름을 입력해주세요" + className={cardStyles.teamNameInput} + /> +
+ + + 생성하기 + + + + + ); +} diff --git a/src/app/(root)/addteam/_domain/components/FeedbackMessage.module.css b/src/app/(root)/addteam/_domain/components/FeedbackMessage.module.css new file mode 100644 index 0000000..ff30b87 --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/FeedbackMessage.module.css @@ -0,0 +1,37 @@ +.helperText { + margin: 0; + color: var(--color-text-default); + font-size: 14px; + line-height: 1.5; + text-align: center; +} + +.errorText { + margin: 0; + color: var(--color-status-danger); + font-size: 14px; + font-weight: 600; + line-height: 1.5; + text-align: center; +} + +.successText { + margin: 0; + color: var(--color-brand-primary); + font-size: 14px; + line-height: 1.5; + text-align: center; +} + +@media (max-width: 767px) { + .helperText, + .errorText, + .successText { + margin-top: 16px; + } + + .helperText { + font-size: 12px; + line-height: 14px; + } +} diff --git a/src/app/(root)/addteam/_domain/components/FeedbackMessage.tsx b/src/app/(root)/addteam/_domain/components/FeedbackMessage.tsx new file mode 100644 index 0000000..454dc1e --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/FeedbackMessage.tsx @@ -0,0 +1,31 @@ +import type { CreateTeamFeedback } from '../interfaces/feedback'; +import feedbackMessageStyles from './FeedbackMessage.module.css'; + +interface FeedbackMessageProps { + id: string; + createTeamFeedback: CreateTeamFeedback | null; +} + +export default function FeedbackMessage({ id, createTeamFeedback }: FeedbackMessageProps) { + if (!createTeamFeedback) { + return ( +

+ 팀 이름은 회사명이나 모임 이름 등으로 설정하면 좋아요. +

+ ); + } + + if (createTeamFeedback.type === 'error') { + return ( + + ); + } + + return ( +

+ {createTeamFeedback.message} +

+ ); +} diff --git a/src/app/(root)/addteam/_domain/components/JoinTeamCard.module.css b/src/app/(root)/addteam/_domain/components/JoinTeamCard.module.css new file mode 100644 index 0000000..1ebdd5b --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/JoinTeamCard.module.css @@ -0,0 +1,99 @@ +.title { + margin: 0; + color: var(--color-text-primary); + font-size: 24px; + font-weight: 700; + line-height: 28px; +} + +.joinLabel { + margin: 0; + color: var(--color-text-primary); + font-size: 16px; + font-weight: 500; + line-height: 19px; +} + +.joinCard { + position: relative; + display: inline-flex; + justify-content: center; + width: var(--join-card-width-desktop); + height: 400px; + padding: 59px 45px 63px; + border-radius: 20px; + background: var(--color-background-inverse); +} + +.joinInputSection { + width: var(--join-input-width-desktop); + margin-top: 40px; + gap: 8px; +} + +.teamLinkInput { + width: 100%; + height: var(--control-height-desktop); +} + +.joinSubmitButton { + width: var(--join-input-width-desktop); + height: var(--control-height-desktop); + margin-top: 40px; + border-radius: 12px; +} + +.joinHelperText { + margin: 24px 0 0; + color: var(--color-text-default); + font-size: 16px; + line-height: 19px; + text-align: center; +} + +@media (max-width: 767px) { + .joinCard { + align-items: stretch; + justify-content: flex-start; + width: var(--create-card-width-mobile); + min-height: 264px; + padding: 24px 22px 28px; + border-radius: 24px; + } + + .joinInputSection { + width: 100%; + margin-top: 24px; + gap: 8px; + } + + .title { + font-size: 20px; + line-height: 24px; + } + + .joinLabel { + font-size: 14px; + line-height: 17px; + } + + .teamLinkInput { + height: var(--control-height-mobile); + font-size: 14px; + line-height: 17px; + } + + .joinSubmitButton { + width: 100%; + height: var(--control-height-mobile); + margin-top: 40px; + font-size: 14px; + line-height: 17px; + } + + .joinHelperText { + margin-top: 16px; + font-size: 12px; + line-height: 14px; + } +} diff --git a/src/app/(root)/addteam/_domain/components/JoinTeamCard.tsx b/src/app/(root)/addteam/_domain/components/JoinTeamCard.tsx new file mode 100644 index 0000000..d062f30 --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/JoinTeamCard.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { type FormEvent } from 'react'; +import { BaseButton } from '@/components/Button/base'; +import { Input } from '@/components/input'; +import type { CreateTeamFeedback } from '../interfaces/feedback'; +import joinCardStyles from './JoinTeamCard.module.css'; +import feedbackStyles from './FeedbackMessage.module.css'; +import clsx from 'clsx'; +import commonStyles from '../styles/common.module.css'; + +const JOIN_TEAM_FEEDBACK_ID = 'join-team-helper-text'; + +interface JoinTeamCardProps { + teamLink: string; + disabled: boolean; + feedback: CreateTeamFeedback | null; + onTeamLinkChange: (value: string) => void; + onSubmit: () => void | Promise; +} + +export default function JoinTeamCard({ + teamLink, + disabled, + feedback, + onTeamLinkChange, + onSubmit, +}: JoinTeamCardProps) { + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + void onSubmit(); + }; + + return ( +
+

팀 참여하기

+ +
+ + onTeamLinkChange(event.target.value)} + aria-describedby={JOIN_TEAM_FEEDBACK_ID} + placeholder="팀 링크를 입력해주세요." + className={joinCardStyles.teamLinkInput} + /> +
+ + + 참여하기 + + + {feedback ? ( + feedback.type === 'error' ? ( + + ) : ( +

+ {feedback.message} +

+ ) + ) : ( +

+ 공유받은 팀 링크를 입력해 참여할 수 있어요. +

+ )} +
+ ); +} diff --git a/src/app/(root)/addteam/_domain/components/NoTeamState.module.css b/src/app/(root)/addteam/_domain/components/NoTeamState.module.css new file mode 100644 index 0000000..15cc22a --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/NoTeamState.module.css @@ -0,0 +1,58 @@ +.noTeamState { + width: min(90vw, 660px); +} + +.noTeamIllustration { + width: min(100%, 660px); + height: auto; +} + +.noTeamMessage { + margin: 20px 0 0; + color: var(--color-text-default); + font-size: 16px; + font-weight: 500; + line-height: 34px; + text-align: center; +} + +.noTeamActions { + width: min(100%, 370px); + margin-top: 48px; + gap: 16px; +} + +.noTeamActionButton { + height: var(--control-height-desktop); +} + +@media (max-width: 767px) { + .noTeamState { + width: 100%; + max-width: 343px; + } + + .noTeamIllustration { + width: min(100%, 343px); + } + + .noTeamMessage { + margin-top: 16px; + color: var(--color-text-default); + font-size: 14px; + font-weight: 500; + line-height: 24px; + } + + .noTeamActions { + width: 100%; + max-width: 343px; + margin-top: 40px; + } + + .noTeamActionButton { + height: var(--control-height-mobile); + font-size: 14px; + line-height: 17px; + } +} diff --git a/src/app/(root)/addteam/_domain/components/NoTeamState.tsx b/src/app/(root)/addteam/_domain/components/NoTeamState.tsx new file mode 100644 index 0000000..23652e1 --- /dev/null +++ b/src/app/(root)/addteam/_domain/components/NoTeamState.tsx @@ -0,0 +1,44 @@ +'use client'; + +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { BaseButton } from '@/components/Button/base'; +import noTeamImage from '../svg/noTeamImg.svg'; +import noTeamStateStyles from './NoTeamState.module.css'; +import clsx from 'clsx'; +import commonStyles from '../styles/common.module.css'; + +export default function NoTeamState() { + const router = useRouter(); + + return ( +
+ 소속된 팀이 없는 상태 일러스트 +

+ 아직 소속된 팀이 없습니다. +
+ 팀을 생성하거나 팀에 참여해보세요. +

+
+ router.push('/addteam/create')} + > + 팀 생성하기 + + router.push('/addteam/join')} + > + 팀 참가하기 + +
+
+ ); +} diff --git a/src/app/(root)/addteam/_domain/constants/createTeam.ts b/src/app/(root)/addteam/_domain/constants/createTeam.ts new file mode 100644 index 0000000..a7b1864 --- /dev/null +++ b/src/app/(root)/addteam/_domain/constants/createTeam.ts @@ -0,0 +1,9 @@ +export const CREATE_TEAM_MESSAGES = { + success: '팀이 생성되었습니다.', + emptyTeamNameError: '팀 이름을 입력해주세요.', + duplicatedTeamNameError: '중복된 팀 이름입니다.', + duplicatedTeamNameFailure: '이미 존재하는 팀 이름이라 생성에 실패했어요.', + invalidRequestFailure: '요청 값이 올바르지 않아 팀 생성에 실패했어요.', + unauthorizedFailure: '팀을 생성할 권한이 없어 실패했어요.', + defaultFailure: '팀 생성에 실패했어요. 잠시 후 다시 시도해주세요.', +} as const; diff --git a/src/app/(root)/addteam/_domain/hooks/useCreateTeam.ts b/src/app/(root)/addteam/_domain/hooks/useCreateTeam.ts new file mode 100644 index 0000000..0f984a1 --- /dev/null +++ b/src/app/(root)/addteam/_domain/hooks/useCreateTeam.ts @@ -0,0 +1,40 @@ +import { Group } from '@/shared/apis/groups/types'; +import { useCreateGroupMutation } from '@/shared/queries/groups/useCreateGroupMutation'; +import { groupsKeys } from '@/shared/queries/groups/queryKeys'; +import { QueryClient, useQueryClient } from '@tanstack/react-query'; +import { CREATE_TEAM_MESSAGES } from '../constants/createTeam'; +import { isDuplicated, normalizeTeamName } from '../utils/duplicationCalculator'; + +function getCachedTeamNames(queryClient: QueryClient) { + const cachedDetails = queryClient.getQueriesData({ + queryKey: groupsKeys.details(), + }); + + return cachedDetails + .map(([, group]) => group?.name) + .filter((name): name is string => typeof name === 'string'); +} + +export function useCreateTeam() { + const queryClient = useQueryClient(); + const createGroupMutation = useCreateGroupMutation(); + + const createTeam = async (name: string) => { + const normalizedName = normalizeTeamName(name); + if (!normalizedName) { + throw new Error(CREATE_TEAM_MESSAGES.emptyTeamNameError); + } + + const cachedNames = getCachedTeamNames(queryClient); + if (isDuplicated(cachedNames, normalizedName)) { + throw new Error(CREATE_TEAM_MESSAGES.duplicatedTeamNameError); + } + + return createGroupMutation.mutateAsync({ name: normalizedName }); + }; + + return { + ...createGroupMutation, + createTeam, + }; +} diff --git a/src/app/(root)/addteam/_domain/hooks/useJoinTeam.ts b/src/app/(root)/addteam/_domain/hooks/useJoinTeam.ts new file mode 100644 index 0000000..f0fcb3e --- /dev/null +++ b/src/app/(root)/addteam/_domain/hooks/useJoinTeam.ts @@ -0,0 +1,36 @@ +import { useAcceptInvitationMutation } from '@/shared/queries/groups/useAcceptInvitationMutation'; +import { useCurrentUserQuery } from '@/shared/queries/user/useCurrentUserQuery'; + +// 팀 링크(URL) 또는 토큰 문자열에서 inviteToken을 추출 +function extractToken(teamLink: string): string { + try { + const url = new URL(teamLink.trim()); + const token = url.searchParams.get('token'); + if (token) return token; + } catch { + // URL 파싱 실패 시 입력값 자체를 토큰으로 사용 + } + return teamLink.trim(); +} + +export function useJoinTeam() { + const acceptInvitationMutation = useAcceptInvitationMutation(); + const { data: currentUser } = useCurrentUserQuery(); + + const joinTeam = async (teamLink: string) => { + const token = extractToken(teamLink); + if (!token) { + throw new Error('팀 링크를 입력해주세요.'); + } + const userEmail = currentUser?.email; + if (!userEmail) { + throw new Error('로그인이 필요합니다.'); + } + return acceptInvitationMutation.mutateAsync({ userEmail, token }); + }; + + return { + ...acceptInvitationMutation, + joinTeam, + }; +} diff --git a/src/app/(root)/addteam/_domain/interfaces/feedback.ts b/src/app/(root)/addteam/_domain/interfaces/feedback.ts new file mode 100644 index 0000000..fbc5d6d --- /dev/null +++ b/src/app/(root)/addteam/_domain/interfaces/feedback.ts @@ -0,0 +1,4 @@ +export interface CreateTeamFeedback { + type: 'success' | 'error'; + message: string; +} diff --git a/src/app/(root)/addteam/_domain/queries/useUploadImageMutation.ts b/src/app/(root)/addteam/_domain/queries/useUploadImageMutation.ts new file mode 100644 index 0000000..a50b6ca --- /dev/null +++ b/src/app/(root)/addteam/_domain/queries/useUploadImageMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query'; +import { uploadImage } from '../apis/image'; + +export function useUploadImageMutation() { + return useMutation({ + mutationFn: uploadImage, + }); +} diff --git a/src/app/(root)/addteam/_domain/styles/common.module.css b/src/app/(root)/addteam/_domain/styles/common.module.css new file mode 100644 index 0000000..e9f86bb --- /dev/null +++ b/src/app/(root)/addteam/_domain/styles/common.module.css @@ -0,0 +1,16 @@ +.flexCol { + display: flex; + flex-direction: column; +} + +.flexColCenter { + display: flex; + flex-direction: column; + align-items: center; +} + +.flexCenter { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/app/(root)/addteam/_domain/svg/noTeamImg.svg b/src/app/(root)/addteam/_domain/svg/noTeamImg.svg new file mode 100644 index 0000000..6ade2c6 --- /dev/null +++ b/src/app/(root)/addteam/_domain/svg/noTeamImg.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(root)/addteam/_domain/utils/duplicationCalculator.ts b/src/app/(root)/addteam/_domain/utils/duplicationCalculator.ts new file mode 100644 index 0000000..4a83666 --- /dev/null +++ b/src/app/(root)/addteam/_domain/utils/duplicationCalculator.ts @@ -0,0 +1,12 @@ +export function normalizeTeamName(name: string) { + return name.trim(); +} + +function normalizeForCompare(name: string) { + return normalizeTeamName(name).toLowerCase(); +} + +export function isDuplicated(existingNames: string[], inputName: string) { + const normalizedInput = normalizeForCompare(inputName); + return existingNames.some((name) => normalizeForCompare(name) === normalizedInput); +} diff --git a/src/app/(root)/addteam/_domain/utils/getCreateTeamFailureMessage.ts b/src/app/(root)/addteam/_domain/utils/getCreateTeamFailureMessage.ts new file mode 100644 index 0000000..3a39843 --- /dev/null +++ b/src/app/(root)/addteam/_domain/utils/getCreateTeamFailureMessage.ts @@ -0,0 +1,38 @@ +import { CREATE_TEAM_MESSAGES } from '../constants/createTeam'; + +const DUPLICATED_STATUS = 'status: 409'; +const INVALID_REQUEST_STATUS = 'status: 400'; +const UNAUTHORIZED_STATUSES = ['status: 401', 'status: 403'] as const; + +function hasStatusMessage(message: string, status: string) { + return message.includes(status); +} + +export function getCreateTeamFailureMessage(error: unknown) { + if (!(error instanceof Error)) { + return CREATE_TEAM_MESSAGES.defaultFailure; + } + + const { message } = error; + + if (message === CREATE_TEAM_MESSAGES.emptyTeamNameError) { + return CREATE_TEAM_MESSAGES.emptyTeamNameError; + } + + if ( + message === CREATE_TEAM_MESSAGES.duplicatedTeamNameError || + hasStatusMessage(message, DUPLICATED_STATUS) + ) { + return CREATE_TEAM_MESSAGES.duplicatedTeamNameFailure; + } + + if (hasStatusMessage(message, INVALID_REQUEST_STATUS)) { + return CREATE_TEAM_MESSAGES.invalidRequestFailure; + } + + if (UNAUTHORIZED_STATUSES.some((status) => hasStatusMessage(message, status))) { + return CREATE_TEAM_MESSAGES.unauthorizedFailure; + } + + return CREATE_TEAM_MESSAGES.defaultFailure; +} diff --git a/src/app/(root)/addteam/create/page.tsx b/src/app/(root)/addteam/create/page.tsx new file mode 100644 index 0000000..ccecf8b --- /dev/null +++ b/src/app/(root)/addteam/create/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import CreateTeamCard from '../_domain/components/CreateTeamCard'; +import { CREATE_TEAM_MESSAGES } from '../_domain/constants/createTeam'; +import { useCreateTeam } from '../_domain/hooks/useCreateTeam'; +import { getCreateTeamFailureMessage } from '../_domain/utils/getCreateTeamFailureMessage'; +import type { CreateTeamFeedback } from '../_domain/interfaces/feedback'; + +export default function CreateTeamPage() { + const [teamName, setTeamName] = useState(''); + const [createTeamFeedback, setCreateTeamFeedback] = useState(null); + const { createTeam, isPending } = useCreateTeam(); + const router = useRouter(); + + const isSubmitDisabled = !teamName.trim() || isPending; + + const handleSubmit = async () => { + if (isSubmitDisabled) return; + + try { + const group = await createTeam(teamName); + setTeamName(''); + setCreateTeamFeedback({ type: 'success', message: CREATE_TEAM_MESSAGES.success }); + router.push(`/${group.id}`); + } catch (error) { + setCreateTeamFeedback({ type: 'error', message: getCreateTeamFailureMessage(error) }); + } + }; + + const handleTeamNameChange = (value: string) => { + setTeamName(value); + setCreateTeamFeedback(null); + }; + + return ( + + ); +} diff --git a/src/app/(root)/addteam/join/page.tsx b/src/app/(root)/addteam/join/page.tsx new file mode 100644 index 0000000..1ecac56 --- /dev/null +++ b/src/app/(root)/addteam/join/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import JoinTeamCard from '../_domain/components/JoinTeamCard'; +import { useJoinTeam } from '../_domain/hooks/useJoinTeam'; +import type { CreateTeamFeedback } from '../_domain/interfaces/feedback'; + +function JoinTeamPageContent() { + const searchParams = useSearchParams(); + // 초대 링크로 직접 접근 시 URL의 현재 주소를 입력값으로 자동 설정 + const [teamLink, setTeamLink] = useState(() => { + if (typeof window === 'undefined') return ''; + return searchParams.get('token') ? window.location.href : ''; + }); + const [feedback, setFeedback] = useState(null); + const { joinTeam, isPending } = useJoinTeam(); + const router = useRouter(); + + const isSubmitDisabled = !teamLink.trim() || isPending; + + const handleSubmit = async () => { + if (isSubmitDisabled) return; + + try { + const group = await joinTeam(teamLink); + setFeedback({ type: 'success', message: '팀에 참여했습니다.' }); + router.push(`/${group.id}`); + } catch { + setFeedback({ type: 'error', message: '유효하지 않은 팀 링크입니다.' }); + } + }; + + const handleTeamLinkChange = (value: string) => { + setTeamLink(value); + setFeedback(null); + }; + + return ( + + ); +} + +export default function JoinTeamPage() { + return ( + + + + ); +} diff --git a/src/app/(root)/addteam/layout.tsx b/src/app/(root)/addteam/layout.tsx new file mode 100644 index 0000000..c1ab4d8 --- /dev/null +++ b/src/app/(root)/addteam/layout.tsx @@ -0,0 +1,15 @@ +import { MobileHeader } from '@/components/sidebar'; +import AddTeamSidebarWrapper from './_domain/components/AddTeamSidebarWrapper'; +import styles from './page.module.css'; + +export default function AddTeamLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
+
{children}
+
+ ); +} diff --git a/src/app/(root)/addteam/page.module.css b/src/app/(root)/addteam/page.module.css new file mode 100644 index 0000000..dab7364 --- /dev/null +++ b/src/app/(root)/addteam/page.module.css @@ -0,0 +1,41 @@ +.page { + --layout-padding-desktop: 24px; + --layout-padding-mobile: 16px; + --create-card-width-desktop: min(90vw, 480px); + --create-card-width-mobile: min(100%, 343px); + --join-card-width-desktop: min(90vw, 550px); + --join-input-width-desktop: min(100%, 460px); + --control-height-desktop: 48px; + --control-height-mobile: 44px; + + display: flex; + min-height: 100vh; + background: var(--color-background-secondary); +} + +.mobileOnlyHeader { + display: none; +} + +.mainContents { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + padding: var(--layout-padding-desktop); +} + +@media (max-width: 767px) { + .page { + flex-direction: column; + } + + .mobileOnlyHeader { + display: block; + } + + .mainContents { + width: 100%; + padding: var(--layout-padding-mobile); + } +} diff --git a/src/app/(root)/addteam/page.tsx b/src/app/(root)/addteam/page.tsx new file mode 100644 index 0000000..9ca589a --- /dev/null +++ b/src/app/(root)/addteam/page.tsx @@ -0,0 +1,5 @@ +import NoTeamState from './_domain/components/NoTeamState'; + +export default function AddTeamPage() { + return ; +}