diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8211207 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# 1. Tech Stack & Environment (Default) +- Frontend: React (Functional Components, Hooks 중심. 함수 선언 스타일은 Coding Standards의 함수 선언 스타일 규칙을 따른다.) +- Style: Tailwind CSS + Shadcn UI (Maintain Design Consistency) +- Language: TypeScript (Strict mode, No `any`) +- Routing: React Router v7 +- State: Zustand (UI State), TanStack Query (Server State) +- Networking: Axios (with Interceptors), MSW (Mocking Sync required) + +# 2. Coding Standards (Quality & Readability) +- "읽기 쉬운 코드" 최우선: 변수명은 직관적으로, 로직은 단순하게 작성할 것. +- **주석 작성 원칙**: + - 주석은 코드가 "무엇을 하는지"보다 "왜 이렇게 처리하는지"를 설명한다. + - 코드만으로 의도가 분명한 단순 로직에는 주석을 달지 않는다. + - 화면, 훅, mock, API처럼 흐름이 긴 파일은 기존 코드처럼 번호나 짧은 제목으로 구획을 나눈다. + - 예: `// 1. 데이터 가져오기 및 권한 확인`, `{/* 참여자 명단 섹션 */}` + - 제품 정책, 서버 응답 보완, 캐시 동기화, 예외 처리, mock이 실제 백엔드 정책을 흉내 내는 부분에는 짧은 한국어 주석을 남긴다. + - 주석은 기본적으로 1~2줄로 작성하고, 긴 설명은 Plan 문서나 별도 문서에 남긴다. + - 오래된 주석, 주석 처리된 미사용 코드, 코드와 맞지 않는 설명은 수정 과정에서 함께 정리한다. +- 모듈화: 하나의 파일이 너무 길어지지 않게 기능별로 분리할 것. +- 에러 처리: 예외 상황(Error Handling)을 항상 고려하여 코드를 작성할 것. +- **Strict Typing**: `any` 사용을 엄격히 금지하며, 모든 API 응답과 Props에 대해 구체적인 interface/type을 정의할 것. +- **데이터 관리 원칙**: 서버 데이터(Server State)는 TanStack Query를, 순수 UI 상태나 전역 캐싱(Client State)은 Zustand를 사용하여 역할을 엄격히 분리할 것. +- **MSW Sync**: 새로운 API 연동이나 수정 시, 반드시 `src/mocks` 내의 모킹 핸들러와 데이터도 세트로 업데이트할 것. +- **코드 검토 및 품질 관리**: + - 코드 검토(타입 체크, 포매팅 검사 등)가 필요할 때는 `yarn check-all` 명령어를 사용할 것. + - 코드 포매팅이 필요한 경우 `yarn fix` 명령어를 사용할 것. +- **함수 선언 스타일**: + - React 컴포넌트는 `function` 키워드로 선언한다. + - 예: `export default function EventMain() { ... }` + - 커스텀 훅은 기존 코드베이스 패턴에 맞춰 `function` 키워드로 선언한다. + - 예: `export default function useEventDetail(...) { ... }` + - 컴포넌트/훅 내부의 이벤트 핸들러, 비즈니스 로직, 유틸리티 함수는 화살표 함수로 작성한다. + - 예: `const handleSubmit = async () => { ... }` + - 컴포넌트가 아닌 export 유틸리티는 화살표 함수로 작성한다. + - 예: `export const formatDate = (...) => { ... }` + + +# 3. Work Process (Mandatory File-based Planning) +- **Step-by-Step Approach**: 코드를 수정하기 전, 반드시 다음 두 파일을 생성/업데이트하여 제시한다. + 1. **[Implementation Plan]**: 구체적인 수정 범위와 로직을 한국어로 기술한 문서. + 2. **[Task]**: 체크박스(`- [ ]`) 형태의 세부 작업 리스트 파일. +- **Permission Required**: 위 두 파일이 생성되고, 사용자의 **승인(Confirmation)**을 받은 후에만 실제 코드 수정을 시작한다. +- **Progress Tracking**: 작업이 진행됨에 따라 [Task] 파일의 체크박스를 업데이트하여 진행 상황을 공유한다. + +# 4. Communication & Persona +- 언어: 모든 설명과 주석, 작업 계획(Plan)은 **'한국어'**로 작성. +- 설명 방식: 초보자도 이해할 수 있게 쉽게 설명하되, 비즈니스 로직과 구조를 명확히 짚어줄 것. +- 태도: 단순히 코드만 짜지 말고, 내 요청에 잠재된 '리스크'나 더 좋은 '대안'이 있다면 먼저 제안해주는 파트너가 될 것. +- 답변 형식: [결론/해결책] -> [코드] -> [상세 설명] 순서로 두괄식으로 답변할 것. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/hooks/useAuthStore.ts b/src/hooks/useAuthStore.ts index 4c55606..d4de0e0 100644 --- a/src/hooks/useAuthStore.ts +++ b/src/hooks/useAuthStore.ts @@ -18,6 +18,8 @@ interface AuthState { setGuestRegistration: (eventId: string, registrationId: string) => void; // 신청 취소 시 정보를 삭제하는 액션 removeGuestRegistration: (eventId: string) => void; + // 인증 상태가 바뀔 때 비로그인 신청 정보를 모두 삭제하는 액션 + clearGuestRegistrations: () => void; // 소셜 로그인 및 회원가입 리다이렉트를 위한 상태 redirectUrl: string | null; redirectTimestamp: number | null; @@ -40,12 +42,16 @@ const useAuthStore = create()( user, token, isLoggedIn: true, + // 로그인 계정 상태와 브라우저에 남은 비로그인 신청 상태가 섞이지 않게 합니다. + guestRegistrations: {}, }), logout: () => set({ user: null, token: null, isLoggedIn: false, + // 로그아웃 이후에는 이전 계정/브라우저 신청 상태를 이어 보지 않습니다. + guestRegistrations: {}, }), updateUser: (user) => set({ user }), setGuestRegistration: (eventId, registrationId) => @@ -65,6 +71,7 @@ const useAuthStore = create()( delete newRegistrations[eventId]; return { guestRegistrations: newRegistrations }; }), + clearGuestRegistrations: () => set({ guestRegistrations: {} }), setRedirectUrl: (url) => set({ diff --git a/src/hooks/useEventDetail.ts b/src/hooks/useEventDetail.ts index 4eda866..0342118 100644 --- a/src/hooks/useEventDetail.ts +++ b/src/hooks/useEventDetail.ts @@ -19,9 +19,17 @@ interface JoinEventVariables { data: JoinEventRequest; } +interface MergeEventDetailOptions { + onInvalidRegId?: () => void; +} + +const shouldClearGuestRegId = (error: unknown): boolean => + isAxiosError(error) && [401, 403, 404].includes(error.response?.status ?? 0); + const getMergedEventDetail = async ( eventId: string, - effectiveRegId?: string | null + effectiveRegId?: string | null, + options?: MergeEventDetailOptions ): Promise => { const eventRes = await getEventDetail(eventId); let mergedData = eventRes.data; @@ -36,7 +44,7 @@ const getMergedEventDetail = async ( regRes.status === 'CONFIRMED' || regRes.status === 'WAITLISTED'; // 비로그인 신청자는 이벤트 상세 응답만으로 본인 상태를 알 수 없어서 - // 저장해 둔 regId로 신청 상세를 병합해 같은 화면 상태를 구성합니다. + // URL 또는 저장해 둔 regId로 신청 상세를 병합해 같은 화면 상태를 구성합니다. mergedData = { ...mergedData, viewer: { @@ -56,6 +64,9 @@ const getMergedEventDetail = async ( }; } catch (regError) { // regId가 만료되었거나 잘못된 경우에는 공개 상세만 보여줘야 합니다. + if (shouldClearGuestRegId(regError)) { + options?.onInvalidRegId?.(); + } console.error('Failed to fetch guest registration info:', regError); } @@ -63,21 +74,40 @@ const getMergedEventDetail = async ( }; export default function useEventDetail(id?: string) { - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const invalidateEventRegistration = useInvalidateEventRegistration(); + const isLoggedIn = useAuthStore((state) => state.isLoggedIn); const urlRegId = searchParams.get('regId'); const guestRegId = useAuthStore((state) => id ? state.guestRegistrations[id] : null ); - const effectiveRegId = urlRegId || guestRegId; + const canUseGuestRegId = !isLoggedIn; + const effectiveRegId = canUseGuestRegId ? urlRegId || guestRegId : null; const setGuestRegistration = useAuthStore( (state) => state.setGuestRegistration ); - // 1. 이벤트 상세는 regId에 따라 viewer 상태가 달라질 수 있어 key에 함께 포함합니다. + const removeGuestRegistration = useAuthStore( + (state) => state.removeGuestRegistration + ); + + const removeRegIdQuery = () => { + if (!searchParams.has('regId')) return; + + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.delete('regId'); + setSearchParams(nextSearchParams, { replace: true }); + }; + + const clearGuestRegId = (eventId: string) => { + removeGuestRegistration(eventId); + removeRegIdQuery(); + }; + + // 1. 비로그인 regId에 따라 viewer 상태가 달라질 수 있어 key에 함께 포함합니다. const eventDetailQuery = useQuery({ queryKey: id ? queryKeys.events.detail(id, effectiveRegId) @@ -86,7 +116,9 @@ export default function useEventDetail(id?: string) { if (!id) { throw new Error('EVENT_ID_REQUIRED'); } - return getMergedEventDetail(id, effectiveRegId); + return getMergedEventDetail(id, effectiveRegId, { + onInvalidRegId: () => clearGuestRegId(id), + }); }, enabled: Boolean(id), retry: (failureCount, error) => { @@ -114,8 +146,9 @@ export default function useEventDetail(id?: string) { onSuccess: async (response, { eventId }) => { const regId = response.data.registrationPublicId; - if (regId) { + if (!isLoggedIn && regId) { setGuestRegistration(eventId, regId); + removeRegIdQuery(); } await invalidateEventRegistration(eventId); @@ -126,7 +159,8 @@ export default function useEventDetail(id?: string) { mutationFn: (registrationId: string) => deleteRegistration(registrationId), onSuccess: async () => { if (id) { - // 비로그인 사용자의 regId는 유지하고, 서버의 CANCELED 상태를 다시 조회합니다. + // 직접 취소한 신청은 더 이상 소유자 상태로 병합하지 않습니다. + clearGuestRegId(id); await invalidateEventRegistration(id); } }, diff --git a/src/routes/EventMain.tsx b/src/routes/EventMain.tsx index aea7842..32e7a94 100644 --- a/src/routes/EventMain.tsx +++ b/src/routes/EventMain.tsx @@ -104,10 +104,10 @@ export default function EventMain() { }; const onCancelClick = async () => { - // API 우선, 없으면 Zustand 스토어 확인 + // 로그인 상태에서는 비로그인 브라우저에 남은 regId를 취소 대상으로 쓰지 않습니다. const regId = viewer.registrationPublicId || - useAuthStore.getState().guestRegistrations[id]; + (!isLoggedIn ? useAuthStore.getState().guestRegistrations[id] : null); if (!regId) return; const success = await handleCancelEvent(regId);