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=... - diff --git a/package.json b/package.json index 774e4bd..8dbecdc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.90.21", "clsx": "^2.1.1", "framer-motion": "^12.34.0", "next": "16.1.3", diff --git a/src/app/addteam/_domain/components/CreateTeamCard.module.css b/src/app/addteam/_domain/components/CreateTeamCard.module.css new file mode 100644 index 0000000..89802e6 --- /dev/null +++ b/src/app/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/addteam/_domain/components/CreateTeamCard.tsx b/src/app/addteam/_domain/components/CreateTeamCard.tsx new file mode 100644 index 0000000..ba39122 --- /dev/null +++ b/src/app/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/addteam/_domain/components/FeedbackMessage.module.css b/src/app/addteam/_domain/components/FeedbackMessage.module.css new file mode 100644 index 0000000..ff30b87 --- /dev/null +++ b/src/app/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/addteam/_domain/components/FeedbackMessage.tsx b/src/app/addteam/_domain/components/FeedbackMessage.tsx new file mode 100644 index 0000000..454dc1e --- /dev/null +++ b/src/app/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/addteam/_domain/components/JoinTeamCard.module.css b/src/app/addteam/_domain/components/JoinTeamCard.module.css new file mode 100644 index 0000000..1ebdd5b --- /dev/null +++ b/src/app/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/addteam/_domain/components/JoinTeamCard.tsx b/src/app/addteam/_domain/components/JoinTeamCard.tsx new file mode 100644 index 0000000..2d7b9ae --- /dev/null +++ b/src/app/addteam/_domain/components/JoinTeamCard.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { BaseButton } from '@/components/Button/base'; +import { Input } from '@/components/input'; +import joinCardStyles from './JoinTeamCard.module.css'; +import clsx from 'clsx'; +import commonStyles from '../styles/common.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={joinCardStyles.teamLinkInput} + /> +
+ + 참여하기 + +

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

+
+ ); +} diff --git a/src/app/addteam/_domain/components/NoTeamState.module.css b/src/app/addteam/_domain/components/NoTeamState.module.css new file mode 100644 index 0000000..15cc22a --- /dev/null +++ b/src/app/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/addteam/_domain/components/NoTeamState.tsx b/src/app/addteam/_domain/components/NoTeamState.tsx new file mode 100644 index 0000000..23652e1 --- /dev/null +++ b/src/app/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/addteam/_domain/constants/createTeam.ts b/src/app/addteam/_domain/constants/createTeam.ts new file mode 100644 index 0000000..a7b1864 --- /dev/null +++ b/src/app/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/addteam/_domain/hooks/useCreateTeam.ts b/src/app/addteam/_domain/hooks/useCreateTeam.ts new file mode 100644 index 0000000..0f984a1 --- /dev/null +++ b/src/app/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/addteam/_domain/interfaces/feedback.ts b/src/app/addteam/_domain/interfaces/feedback.ts new file mode 100644 index 0000000..fbc5d6d --- /dev/null +++ b/src/app/addteam/_domain/interfaces/feedback.ts @@ -0,0 +1,4 @@ +export interface CreateTeamFeedback { + type: 'success' | 'error'; + message: string; +} diff --git a/src/app/addteam/_domain/styles/common.module.css b/src/app/addteam/_domain/styles/common.module.css new file mode 100644 index 0000000..e9f86bb --- /dev/null +++ b/src/app/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/addteam/_domain/svg/noTeamImg.svg b/src/app/addteam/_domain/svg/noTeamImg.svg new file mode 100644 index 0000000..6ade2c6 --- /dev/null +++ b/src/app/addteam/_domain/svg/noTeamImg.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/addteam/_domain/utils/duplicationCalculator.ts b/src/app/addteam/_domain/utils/duplicationCalculator.ts new file mode 100644 index 0000000..4a83666 --- /dev/null +++ b/src/app/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/addteam/_domain/utils/getCreateTeamFailureMessage.ts b/src/app/addteam/_domain/utils/getCreateTeamFailureMessage.ts new file mode 100644 index 0000000..3a39843 --- /dev/null +++ b/src/app/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/addteam/create/page.tsx b/src/app/addteam/create/page.tsx new file mode 100644 index 0000000..13d0b05 --- /dev/null +++ b/src/app/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(`/teams/${group.id}`); + } catch (error) { + setCreateTeamFeedback({ type: 'error', message: getCreateTeamFailureMessage(error) }); + } + }; + + const handleTeamNameChange = (value: string) => { + setTeamName(value); + setCreateTeamFeedback(null); + }; + + return ( + + ); +} diff --git a/src/app/addteam/join/page.tsx b/src/app/addteam/join/page.tsx new file mode 100644 index 0000000..504affc --- /dev/null +++ b/src/app/addteam/join/page.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useState } from 'react'; +import JoinTeamCard from '../_domain/components/JoinTeamCard'; + +export default function JoinTeamPage() { + const [teamLink, setTeamLink] = useState(''); + + return ; +} diff --git a/src/app/addteam/layout.tsx b/src/app/addteam/layout.tsx new file mode 100644 index 0000000..5167758 --- /dev/null +++ b/src/app/addteam/layout.tsx @@ -0,0 +1,14 @@ +import { MobileHeader, Sidebar } from '@/components/sidebar'; +import styles from './page.module.css'; + +export default function AddTeamLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
+
{children}
+
+ ); +} diff --git a/src/app/addteam/page.module.css b/src/app/addteam/page.module.css new file mode 100644 index 0000000..dab7364 --- /dev/null +++ b/src/app/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/addteam/page.tsx b/src/app/addteam/page.tsx new file mode 100644 index 0000000..9ca589a --- /dev/null +++ b/src/app/addteam/page.tsx @@ -0,0 +1,5 @@ +import NoTeamState from './_domain/components/NoTeamState'; + +export default function AddTeamPage() { + return ; +} 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}; +} 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, }); } 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 }); + }, + }); +}