From 311945caba1c71a9d91e6599abb44097b81954a5 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 13 Feb 2026 18:02:42 +0900 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20=ED=8C=80=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8D=BC=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/page.module.css | 173 ++++++++++++++++++++++++++++++++ src/app/addteam/page.tsx | 72 +++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/app/addteam/page.module.css create mode 100644 src/app/addteam/page.tsx diff --git a/src/app/addteam/page.module.css b/src/app/addteam/page.module.css new file mode 100644 index 0000000..ce1c1ba --- /dev/null +++ b/src/app/addteam/page.module.css @@ -0,0 +1,173 @@ +.page { + display: flex; + min-height: 100vh; + background: var(--color-background-secondary); +} + +.mobileGnb { + display: none; +} + +.mainContents { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.card { + width: min(90vw, 480px); + padding: 32px 28px; + display: flex; + flex-direction: column; + gap: 20px; + border-radius: 24px; + box-shadow: 4px 4px 10px 0 #24242440; + background-color: var(--color-background-inverse); +} + +.title { + margin: 0; + font-size: 24px; + font-weight: 700; + line-height: 28px; + color: var(--color-text-primary); +} + +.profileSection { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +.inputSection { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + margin: 0; + font-size: 16px; + font-weight: 500; + line-height: 19px; + color: var(--color-text-secondary); +} + +.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; + line-height: 1.5; + text-align: center; +} + +@media (max-width: 767px) { + .page { + flex-direction: column; + } + + .mobileGnb { + display: block; + width: 100%; + align-self: stretch; + } + + .mobileGnb :global(header) { + display: flex; + width: 100%; + height: 52px; + padding: 12px 16px; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--Border-Primary, #e2e8f0); + background: var(--Background-Primary, #fff); + } + + .mobileGnb :global(header) > div { + display: flex; + width: 102px; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + .mainContents { + width: 100%; + padding: 16px; + } + + .card { + width: 343px; + height: 464px; + padding: 24px 22px; + gap: 0; + border-radius: 24px; + } + + .title { + margin: 0; + color: var(--Text-Primary, #1e293b); + font-family: Pretendard, sans-serif; + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: 24px; + } + + .profileSection { + margin-top: 34px; + } + + .inputSection { + margin-top: 24px; + gap: 8px; + } + + .label { + color: var(--Text-Primary, #1e293b); + font-family: Pretendard, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 17px; + } + + .helperText { + color: var(--Text-Default, #64748b); + text-align: center; + font-family: Pretendard, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 14px; + } + + .teamNameInput { + height: 44px; + padding: 12px; + font-size: 14px; + line-height: 17px; + } + + .submitButton { + margin-top: 40px; + height: 44px; + font-size: 14px; + } + + .helperText, + .errorText { + margin-top: 16px; + } +} diff --git a/src/app/addteam/page.tsx b/src/app/addteam/page.tsx new file mode 100644 index 0000000..f0420eb --- /dev/null +++ b/src/app/addteam/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; +import { BaseButton } from '@/components/Button/base'; +import { Input } from '@/components/input'; +import { ProfileImage } from '@/components/profile-img'; +import { MobileHeader, Sidebar } from '@/components/sidebar'; +import { useCreateTeam } from './_hooks/useCreateTeam'; +import styles from './page.module.css'; + +export default function AddTeamPage() { + const [teamName, setTeamName] = useState(''); + const { createTeam, isPending, error } = useCreateTeam(); + + const isSubmitDisabled = !teamName.trim() || isPending; + + const handleSubmit = async () => { + if (isSubmitDisabled) return; + + await createTeam(teamName); + setTeamName(''); + }; + + return ( +
+ +
+ +
+
+
+

팀 생성하기

+ +
+ +
+ +
+ + setTeamName(e.target.value)} + placeholder="팀 이름을 입력해주세요" + className={styles.teamNameInput} + /> +
+ + void handleSubmit()} + > + 생성하기 + + + {error ? ( +

+ {error.message} +

+ ) : ( +

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

+ )} +
+
+
+ ); +} From f60c25862350aaef5b11f08c2b542abd8ab10d18 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 13 Feb 2026 18:03:02 +0900 Subject: [PATCH 02/24] =?UTF-8?q?feat:=20tanstack=20query=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EB=B0=94=EC=9D=B4=EB=8D=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 5 ++++- src/app/providers.tsx | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 src/app/providers.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f2e339e..734715d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import '@/shared/styles/color.css'; import './globals.css'; import { pretendard } from '@/shared/styles/font'; +import Providers from './providers'; export const metadata: Metadata = { title: 'Create Next App', @@ -12,7 +13,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..2f5343d --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +type ProvidersProps = { + children: ReactNode; +}; + +export default function Providers({ children }: ProvidersProps) { + const [queryClient] = useState(() => new QueryClient()); + + return {children}; +} From 3bd29db06d62de323ea5603ea3b8d89a677f107b Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 13 Feb 2026 18:03:36 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20=ED=8C=80=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BB=A4=EC=8A=A4=ED=85=80=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_hooks/useCreateTeam.ts | 42 +++++++++++++++++++++++++ src/app/addteam/_hooks/useTeams.ts | 5 +++ 2 files changed, 47 insertions(+) create mode 100644 src/app/addteam/_hooks/useCreateTeam.ts create mode 100644 src/app/addteam/_hooks/useTeams.ts diff --git a/src/app/addteam/_hooks/useCreateTeam.ts b/src/app/addteam/_hooks/useCreateTeam.ts new file mode 100644 index 0000000..e5d5963 --- /dev/null +++ b/src/app/addteam/_hooks/useCreateTeam.ts @@ -0,0 +1,42 @@ +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 { isDuplicated, normalizeTeamName } from '../_utils/duplicationCalculator'; + +const EMPTY_TEAM_NAME_ERROR = '팀 이름을 입력해주세요.'; +const DUPLICATED_TEAM_NAME_ERROR = '중복된 팀 이름입니다.'; + +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(EMPTY_TEAM_NAME_ERROR); + } + + const cachedNames = getCachedTeamNames(queryClient); + if (isDuplicated(cachedNames, normalizedName)) { + throw new Error(DUPLICATED_TEAM_NAME_ERROR); + } + + return createGroupMutation.mutateAsync({ name: normalizedName }); + }; + + return { + ...createGroupMutation, + createTeam, + }; +} diff --git a/src/app/addteam/_hooks/useTeams.ts b/src/app/addteam/_hooks/useTeams.ts new file mode 100644 index 0000000..0dbab71 --- /dev/null +++ b/src/app/addteam/_hooks/useTeams.ts @@ -0,0 +1,5 @@ +import { useGroupQuery } from '@/shared/queries/groups/useGroupQuery'; + +export function useTeams(teamId?: number) { + return useGroupQuery(teamId); +} From df122d72f206f2b3b515a2cea8aa64d008e44e64 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 13 Feb 2026 18:03:59 +0900 Subject: [PATCH 04/24] =?UTF-8?q?feat:=20=ED=8C=80=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_utils/duplicationCalculator.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/app/addteam/_utils/duplicationCalculator.ts diff --git a/src/app/addteam/_utils/duplicationCalculator.ts b/src/app/addteam/_utils/duplicationCalculator.ts new file mode 100644 index 0000000..4a83666 --- /dev/null +++ b/src/app/addteam/_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); +} From 77843918ea70ee4baa6d08d9ea8c964119416bb9 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 14 Feb 2026 16:40:26 +0900 Subject: [PATCH 05/24] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=9B=85=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_hooks/useTeams.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/app/addteam/_hooks/useTeams.ts diff --git a/src/app/addteam/_hooks/useTeams.ts b/src/app/addteam/_hooks/useTeams.ts deleted file mode 100644 index 0dbab71..0000000 --- a/src/app/addteam/_hooks/useTeams.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useGroupQuery } from '@/shared/queries/groups/useGroupQuery'; - -export function useTeams(teamId?: number) { - return useGroupQuery(teamId); -} From 4a50c2805902a48a2993474eadad34e35665aea6 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 14 Feb 2026 21:22:24 +0900 Subject: [PATCH 06/24] =?UTF-8?q?refactor:=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=83=81=ED=98=B8=EC=9E=91=EC=9A=A9=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=95=88=EB=82=B4=EB=AC=B8=EA=B5=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/page.module.css | 12 +++++++- src/app/addteam/page.tsx | 52 +++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/app/addteam/page.module.css b/src/app/addteam/page.module.css index ce1c1ba..4dd5187 100644 --- a/src/app/addteam/page.module.css +++ b/src/app/addteam/page.module.css @@ -70,6 +70,15 @@ font-size: 14px; line-height: 1.5; text-align: center; + font-weight: 600; +} + +.successText { + margin: 0; + color: var(--color-brand-primary); + font-size: 14px; + line-height: 1.5; + text-align: center; } @media (max-width: 767px) { @@ -167,7 +176,8 @@ } .helperText, - .errorText { + .errorText, + .successText { margin-top: 16px; } } diff --git a/src/app/addteam/page.tsx b/src/app/addteam/page.tsx index f0420eb..c4f0352 100644 --- a/src/app/addteam/page.tsx +++ b/src/app/addteam/page.tsx @@ -8,17 +8,57 @@ import { MobileHeader, Sidebar } from '@/components/sidebar'; import { useCreateTeam } from './_hooks/useCreateTeam'; import styles from './page.module.css'; +const TEAM_CREATED_MESSAGE = '팀이 생성되었습니다.'; +const EMPTY_TEAM_NAME_ERROR = '팀 이름을 입력해주세요.'; +const DUPLICATED_TEAM_NAME_ERROR = '중복된 팀 이름입니다.'; +const DEFAULT_CREATE_TEAM_ERROR = '팀 생성에 실패했어요. 잠시 후 다시 시도해주세요.'; + +type Feedback = { + type: 'success' | 'error'; + message: string; +}; + +function getCreateTeamFailureMessage(error: unknown) { + if (!(error instanceof Error)) { + return DEFAULT_CREATE_TEAM_ERROR; + } + + if (error.message === EMPTY_TEAM_NAME_ERROR) { + return EMPTY_TEAM_NAME_ERROR; + } + + if (error.message === DUPLICATED_TEAM_NAME_ERROR || error.message.includes('status: 409')) { + return '이미 존재하는 팀 이름이라 생성에 실패했어요.'; + } + + if (error.message.includes('status: 400')) { + return '요청 값이 올바르지 않아 팀 생성에 실패했어요.'; + } + + if (error.message.includes('status: 401') || error.message.includes('status: 403')) { + return '팀을 생성할 권한이 없어 실패했어요.'; + } + + return DEFAULT_CREATE_TEAM_ERROR; +} + export default function AddTeamPage() { const [teamName, setTeamName] = useState(''); - const { createTeam, isPending, error } = useCreateTeam(); + const [feedback, setFeedback] = useState(null); + const { createTeam, isPending } = useCreateTeam(); const isSubmitDisabled = !teamName.trim() || isPending; const handleSubmit = async () => { if (isSubmitDisabled) return; - await createTeam(teamName); - setTeamName(''); + try { + await createTeam(teamName); + setTeamName(''); + setFeedback({ type: 'success', message: TEAM_CREATED_MESSAGE }); + } catch (error) { + setFeedback({ type: 'error', message: getCreateTeamFailureMessage(error) }); + } }; return ( @@ -56,10 +96,12 @@ export default function AddTeamPage() { 생성하기 - {error ? ( + {feedback?.type === 'error' ? (

- {error.message} + {feedback.message}

+ ) : feedback?.type === 'success' ? ( +

{feedback.message}

) : (

팀 이름은 회사명이나 모임 이름 등으로 설정하면 좋아요. From 9e62147b005f06f194801ed21fc59abbb2ebc701 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 14 Feb 2026 22:01:26 +0900 Subject: [PATCH 07/24] =?UTF-8?q?feat:=20api=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/apis/config.ts | 14 ++++++-- src/shared/apis/fetchApi.ts | 64 +++++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/shared/apis/config.ts b/src/shared/apis/config.ts index a6449f4..b95b025 100644 --- a/src/shared/apis/config.ts +++ b/src/shared/apis/config.ts @@ -1,2 +1,12 @@ -export const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL!; -export const TEAM_ID = '20-1'; +const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; +const apiTeamId = process.env.NEXT_PUBLIC_API_TEAM_ID; + +if (!apiBaseUrl) { + throw new Error('NEXT_PUBLIC_API_BASE_URL is not defined.'); +} +if (!apiTeamId) { + throw new Error('NEXT_PUBLIC_API_TEAM_ID is not defined.'); +} + +export const BASE_URL = apiBaseUrl; +export const TEAM_ID = apiTeamId; diff --git a/src/shared/apis/fetchApi.ts b/src/shared/apis/fetchApi.ts index 3348cff..41496eb 100644 --- a/src/shared/apis/fetchApi.ts +++ b/src/shared/apis/fetchApi.ts @@ -1,13 +1,65 @@ import { BASE_URL, TEAM_ID } from './config'; +const BODYLESS_METHODS = new Set(['GET', 'HEAD']); +const NORMALIZED_BASE_URL = normalizeBaseUrl(BASE_URL); +const DEV_ACCESS_TOKEN = process.env.NEXT_PUBLIC_DEV_ACCESS_TOKEN; +const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; + +function normalizeBaseUrl(baseUrl: string) { + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; +} + +function normalizePath(path: string) { + return path.startsWith('/') ? path.slice(1) : path; +} + +function buildApiUrl(path: string) { + const relativePath = `${TEAM_ID}/${normalizePath(path)}`; + return new URL(relativePath, NORMALIZED_BASE_URL).toString(); +} + +function getMethod(options: RequestInit) { + return (options.method ?? 'GET').toUpperCase(); +} + +function hasBody(body: RequestInit['body']) { + return body !== undefined && body !== null; +} + +function assertBodyAllowed(method: string, body: RequestInit['body']) { + if (!hasBody(body)) return; + if (BODYLESS_METHODS.has(method)) { + throw new Error(`HTTP ${method} request must not include a body.`); + } +} + +function shouldSetJsonContentType(headers: Headers, body: RequestInit['body']) { + if (!hasBody(body)) return false; + if (headers.has('Content-Type')) return false; + return typeof body === 'string'; +} + +function shouldAttachDevAuthHeader(headers: Headers) { + if (!DEV_ACCESS_TOKEN) return false; + if (headers.has('Authorization')) return false; + return IS_DEVELOPMENT; +} + export function fetchApi(path: string, options: RequestInit = {}) { - const url = `${BASE_URL}/${TEAM_ID}${path}`; + const method = getMethod(options); + assertBodyAllowed(method, options.body); + + const headers = new Headers(options.headers); + if (shouldSetJsonContentType(headers, options.body)) { + headers.set('Content-Type', 'application/json'); + } + if (shouldAttachDevAuthHeader(headers)) { + headers.set('Authorization', `Bearer ${DEV_ACCESS_TOKEN}`); + } - return fetch(url, { + return fetch(buildApiUrl(path), { ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, + method, + headers, }); } From d7b025ace044aa71e091100bddb580456990a708 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 00:23:01 +0900 Subject: [PATCH 08/24] =?UTF-8?q?chore:=20example=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 62 ---------------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index df7807b..0000000 --- a/.env.example +++ /dev/null @@ -1,62 +0,0 @@ -# ====================================== -# API 설정 (공개 가능) -# ====================================== -# 프론트엔드에서 호출할 백엔드 API의 기본 주소입니다. -# -# 이 값은 NEXT_PUBLIC_ 접두사가 붙어 있으므로 -# ▶ 브라우저(클라이언트)에 노출되는 값입니다. -# ▶ 보안 정보가 아닌 "공개 가능한 주소"만 작성해야 합니다. -# -# 예시: -# - 배포 환경: https://fe-project-cowokers.vercel.app -# -# ⚠️ API 주소는 네트워크 요청(Network 탭)에서 누구나 확인 가능하므로 -# ⚠️ 절대 비밀 키나 토큰을 포함해서는 안 됩니다. -# -# 이 값은 기본값으로 .env에 커밋해도 되며, -# 개인 로컬 환경에서 다른 주소를 쓰고 싶다면 -# .env.local 파일에서 덮어쓸 수 있습니다. -NEXT_PUBLIC_API_BASE_URL=https://fe-project-cowokers.vercel.app - - -# ====================================== -# 애플리케이션 환경 설정 (공개 가능) -# ====================================== -# 현재 실행 중인 프론트엔드 애플리케이션의 환경을 나타냅니다. -# -# 이 값 역시 NEXT_PUBLIC_ 접두사가 붙어 있으므로 -# ▶ 클라이언트 코드에서 접근 가능 -# ▶ 조건 분기, 로그 출력, 기능 플래그 등에 사용됩니다. -# -# 사용 가능한 값 예시: -# - development : 로컬 개발 환경 -# - staging : 테스트 / 검증 환경 -# - production : 실제 서비스 환경 -# -# ⚠️ 보안과 직접적인 관련은 없으며, -# ⚠️ 환경별 UI / 로직 분기를 위한 용도입니다. -NEXT_PUBLIC_APP_ENV=development - - -# ====================================== -# OAuth / 인증 관련 비밀 키 (절대 공개 금지) -# ====================================== -# 아래 값들은 "비밀 키(Secret)"에 해당합니다. -# -# ▶ 서버에서만 사용되어야 하며 -# ▶ 클라이언트 코드(Client Component)에서 접근하면 안 됩니다. -# ▶ NEXT_PUBLIC_ 접두사를 절대 붙이지 마세요. -# -# ⚠️ 이 값들이 유출될 경우: -# - OAuth 인증 위조 -# - 사용자 계정 탈취 -# - 서비스 보안 사고 -# 로 이어질 수 있습니다. -# -# ▶ 반드시 .env.local 파일 또는 -# ▶ 배포 환경(Vercel / CI 환경 변수)에만 설정하세요. -# ▶ Git에 커밋하지 마세요. -# -# 키값 예시: -KAKAO_CLIENT_SECRET=... - From 625083d564e07902b325ecf180c27aab3f300370 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 00:23:42 +0900 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20gropus=20=EA=B4=80=EB=A0=A8=20api?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/apis/groups/group.ts | 13 ++++++ src/shared/apis/groups/http.ts | 46 +++++++++++++++++++ src/shared/apis/groups/invitation.ts | 21 +++++++++ src/shared/apis/groups/member.ts | 28 +++++++++++ src/shared/apis/groups/task.ts | 12 +++++ src/shared/apis/groups/types.ts | 43 +++++++++++++++++ src/shared/queries/groups/queryKeys.ts | 6 +++ .../queries/groups/useCreateGroupMutation.ts | 18 ++++++++ 8 files changed, 187 insertions(+) create mode 100644 src/shared/apis/groups/group.ts create mode 100644 src/shared/apis/groups/http.ts create mode 100644 src/shared/apis/groups/invitation.ts create mode 100644 src/shared/apis/groups/member.ts create mode 100644 src/shared/apis/groups/task.ts create mode 100644 src/shared/apis/groups/types.ts create mode 100644 src/shared/queries/groups/queryKeys.ts create mode 100644 src/shared/queries/groups/useCreateGroupMutation.ts diff --git a/src/shared/apis/groups/group.ts b/src/shared/apis/groups/group.ts new file mode 100644 index 0000000..16e6526 --- /dev/null +++ b/src/shared/apis/groups/group.ts @@ -0,0 +1,13 @@ +import { requestJson } from './http'; +import { CreateGroupBody, Group } from './types'; + +const GROUP_ERROR_MESSAGE = { + create: '그룹 생성 실패', +} as const; + +export function createGroup(body: CreateGroupBody): Promise { + return requestJson('/groups', GROUP_ERROR_MESSAGE.create, { + method: 'POST', + body: JSON.stringify(body), + }); +} diff --git a/src/shared/apis/groups/http.ts b/src/shared/apis/groups/http.ts new file mode 100644 index 0000000..9c87779 --- /dev/null +++ b/src/shared/apis/groups/http.ts @@ -0,0 +1,46 @@ +import { fetchApi } from '../fetchApi'; + +interface RequestErrorContext { + message: string; + path: string; + method: string; +} + +function assertOk(response: Response, context: RequestErrorContext) { + if (!response.ok) { + throw new Error( + `${context.message} (${context.method} ${context.path}, status: ${response.status})`, + ); + } +} + +function parseJson(response: Response): Promise { + return response.json() as Promise; +} + +export async function requestJson( + path: string, + message: string, + options?: RequestInit, +): Promise { + const response = await fetchApi(path, options); + assertOk(response, { + message, + path, + method: (options?.method ?? 'GET').toUpperCase(), + }); + return parseJson(response); +} + +export async function requestVoid( + path: string, + message: string, + options?: RequestInit, +): Promise { + const response = await fetchApi(path, options); + assertOk(response, { + message, + path, + method: (options?.method ?? 'GET').toUpperCase(), + }); +} diff --git a/src/shared/apis/groups/invitation.ts b/src/shared/apis/groups/invitation.ts new file mode 100644 index 0000000..0c5433e --- /dev/null +++ b/src/shared/apis/groups/invitation.ts @@ -0,0 +1,21 @@ +import { requestJson } from './http'; +import { AcceptInvitationBody, Group, InvitationInfo } from './types'; + +const INVITATION_ERROR_MESSAGE = { + fetch: 'Failed to fetch invitation info', + accept: 'Failed to accept invitation', +} as const; + +export function getInvitationInfo(groupId: number): Promise { + return requestJson( + `/groups/${groupId}/invitation`, + INVITATION_ERROR_MESSAGE.fetch, + ); +} + +export function acceptInvitation(body: AcceptInvitationBody): Promise { + return requestJson('/groups/accept-invitation', INVITATION_ERROR_MESSAGE.accept, { + method: 'POST', + body: JSON.stringify(body), + }); +} diff --git a/src/shared/apis/groups/member.ts b/src/shared/apis/groups/member.ts new file mode 100644 index 0000000..2b48fc1 --- /dev/null +++ b/src/shared/apis/groups/member.ts @@ -0,0 +1,28 @@ +import { requestJson, requestVoid } from './http'; +import { AddMemberBody, Group, GroupMember } from './types'; + +const MEMBER_ERROR_MESSAGE = { + fetch: 'Failed to fetch group member', + add: 'Failed to add member', + remove: 'Failed to remove group member', +} as const; + +export function addMember(groupId: number, body: AddMemberBody): Promise { + return requestJson(`/groups/${groupId}/member`, MEMBER_ERROR_MESSAGE.add, { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export function getGroupMember(groupId: number, memberUserId: number): Promise { + return requestJson( + `/groups/${groupId}/member/${memberUserId}`, + MEMBER_ERROR_MESSAGE.fetch, + ); +} + +export function removeGroupMember(groupId: number, memberUserId: number): Promise { + return requestVoid(`/groups/${groupId}/member/${memberUserId}`, MEMBER_ERROR_MESSAGE.remove, { + method: 'DELETE', + }); +} diff --git a/src/shared/apis/groups/task.ts b/src/shared/apis/groups/task.ts new file mode 100644 index 0000000..3cae026 --- /dev/null +++ b/src/shared/apis/groups/task.ts @@ -0,0 +1,12 @@ +import { requestJson } from './http'; +import { GroupTask } from './types'; + +const TASK_ERROR_MESSAGE = { + fetch: 'Failed to fetch group tasks', +} as const; + +export function getGroupTasks(groupId: number): Promise { + return requestJson(`/groups/${groupId}/tasks`, TASK_ERROR_MESSAGE.fetch, { + cache: 'no-store', + }); +} diff --git a/src/shared/apis/groups/types.ts b/src/shared/apis/groups/types.ts new file mode 100644 index 0000000..c44120a --- /dev/null +++ b/src/shared/apis/groups/types.ts @@ -0,0 +1,43 @@ +export interface CreateGroupBody { + name: string; + image?: string; +} + +export interface Group { + id: number; + name: string; + image: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AcceptInvitationBody { + token: string; +} + +export interface InvitationInfo { + id: number; + name: string; + image: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AddMemberBody { + email: string; +} + +export interface GroupMember { + userId: number; + name: string; + email?: string; + image?: string | null; +} + +export interface GroupTask { + id: number; + name?: string; + title?: string; + done?: boolean; + date?: string; +} diff --git a/src/shared/queries/groups/queryKeys.ts b/src/shared/queries/groups/queryKeys.ts new file mode 100644 index 0000000..0989ce0 --- /dev/null +++ b/src/shared/queries/groups/queryKeys.ts @@ -0,0 +1,6 @@ +export const groupsKeys = { + all: ['groups'] as const, + details: () => [...groupsKeys.all, 'detail'] as const, + detail: (groupId: number) => [...groupsKeys.details(), groupId] as const, + create: () => [...groupsKeys.all, 'create'] as const, +}; diff --git a/src/shared/queries/groups/useCreateGroupMutation.ts b/src/shared/queries/groups/useCreateGroupMutation.ts new file mode 100644 index 0000000..90bb832 --- /dev/null +++ b/src/shared/queries/groups/useCreateGroupMutation.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createGroup } from '@/shared/apis/groups/group'; +import { groupsKeys } from './queryKeys'; + +export function useCreateGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: groupsKeys.create(), + mutationFn: createGroup, + onSuccess: (group) => { + queryClient.setQueryData(groupsKeys.detail(group.id), group); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: groupsKeys.all }); + }, + }); +} From a5a23a8cf32bbb642bbc9097da5eb3e023f6326b Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 01:28:26 +0900 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20=ED=8C=80=20=EC=97=86=EC=9D=84?= =?UTF-8?q?=EB=95=8C=20=EB=B6=84=EA=B8=B0=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/NoTeamState.module.css | 64 +++++++++++++++++++ src/app/addteam/_components/NoTeamState.tsx | 30 +++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/app/addteam/_components/NoTeamState.module.css create mode 100644 src/app/addteam/_components/NoTeamState.tsx diff --git a/src/app/addteam/_components/NoTeamState.module.css b/src/app/addteam/_components/NoTeamState.module.css new file mode 100644 index 0000000..87dd988 --- /dev/null +++ b/src/app/addteam/_components/NoTeamState.module.css @@ -0,0 +1,64 @@ +.emptyState { + width: min(90vw, 660px); + display: flex; + flex-direction: column; + align-items: center; +} + +.illustration { + width: min(100%, 660px); + height: auto; +} + +.message { + margin: 20px 0 0; + color: var(--color-text-default); + font-size: 16px; + font-weight: 500; + line-height: 34px; + text-align: center; +} + +.actions { + width: min(100%, 370px); + margin-top: 48px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.actionButton { + height: 48px; +} + +@media (max-width: 767px) { + .emptyState { + width: 100%; + max-width: 343px; + } + + .illustration { + width: min(100%, 343px); + } + + .message { + margin-top: 16px; + color: var(--Text-Default, #64748b); + font-family: Pretendard, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 24px; + } + + .actions { + width: 100%; + max-width: 343px; + margin-top: 40px; + } + + .actionButton { + height: 44px; + font-size: 14px; + line-height: 17px; + } +} diff --git a/src/app/addteam/_components/NoTeamState.tsx b/src/app/addteam/_components/NoTeamState.tsx new file mode 100644 index 0000000..f7dcb5d --- /dev/null +++ b/src/app/addteam/_components/NoTeamState.tsx @@ -0,0 +1,30 @@ +'use client'; + +import Image from 'next/image'; +import { BaseButton } from '@/components/Button/base'; +import noTeamImage from '../svg/noTeamImg.svg'; +import styles from './NoTeamState.module.css'; + +export default function NoTeamState() { + return ( +

+ 소속된 팀이 없는 상태 일러스트 +

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

+
+ 팀 생성하기 + + 팀 참가하기 + +
+
+ ); +} From 61a6064ee1ac59e63fe640e3290063fc1d6741dc Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 01:28:47 +0900 Subject: [PATCH 11/24] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=97=86=EC=9D=8C?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/svg/noTeamImg.svg | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/app/addteam/svg/noTeamImg.svg diff --git a/src/app/addteam/svg/noTeamImg.svg b/src/app/addteam/svg/noTeamImg.svg new file mode 100644 index 0000000..6ade2c6 --- /dev/null +++ b/src/app/addteam/svg/noTeamImg.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7bfa517257d97d2c5e7f28d760092ac825b33081 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 15:42:16 +0900 Subject: [PATCH 12/24] =?UTF-8?q?refactor:=20=EC=9C=A0=ED=8B=B8,=20?= =?UTF-8?q?=EC=83=81=EC=88=98,=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_constants/createTeam.ts | 11 ++++++++ src/app/addteam/_interfaces/feedback.ts | 4 +++ .../_utils/getCreateTeamFailureMessage.ts | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/app/addteam/_constants/createTeam.ts create mode 100644 src/app/addteam/_interfaces/feedback.ts create mode 100644 src/app/addteam/_utils/getCreateTeamFailureMessage.ts diff --git a/src/app/addteam/_constants/createTeam.ts b/src/app/addteam/_constants/createTeam.ts new file mode 100644 index 0000000..d109b67 --- /dev/null +++ b/src/app/addteam/_constants/createTeam.ts @@ -0,0 +1,11 @@ +export const ENABLE_NO_TEAM_STATE_PREVIEW = true; + +export const CREATE_TEAM_MESSAGES = { + success: '팀이 생성되었습니다.', + emptyTeamNameError: '팀 이름을 입력해주세요.', + duplicatedTeamNameError: '중복된 팀 이름입니다.', + duplicatedTeamNameFailure: '이미 존재하는 팀 이름이라 생성에 실패했어요.', + invalidRequestFailure: '요청 값이 올바르지 않아 팀 생성에 실패했어요.', + unauthorizedFailure: '팀을 생성할 권한이 없어 실패했어요.', + defaultFailure: '팀 생성에 실패했어요. 잠시 후 다시 시도해주세요.', +} as const; diff --git a/src/app/addteam/_interfaces/feedback.ts b/src/app/addteam/_interfaces/feedback.ts new file mode 100644 index 0000000..82f9b94 --- /dev/null +++ b/src/app/addteam/_interfaces/feedback.ts @@ -0,0 +1,4 @@ +export interface Feedback { + type: 'success' | 'error'; + message: string; +} diff --git a/src/app/addteam/_utils/getCreateTeamFailureMessage.ts b/src/app/addteam/_utils/getCreateTeamFailureMessage.ts new file mode 100644 index 0000000..dec4b33 --- /dev/null +++ b/src/app/addteam/_utils/getCreateTeamFailureMessage.ts @@ -0,0 +1,28 @@ +import { CREATE_TEAM_MESSAGES } from '../_constants/createTeam'; + +export function getCreateTeamFailureMessage(error: unknown) { + if (!(error instanceof Error)) { + return CREATE_TEAM_MESSAGES.defaultFailure; + } + + if (error.message === CREATE_TEAM_MESSAGES.emptyTeamNameError) { + return CREATE_TEAM_MESSAGES.emptyTeamNameError; + } + + if ( + error.message === CREATE_TEAM_MESSAGES.duplicatedTeamNameError || + error.message.includes('status: 409') + ) { + return CREATE_TEAM_MESSAGES.duplicatedTeamNameFailure; + } + + if (error.message.includes('status: 400')) { + return CREATE_TEAM_MESSAGES.invalidRequestFailure; + } + + if (error.message.includes('status: 401') || error.message.includes('status: 403')) { + return CREATE_TEAM_MESSAGES.unauthorizedFailure; + } + + return CREATE_TEAM_MESSAGES.defaultFailure; +} From 1bd7249e61a58e1542340edf65407944a6002b0d Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:39:53 +0900 Subject: [PATCH 13/24] =?UTF-8?q?refactor:=20style=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/NoTeamState.module.css | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/app/addteam/_components/NoTeamState.module.css diff --git a/src/app/addteam/_components/NoTeamState.module.css b/src/app/addteam/_components/NoTeamState.module.css deleted file mode 100644 index 87dd988..0000000 --- a/src/app/addteam/_components/NoTeamState.module.css +++ /dev/null @@ -1,64 +0,0 @@ -.emptyState { - width: min(90vw, 660px); - display: flex; - flex-direction: column; - align-items: center; -} - -.illustration { - width: min(100%, 660px); - height: auto; -} - -.message { - margin: 20px 0 0; - color: var(--color-text-default); - font-size: 16px; - font-weight: 500; - line-height: 34px; - text-align: center; -} - -.actions { - width: min(100%, 370px); - margin-top: 48px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.actionButton { - height: 48px; -} - -@media (max-width: 767px) { - .emptyState { - width: 100%; - max-width: 343px; - } - - .illustration { - width: min(100%, 343px); - } - - .message { - margin-top: 16px; - color: var(--Text-Default, #64748b); - font-family: Pretendard, sans-serif; - font-size: 14px; - font-weight: 500; - line-height: 24px; - } - - .actions { - width: 100%; - max-width: 343px; - margin-top: 40px; - } - - .actionButton { - height: 44px; - font-size: 14px; - line-height: 17px; - } -} From d5e61d60d1b1a2d5da9b5995b3097423681f6cff Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:40:43 +0900 Subject: [PATCH 14/24] =?UTF-8?q?refactor:=20=ED=8C=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=83=81=EC=88=98?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_hooks/useCreateTeam.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/addteam/_hooks/useCreateTeam.ts b/src/app/addteam/_hooks/useCreateTeam.ts index e5d5963..b9d2042 100644 --- a/src/app/addteam/_hooks/useCreateTeam.ts +++ b/src/app/addteam/_hooks/useCreateTeam.ts @@ -2,11 +2,9 @@ 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'; -const EMPTY_TEAM_NAME_ERROR = '팀 이름을 입력해주세요.'; -const DUPLICATED_TEAM_NAME_ERROR = '중복된 팀 이름입니다.'; - function getCachedTeamNames(queryClient: QueryClient) { const cachedDetails = queryClient.getQueriesData({ queryKey: groupsKeys.details(), @@ -24,12 +22,12 @@ export function useCreateTeam() { const createTeam = async (name: string) => { const normalizedName = normalizeTeamName(name); if (!normalizedName) { - throw new Error(EMPTY_TEAM_NAME_ERROR); + throw new Error(CREATE_TEAM_MESSAGES.emptyTeamNameError); } const cachedNames = getCachedTeamNames(queryClient); if (isDuplicated(cachedNames, normalizedName)) { - throw new Error(DUPLICATED_TEAM_NAME_ERROR); + throw new Error(CREATE_TEAM_MESSAGES.duplicatedTeamNameError); } return createGroupMutation.mutateAsync({ name: normalizedName }); From f7734caff46b8d1091a6c1cfd76faa336f81f53d Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:41:04 +0900 Subject: [PATCH 15/24] =?UTF-8?q?chore:=20svg=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/{svg => _svg}/noTeamImg.svg | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/app/addteam/{svg => _svg}/noTeamImg.svg (100%) diff --git a/src/app/addteam/svg/noTeamImg.svg b/src/app/addteam/_svg/noTeamImg.svg similarity index 100% rename from src/app/addteam/svg/noTeamImg.svg rename to src/app/addteam/_svg/noTeamImg.svg From 185d607a89e75a01e942cd84c4c01f9bc4eaa28e Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:45:18 +0900 Subject: [PATCH 16/24] =?UTF-8?q?refactor:=20=ED=8C=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=95=98=EA=B8=B0,=20=EC=B0=B8=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B8=B0,=20=EB=B9=88=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../addteam/_components/CreateTeamCard.tsx | 62 +++++++++++++++++++ .../addteam/_components/FeedbackMessage.tsx | 31 ++++++++++ src/app/addteam/_components/JoinTeamCard.tsx | 40 ++++++++++++ src/app/addteam/_components/NoTeamState.tsx | 27 +++++--- 4 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 src/app/addteam/_components/CreateTeamCard.tsx create mode 100644 src/app/addteam/_components/FeedbackMessage.tsx create mode 100644 src/app/addteam/_components/JoinTeamCard.tsx diff --git a/src/app/addteam/_components/CreateTeamCard.tsx b/src/app/addteam/_components/CreateTeamCard.tsx new file mode 100644 index 0000000..8fbd711 --- /dev/null +++ b/src/app/addteam/_components/CreateTeamCard.tsx @@ -0,0 +1,62 @@ +'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 styles from '../page.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={styles.teamNameInput} + /> +
+ + + 생성하기 + + + + + ); +} diff --git a/src/app/addteam/_components/FeedbackMessage.tsx b/src/app/addteam/_components/FeedbackMessage.tsx new file mode 100644 index 0000000..267659c --- /dev/null +++ b/src/app/addteam/_components/FeedbackMessage.tsx @@ -0,0 +1,31 @@ +import type { CreateTeamFeedback } from '../_interfaces/feedback'; +import styles from '../page.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/addteam/_components/JoinTeamCard.tsx b/src/app/addteam/_components/JoinTeamCard.tsx new file mode 100644 index 0000000..19558ee --- /dev/null +++ b/src/app/addteam/_components/JoinTeamCard.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { BaseButton } from '@/components/Button/base'; +import { Input } from '@/components/input'; +import styles from '../page.module.css'; + +interface JoinTeamCardProps { + teamLink: string; + onTeamLinkChange: (value: string) => void; +} + +export default function JoinTeamCard({ teamLink, onTeamLinkChange }: JoinTeamCardProps) { + const helperTextId = 'join-team-helper-text'; + + return ( +
+

팀 참여하기

+ +
+ + onTeamLinkChange(event.target.value)} + aria-describedby={helperTextId} + placeholder="팀 링크를 입력해주세요." + className={styles.teamLinkInput} + /> +
+ + 참여하기 + +

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

+
+ ); +} diff --git a/src/app/addteam/_components/NoTeamState.tsx b/src/app/addteam/_components/NoTeamState.tsx index f7dcb5d..235da80 100644 --- a/src/app/addteam/_components/NoTeamState.tsx +++ b/src/app/addteam/_components/NoTeamState.tsx @@ -3,25 +3,36 @@ import Image from 'next/image'; import { BaseButton } from '@/components/Button/base'; import noTeamImage from '../svg/noTeamImg.svg'; -import styles from './NoTeamState.module.css'; +import styles from '../page.module.css'; -export default function NoTeamState() { +interface NoTeamStateProps { + onCreateTeamClick: () => void; + onJoinTeamClick: () => void; +} + +export default function NoTeamState({ onCreateTeamClick, onJoinTeamClick }: NoTeamStateProps) { return ( -
+
소속된 팀이 없는 상태 일러스트 -

+

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

-
- 팀 생성하기 - +
+ + 팀 생성하기 + + 팀 참가하기
From 0f8b98e55b96d4416191ff304b2b0583c2cf4411 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:45:48 +0900 Subject: [PATCH 17/24] =?UTF-8?q?chore:=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/_interfaces/feedback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/addteam/_interfaces/feedback.ts b/src/app/addteam/_interfaces/feedback.ts index 82f9b94..fbc5d6d 100644 --- a/src/app/addteam/_interfaces/feedback.ts +++ b/src/app/addteam/_interfaces/feedback.ts @@ -1,4 +1,4 @@ -export interface Feedback { +export interface CreateTeamFeedback { type: 'success' | 'error'; message: string; } From be7e44cd7f71affa302188325bb4b61621b86b4a Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:46:21 +0900 Subject: [PATCH 18/24] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EB=A7=A4=EC=A7=81=EB=84=98=EB=B2=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_utils/getCreateTeamFailureMessage.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/addteam/_utils/getCreateTeamFailureMessage.ts b/src/app/addteam/_utils/getCreateTeamFailureMessage.ts index dec4b33..a3bbe65 100644 --- a/src/app/addteam/_utils/getCreateTeamFailureMessage.ts +++ b/src/app/addteam/_utils/getCreateTeamFailureMessage.ts @@ -1,26 +1,36 @@ 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; } - if (error.message === CREATE_TEAM_MESSAGES.emptyTeamNameError) { + const { message } = error; + + if (message === CREATE_TEAM_MESSAGES.emptyTeamNameError) { return CREATE_TEAM_MESSAGES.emptyTeamNameError; } if ( - error.message === CREATE_TEAM_MESSAGES.duplicatedTeamNameError || - error.message.includes('status: 409') + message === CREATE_TEAM_MESSAGES.duplicatedTeamNameError || + hasStatusMessage(message, DUPLICATED_STATUS) ) { return CREATE_TEAM_MESSAGES.duplicatedTeamNameFailure; } - if (error.message.includes('status: 400')) { + if (hasStatusMessage(message, INVALID_REQUEST_STATUS)) { return CREATE_TEAM_MESSAGES.invalidRequestFailure; } - if (error.message.includes('status: 401') || error.message.includes('status: 403')) { + if (UNAUTHORIZED_STATUSES.some((status) => hasStatusMessage(message, status))) { return CREATE_TEAM_MESSAGES.unauthorizedFailure; } From a185bd32656dd64e691b0b10434f4ec7058732a2 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 17:46:42 +0900 Subject: [PATCH 19/24] =?UTF-8?q?refactor:=20=EB=B6=84=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/page.module.css | 220 ++++++++++++++++++++++++++------ src/app/addteam/page.tsx | 123 ++++++------------ 2 files changed, 224 insertions(+), 119 deletions(-) diff --git a/src/app/addteam/page.module.css b/src/app/addteam/page.module.css index 4dd5187..31c3b58 100644 --- a/src/app/addteam/page.module.css +++ b/src/app/addteam/page.module.css @@ -1,26 +1,35 @@ .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); } -.mobileGnb { +.mobileOnlyHeader { display: none; } .mainContents { - flex: 1; display: flex; align-items: center; justify-content: center; - padding: 24px; + flex: 1; + padding: var(--layout-padding-desktop); } .card { - width: min(90vw, 480px); - padding: 32px 28px; display: flex; flex-direction: column; + width: var(--create-card-width-desktop); + padding: 32px 28px; gap: 20px; border-radius: 24px; box-shadow: 4px 4px 10px 0 #24242440; @@ -29,17 +38,17 @@ .title { margin: 0; + color: var(--color-text-primary); font-size: 24px; font-weight: 700; line-height: 28px; - color: var(--color-text-primary); } .profileSection { display: flex; flex-direction: column; - gap: 12px; align-items: center; + gap: 12px; } .inputSection { @@ -50,10 +59,10 @@ .label { margin: 0; + color: var(--color-text-secondary); font-size: 16px; font-weight: 500; line-height: 19px; - color: var(--color-text-secondary); } .helperText { @@ -68,9 +77,9 @@ margin: 0; color: var(--color-status-danger); font-size: 14px; + font-weight: 600; line-height: 1.5; text-align: center; - font-weight: 600; } .successText { @@ -81,56 +90,133 @@ text-align: center; } +.joinCard { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: var(--join-card-width-desktop); + height: 400px; + padding: 59px 45px 63px; + border-radius: 20px; + background: var(--color-background-inverse); +} + +.joinInputSection { + display: flex; + flex-direction: column; + width: var(--join-input-width-desktop); + margin-top: 40px; + gap: 8px; +} + +.joinLabel { + margin: 0; + color: var(--color-text-primary); + font-size: 16px; + font-weight: 500; + line-height: 19px; +} + +.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; +} + +.noTeamState { + display: flex; + flex-direction: column; + align-items: center; + 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 { + display: flex; + flex-direction: column; + width: min(100%, 370px); + margin-top: 48px; + gap: 16px; +} + +.noTeamActionButton { + height: var(--control-height-desktop); +} + @media (max-width: 767px) { .page { flex-direction: column; } - .mobileGnb { + .mobileOnlyHeader { display: block; width: 100%; align-self: stretch; } - .mobileGnb :global(header) { + .mobileOnlyHeader :global(header) { display: flex; + align-items: center; + justify-content: space-between; width: 100%; height: 52px; padding: 12px 16px; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--Border-Primary, #e2e8f0); - background: var(--Background-Primary, #fff); + border-bottom: 1px solid var(--color-border-primary, #e2e8f0); + background: var(--color-background-inverse); } - .mobileGnb :global(header) > div { + .mobileOnlyHeader :global(header) > div { display: flex; - width: 102px; align-items: center; + width: 102px; gap: 2px; flex-shrink: 0; } .mainContents { width: 100%; - padding: 16px; + padding: var(--layout-padding-mobile); } .card { - width: 343px; - height: 464px; + width: var(--create-card-width-mobile); + min-height: 464px; padding: 24px 22px; gap: 0; border-radius: 24px; } .title { - margin: 0; - color: var(--Text-Primary, #1e293b); - font-family: Pretendard, sans-serif; + color: var(--color-text-primary); font-size: 20px; - font-style: normal; - font-weight: 700; line-height: 24px; } @@ -144,26 +230,19 @@ } .label { - color: var(--Text-Primary, #1e293b); - font-family: Pretendard, sans-serif; + color: var(--color-text-primary); font-size: 14px; - font-style: normal; - font-weight: 500; line-height: 17px; } .helperText { - color: var(--Text-Default, #64748b); - text-align: center; - font-family: Pretendard, sans-serif; + color: var(--color-text-default); font-size: 12px; - font-style: normal; - font-weight: 400; line-height: 14px; } .teamNameInput { - height: 44px; + height: var(--control-height-mobile); padding: 12px; font-size: 14px; line-height: 17px; @@ -171,7 +250,7 @@ .submitButton { margin-top: 40px; - height: 44px; + height: var(--control-height-mobile); font-size: 14px; } @@ -180,4 +259,73 @@ .successText { margin-top: 16px; } + + .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; + } + + .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; + } + + .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/addteam/page.tsx b/src/app/addteam/page.tsx index c4f0352..a98018a 100644 --- a/src/app/addteam/page.tsx +++ b/src/app/addteam/page.tsx @@ -1,50 +1,24 @@ 'use client'; -import { useState } from 'react'; -import { BaseButton } from '@/components/Button/base'; -import { Input } from '@/components/input'; -import { ProfileImage } from '@/components/profile-img'; +import { type ReactNode, useState } from 'react'; import { MobileHeader, Sidebar } from '@/components/sidebar'; +import CreateTeamCard from './_components/CreateTeamCard'; +import JoinTeamCard from './_components/JoinTeamCard'; +import NoTeamState from './_components/NoTeamState'; +import { CREATE_TEAM_MESSAGES, ENABLE_NO_TEAM_STATE_PREVIEW } from './_constants/createTeam'; import { useCreateTeam } from './_hooks/useCreateTeam'; +import type { CreateTeamFeedback } from './_interfaces/feedback'; +import { getCreateTeamFailureMessage } from './_utils/getCreateTeamFailureMessage'; import styles from './page.module.css'; -const TEAM_CREATED_MESSAGE = '팀이 생성되었습니다.'; -const EMPTY_TEAM_NAME_ERROR = '팀 이름을 입력해주세요.'; -const DUPLICATED_TEAM_NAME_ERROR = '중복된 팀 이름입니다.'; -const DEFAULT_CREATE_TEAM_ERROR = '팀 생성에 실패했어요. 잠시 후 다시 시도해주세요.'; - -type Feedback = { - type: 'success' | 'error'; - message: string; -}; - -function getCreateTeamFailureMessage(error: unknown) { - if (!(error instanceof Error)) { - return DEFAULT_CREATE_TEAM_ERROR; - } - - if (error.message === EMPTY_TEAM_NAME_ERROR) { - return EMPTY_TEAM_NAME_ERROR; - } - - if (error.message === DUPLICATED_TEAM_NAME_ERROR || error.message.includes('status: 409')) { - return '이미 존재하는 팀 이름이라 생성에 실패했어요.'; - } - - if (error.message.includes('status: 400')) { - return '요청 값이 올바르지 않아 팀 생성에 실패했어요.'; - } - - if (error.message.includes('status: 401') || error.message.includes('status: 403')) { - return '팀을 생성할 권한이 없어 실패했어요.'; - } - - return DEFAULT_CREATE_TEAM_ERROR; -} +type AddTeamView = 'empty' | 'create' | 'join'; +const INITIAL_VIEW: AddTeamView = ENABLE_NO_TEAM_STATE_PREVIEW ? 'empty' : 'create'; export default function AddTeamPage() { const [teamName, setTeamName] = useState(''); - const [feedback, setFeedback] = useState(null); + const [teamLink, setTeamLink] = useState(''); + const [createTeamFeedback, setCreateTeamFeedback] = useState(null); + const [view, setView] = useState(INITIAL_VIEW); const { createTeam, isPending } = useCreateTeam(); const isSubmitDisabled = !teamName.trim() || isPending; @@ -55,60 +29,43 @@ export default function AddTeamPage() { try { await createTeam(teamName); setTeamName(''); - setFeedback({ type: 'success', message: TEAM_CREATED_MESSAGE }); + setCreateTeamFeedback({ type: 'success', message: CREATE_TEAM_MESSAGES.success }); } catch (error) { - setFeedback({ type: 'error', message: getCreateTeamFailureMessage(error) }); + setCreateTeamFeedback({ type: 'error', message: getCreateTeamFailureMessage(error) }); } }; + const handleGoCreateView = () => setView('create'); + const handleGoJoinView = () => setView('join'); + + const handleTeamNameChange = (value: string) => { + setTeamName(value); + setCreateTeamFeedback(null); + }; + + const contentByView = { + create: ( + + ), + join: , + empty: ( + + ), + } satisfies Record; + return (
-
+
-
-
-

팀 생성하기

- -
- -
- -
- - setTeamName(e.target.value)} - placeholder="팀 이름을 입력해주세요" - className={styles.teamNameInput} - /> -
- - void handleSubmit()} - > - 생성하기 - - - {feedback?.type === 'error' ? ( -

- {feedback.message} -

- ) : feedback?.type === 'success' ? ( -

{feedback.message}

- ) : ( -

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

- )} -
-
+
{contentByView[view]}
); } From 075f6d7e0705602e63f022302658138b78c6bdaa Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 19:22:52 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix:=20svg=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9B=90=EC=83=81=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/addteam/{_svg => svg}/noTeamImg.svg | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/app/addteam/{_svg => svg}/noTeamImg.svg (100%) diff --git a/src/app/addteam/_svg/noTeamImg.svg b/src/app/addteam/svg/noTeamImg.svg similarity index 100% rename from src/app/addteam/_svg/noTeamImg.svg rename to src/app/addteam/svg/noTeamImg.svg From 53a7bfd12d280b621dc592ac2e04a31033d52b27 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 16 Feb 2026 19:23:16 +0900 Subject: [PATCH 21/24] =?UTF-8?q?refactor:=20css=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/CreateTeamCard.module.css | 57 ++++++++++++++ .../addteam/_components/CreateTeamCard.tsx | 19 +++-- .../_components/FeedbackMessage.module.css | 37 +++++++++ .../addteam/_components/FeedbackMessage.tsx | 8 +- .../_components/JoinTeamCard.module.css | 78 +++++++++++++++++++ src/app/addteam/_components/JoinTeamCard.tsx | 19 +++-- .../_components/NoTeamState.module.css | 58 ++++++++++++++ src/app/addteam/_components/NoTeamState.tsx | 16 ++-- src/app/addteam/_styles/common.module.css | 21 +++++ 9 files changed, 286 insertions(+), 27 deletions(-) create mode 100644 src/app/addteam/_components/CreateTeamCard.module.css create mode 100644 src/app/addteam/_components/FeedbackMessage.module.css create mode 100644 src/app/addteam/_components/JoinTeamCard.module.css create mode 100644 src/app/addteam/_components/NoTeamState.module.css create mode 100644 src/app/addteam/_styles/common.module.css diff --git a/src/app/addteam/_components/CreateTeamCard.module.css b/src/app/addteam/_components/CreateTeamCard.module.css new file mode 100644 index 0000000..cbb8466 --- /dev/null +++ b/src/app/addteam/_components/CreateTeamCard.module.css @@ -0,0 +1,57 @@ +.card { + 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) { + .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/addteam/_components/CreateTeamCard.tsx b/src/app/addteam/_components/CreateTeamCard.tsx index 8fbd711..ec4aaef 100644 --- a/src/app/addteam/_components/CreateTeamCard.tsx +++ b/src/app/addteam/_components/CreateTeamCard.tsx @@ -6,7 +6,10 @@ import { Input } from '@/components/input'; import { ProfileImage } from '@/components/profile-img'; import type { CreateTeamFeedback } from '../_interfaces/feedback'; import FeedbackMessage from './FeedbackMessage'; -import styles from '../page.module.css'; +import pageStyles from '../page.module.css'; +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'; @@ -31,15 +34,15 @@ export default function CreateTeamCard({ }; return ( -
-

팀 생성하기

+ +

팀 생성하기

-
+
-
-