diff --git a/CLAUDE.md b/CLAUDE.md index 38d86d5..246c94a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,12 @@ StartPage (활성 스프린트 여부 분기) └─ NoticePage (안내) └─ SprintCodePage (인증 코드 입력) └─ SprintIntroPage (스프린트 소개) - └─ UserInfoPage (사용자 정보 입력) - └─ (회고 설문 스텝들 — 추후 구현 예정) + └─ UserInfoPage (사용자 정보 입력, step 1/7) + └─ StopCommentPage (step 2/7) + └─ StartCommentPage (step 3/7) + └─ ContinueCommentPage (step 4/7) + └─ MvpPage (step 6/7) + └─ ClosingPage (step 7/7) ``` --- @@ -23,29 +27,18 @@ StartPage (활성 스프린트 여부 분기) ``` src/ ├── assets/ # 정적 이미지 리소스 -├── components/ # 공통 재사용 컴포넌트 -│ ├── CodeInput # 인증 코드 입력 필드 -│ ├── ContentHeading # 페이지 제목 영역 -│ ├── ImageSection # 이미지 섹션 -│ ├── PageLayout # 전체 페이지 래퍼 -│ ├── ProgressBar # 스텝 진행 바 -│ ├── SelectField # 셀렉트 폼 필드 -│ └── StepLayout # 스텝 기반 레이아웃 (하단 버튼 포함) +├── components/ # 공통 재사용 컴포넌트 (상세 구조는 📦 패키지 구조 참고) ├── context/ -│ └── SubmissionContext.tsx # 설문 제출 데이터 전역 상태 -├── hooks/ -│ └── useErrorHandler.ts # 에러 핸들링 커스텀 훅 +│ └── CommentFormContext.tsx # 설문 폼 전역 상태 (CommentFormProvider) +├── hooks/ # 커스텀 훅 (useCommentForm, usePeerMembers, useErrorHandler) ├── lib/ -│ ├── api/ -│ │ ├── chapter.ts # 챕터 API -│ │ ├── comment.ts # 댓글(회고) API -│ │ ├── sprint.ts # 스프린트 API -│ │ └── user.ts # 유저 API -│ ├── apiClient.ts # Supabase 호출 래퍼 (에러 분류) -│ ├── errors.ts # 커스텀 에러 클래스 -│ └── supabase.ts # Supabase 클라이언트 초기화 +│ ├── api/ # 도메인별 API (comment, sprint, sprintPeers, chapter, user) +│ ├── apiClient.ts # Supabase 호출 래퍼 (에러 분류) +│ ├── errors.ts # 커스텀 에러 클래스 +│ └── supabase.ts # Supabase 클라이언트 초기화 ├── pages/ # 라우트 단위 페이지 컴포넌트 ├── types/ # TypeScript 타입 정의 +├── utils/ # 순수 비즈니스 로직 유틸 ├── App.tsx # 라우터 설정 진입점 └── main.tsx # 앱 마운트 ``` @@ -59,7 +52,7 @@ src/ | 프레임워크 | React 18 + TypeScript | | 빌드 | Vite | | 라우팅 | React Router v7 | -| 상태 관리 | React Context (`SubmissionContext`) + Zustand (필요 시) | +| 상태 관리 | React Context (`CommentFormContext`) + Zustand (필요 시) | | 백엔드/DB | Supabase (RPC 기반 쿼리) | | 스타일링 | vanilla-extract (`*.css.ts`) | | 디자인 시스템 | `@sopt-makers/ui`, `@sopt-makers/colors`, `@sopt-makers/fonts`, `@sopt-makers/icons` | @@ -111,6 +104,7 @@ src/ │ ├── StopCommentPage │ ├── StartCommentPage │ ├── ContinueCommentPage +│ ├── MvpPage │ ├── ClosingPage │ └── ErrorPage ├── types/ → 도메인 타입 정의 @@ -194,9 +188,9 @@ src/ ## 상태 관리 규칙 -- 설문 제출 데이터는 `SubmissionContext` (`src/context/SubmissionContext.tsx`)를 통해 관리 -- 페이지 간 데이터 전달은 `location.state` 또는 `SubmissionContext` 사용 -- Zustand는 SubmissionContext로 처리하기 어려운 전역 상태에만 도입 +- 설문 폼 데이터는 `CommentFormContext` (`src/context/CommentFormContext.tsx`)를 통해 관리 +- 페이지 간 데이터 전달은 `location.state` 또는 `CommentFormContext` 사용 +- Zustand는 CommentFormContext로 처리하기 어려운 전역 상태에만 도입 --- @@ -217,7 +211,7 @@ src/ 1. `src/pages/` 아래 `XxxPage.tsx` + `XxxPage.css.ts` 생성 2. `src/App.tsx` 라우터에 경로 등록 -3. 필요한 경우 `SubmissionContext` 타입 확장 +3. 필요한 경우 `src/types/comment.ts`의 `CommentFormState` 타입 확장 ### 에러 처리 diff --git a/src/App.tsx b/src/App.tsx index c89bc34..7e2f240 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import UserInfoPage from '@pages/UserInfoPage'; import StopCommentPage from '@pages/StopCommentPage'; import StartCommentPage from '@pages/StartCommentPage'; import ContinueCommentPage from '@pages/ContinueCommentPage'; +import MvpPage from '@pages/MvpPage'; import ClosingPage from '@pages/ClosingPage'; const router = createBrowserRouter([ @@ -29,6 +30,7 @@ const router = createBrowserRouter([ { path: '/stop-comment', element: }, { path: '/start-comment', element: }, { path: '/continue-comment', element: }, + { path: '/mvp', element: }, { path: '/closing', element: }, ]); diff --git a/src/components/peer-comment/PeerCommentStepTemplate.tsx b/src/components/peer-comment/PeerCommentStepTemplate.tsx index b7fdcf9..b36cc70 100644 --- a/src/components/peer-comment/PeerCommentStepTemplate.tsx +++ b/src/components/peer-comment/PeerCommentStepTemplate.tsx @@ -30,7 +30,7 @@ function submissionPatch( function PeerCommentStepTemplate({ content, currentStep, - totalSteps = 7, + totalSteps = 6, nextPath, }: PeerCommentStepTemplateProps) { const { title, description, guideImages } = content; diff --git a/src/pages/ClosingPage.tsx b/src/pages/ClosingPage.tsx index 580c5f7..de96f5f 100644 --- a/src/pages/ClosingPage.tsx +++ b/src/pages/ClosingPage.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { StepLayout } from '../components'; -import headerImg from '../assets/header_img.png'; import endingImg from '../assets/ending.svg'; import * as styles from './ClosingPage.css'; @@ -14,10 +13,9 @@ function ClosingPage() { return ( ); } diff --git a/src/pages/MvpPage.css.ts b/src/pages/MvpPage.css.ts new file mode 100644 index 0000000..cb7dcbd --- /dev/null +++ b/src/pages/MvpPage.css.ts @@ -0,0 +1,124 @@ +import { style } from '@vanilla-extract/css'; +import { colors } from '@sopt-makers/colors'; +import { fontsObject } from '@sopt-makers/fonts'; + +export const body = style({ + display: 'flex', + flexDirection: 'column', + gap: 32, + color: colors.white, +}); + +export const fields = style({ + display: 'flex', + flexDirection: 'column', + gap: 28, +}); + +export const fieldGroup = style({ + display: 'flex', + flexDirection: 'column', + gap: 8, +}); + +export const searchContainer = style({ + position: 'relative', + marginTop: 4, +}); + +export const inputWrapper = style({ + display: 'flex', + alignItems: 'center', + backgroundColor: colors.gray800, + borderRadius: 6, + padding: '12px 14px', + gap: 8, +}); + +export const searchInput = style({ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + color: colors.white, + ...fontsObject.BODY_3_14_M, + selectors: { + '&::placeholder': { + color: colors.gray400, + }, + }, +}); + +export const clearButton = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + border: 'none', + background: 'transparent', + cursor: 'pointer', + flexShrink: 0, +}); + +export const dropdown = style({ + position: 'absolute', + top: '100%', + left: 0, + right: 0, + backgroundColor: colors.gray800, + borderRadius: 6, + marginTop: 4, + zIndex: 10, + overflow: 'hidden', + listStyle: 'none', + padding: 0, +}); + +export const dropdownItem = style({ + display: 'flex', + alignItems: 'center', + gap: 8, + width: '100%', + padding: '10px 14px', + border: 'none', + background: 'transparent', + cursor: 'pointer', + color: colors.white, + ...fontsObject.BODY_3_14_M, + textAlign: 'left', + selectors: { + '&:hover': { + backgroundColor: colors.gray700, + }, + }, +}); + +export const avatarIcon = style({ + width: 28, + height: 28, + borderRadius: '50%', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + backgroundColor: colors.gray700, + color: colors.gray500, +}); + +export const chipWrapper = style({ + marginTop: 4, + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', +}); + +export const clearIcon = style({ + width: 20, + height: 20, + color: colors.gray50, +}); + +export const avatarIconSvg = style({ + width: 20, + height: 20, +}); diff --git a/src/pages/MvpPage.tsx b/src/pages/MvpPage.tsx new file mode 100644 index 0000000..29fec8b --- /dev/null +++ b/src/pages/MvpPage.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FieldBox } from '@sopt-makers/ui'; +import { IconUser, IconXCircle } from '@sopt-makers/icons'; +import { StepLayout, ContentHeading, MemberChip, InputField } from '@components'; +import { usePeerMembers, useCommentForm } from '@hooks'; +import type { PeerMember } from '@types'; +import * as styles from './MvpPage.css'; + +function MvpPage() { + const navigate = useNavigate(); + const { update } = useCommentForm(); + const peerMembers = usePeerMembers(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedMember, setSelectedMember] = useState(null); + const [reason, setReason] = useState(''); + + const filteredMembers = searchQuery + ? peerMembers.filter((m) => m.name.includes(searchQuery)) + : []; + + const isAllFilled = selectedMember !== null && reason.trim().length > 0; + + const handleSelectMember = (member: PeerMember) => { + setSelectedMember(member); + setSearchQuery(''); + }; + + const handleSubmit = () => { + if (!selectedMember) return; + update({ mvp: { target_user_id: selectedMember.userId, comment_text: reason } }); + navigate('/closing'); + }; + + return ( + +
+ + 마지막이에요! +
+ 이번 스프린트에서 뛰어난 모습을 보여주었던 동료가 있다면 +
+ 이름과 이유를 작성해주세요. + + } + /> + +
+
+ + +
+
+ ) => setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+ + {searchQuery && filteredMembers.length > 0 && ( +
    + {filteredMembers.map((member) => ( +
  • + +
  • + ))} +
+ )} +
+ + {selectedMember && ( +
+ +
+ )} +
+ + +
+
+
+ ); +} + +export default MvpPage; diff --git a/src/pages/SprintCodePage.tsx b/src/pages/SprintCodePage.tsx index b150622..111b60a 100644 --- a/src/pages/SprintCodePage.tsx +++ b/src/pages/SprintCodePage.tsx @@ -38,7 +38,7 @@ function SprintCodePage() { isNextDisabled={code.length !== SPRINT_CODE_LENGTH || showError} showProgressBar={true} currentStep={0} - totalSteps={7} + totalSteps={6} > diff --git a/src/pages/SprintIntroPage.tsx b/src/pages/SprintIntroPage.tsx index c11ff90..e3a0998 100644 --- a/src/pages/SprintIntroPage.tsx +++ b/src/pages/SprintIntroPage.tsx @@ -22,7 +22,7 @@ function SprintIntroPage() { onNext={handleStart} showProgressBar={true} currentStep={0} - totalSteps={7} + totalSteps={6} >
diff --git a/src/pages/UserInfoPage.tsx b/src/pages/UserInfoPage.tsx index b7a0559..86a4606 100644 --- a/src/pages/UserInfoPage.tsx +++ b/src/pages/UserInfoPage.tsx @@ -54,7 +54,7 @@ function UserInfoPage() { isNextDisabled={!isAllFilled} showProgressBar={true} currentStep={1} - totalSteps={7} + totalSteps={6} >