diff --git "a/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" "b/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" new file mode 100644 index 000000000..9fee2a2ba --- /dev/null +++ "b/frontend/.claude/agents/\354\203\201\355\203\234\352\264\200\353\246\254\353\266\200\354\204\234.md" @@ -0,0 +1,420 @@ +# 상태관리 Agent + +UI 상태, 브라우저 상태, 서버 상태를 적절한 도구로 분류하고 관리하는 전담 에이전트 + +## 역할 + +- 새로운 상태 추가 시 적절한 도구 선택 가이드 +- Zustand 스토어 생성 및 수정 +- localStorage / sessionStorage 사용 패턴 유지 +- URL 상태(useSearchParams) 관리 +- React Context 생성 및 관리 +- 상태 중복, 과도한 전역화 방지 + +## 상태 도구 선택 기준 + +새로운 상태를 추가할 때 아래 순서로 판단: + +```text +Q1. 서버에서 오는 데이터인가? + YES → React Query (→ API훅부서에 위임) + +Q2. URL로 공유/북마크/뒤로가기 복원이 필요한가? + YES → useSearchParams / useParams + +Q3. 이 컴포넌트 외에 다른 컴포넌트가 접근해야 하는가? + NO → useState (로컬 UI 상태) + YES ↓ + +Q4. 브라우저를 닫아도 유지해야 하는가? + YES → localStorage 직접 사용 (또는 Zustand + persist localStorage) + +Q5. 탭을 닫을 때까지만 유지하면 되는가? + YES → Zustand + persist + sessionStorage + +Q6. 메모리(새로고침 시 리셋)로 충분한가? + YES → Zustand (persist 없이) + +Q7. 실시간 이벤트(SSE/WebSocket)인가? + YES → 커스텀 훅 (`useXxxSSE`) + +Q8. 특정 서브트리에서만 공유가 필요하고 + Provider 패턴이 명확히 필요한가? + YES → React Context (현재 프로젝트에서는 Zustand 우선 고려) +``` + +--- + +## 1. 로컬 UI 상태 → `useState` + +**기준**: 컴포넌트 외부에서 접근할 필요 없는 일시적 상태 + +```typescript +// 모달 열기/닫기 +const [isOpen, setIsOpen] = useState(false); + +// 현재 이미지 인덱스 +const [index, setIndex] = useState(0); + +// 유효성 검사 실패 ID 목록 +const [invalidQuestionIds, setInvalidQuestionIds] = useState([]); +``` + +**하지 말 것**: 모달 하나 때문에 Zustand 스토어 만들지 않기. + +--- + +## 2. 전역 UI 상태 → `Zustand` + +**기준**: 여러 컴포넌트에서 공유되며 서버와 무관한 클라이언트 상태 + +**파일 위치**: `src/store/use도메인Store.ts` + +### 기본 패턴 (메모리만 유지) + +```typescript +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +interface SearchStore { + keyword: string; + setKeyword: (keyword: string) => void; + inputValue: string; + setInputValue: (value: string) => void; + isSearching: boolean; + setIsSearching: (isSearching: boolean) => void; + resetSearch: () => void; +} + +export const useSearchStore = create()( + subscribeWithSelector((set) => ({ + keyword: '', + setKeyword: (keyword) => set({ keyword }), + inputValue: '', + setInputValue: (value) => set({ inputValue: value }), + isSearching: false, + setIsSearching: (isSearching) => set({ isSearching }), + resetSearch: () => set({ keyword: '', inputValue: '', isSearching: false }), + })), +); +``` + +### selector 훅 export 패턴 (권장) + +스토어 파일 내에서 selector 훅을 함께 export해 컴포넌트가 스토어 구조에 직접 의존하지 않게 한다. + +```typescript +export const useAdminClubStore = create()( + subscribeWithSelector((set) => ({ + clubId: null, + setClubId: (id) => set({ clubId: id }), + hasConsented: true, + setHasConsented: (value) => set({ hasConsented: value }), + })), +); + +// 스토어 파일 내에서 selector 훅 export +export const useAdminClubId = () => { + const clubId = useAdminClubStore((state) => state.clubId); + const setClubId = useAdminClubStore((state) => state.setClubId); + return { clubId, setClubId }; +}; + +export const useAdminHasConsented = () => { + const hasConsented = useAdminClubStore((state) => state.hasConsented); + const setHasConsented = useAdminClubStore((state) => state.setHasConsented); + return { hasConsented, setHasConsented }; +}; +``` + +컴포넌트에서는 스토어 직접 접근 대신 selector 훅 사용: + +```typescript +// ❌ 스토어 직접 접근 +const clubId = useAdminClubStore((state) => state.clubId); + +// ✅ selector 훅 사용 +const { clubId, setClubId } = useAdminClubId(); +``` + +### sessionStorage 영속 패턴 (탭 닫을 때까지 유지) + +```typescript +import { create } from 'zustand'; +import { + createJSONStorage, + persist, + subscribeWithSelector, +} from 'zustand/middleware'; + +interface CategoryStore { + selectedCategory: string; + setSelectedCategory: (category: string) => void; +} + +export const useCategoryStore = create()( + subscribeWithSelector( + persist( + (set) => ({ + selectedCategory: 'all', + setSelectedCategory: (category) => set({ selectedCategory: category }), + }), + { + name: 'category-storage', + storage: createJSONStorage(() => sessionStorage), + }, + ), + ), +); +``` + +### 선택적 구독 (성능 최적화) + +```typescript +// 전체 스토어 구독 (불필요한 리렌더링 유발 가능) +const { keyword, setKeyword } = useSearchStore(); + +// 특정 값만 구독 (권장) +const keyword = useSearchStore((state) => state.keyword); +``` + +### 미들웨어 사용 기준 + +| 미들웨어 | 사용 시점 | +| -------------------------- | -------------------------------- | +| `subscribeWithSelector` | 항상 사용 (선택적 구독 가능하게) | +| `persist + sessionStorage` | 탭 닫을 때까지 유지 필요 | +| `persist + localStorage` | 브라우저 닫아도 유지 필요 | + +--- + +## 3. 브라우저 영속 상태 → `localStorage` + +**기준**: 로그아웃/명시적 삭제 전까지 유지해야 하는 단순 데이터 + +**키 관리**: 반드시 `src/constants/storageKeys.ts`에 등록 + +```typescript +// src/constants/storageKeys.ts +export const STORAGE_KEYS = { + ACCESS_TOKEN: 'accessToken', + HAS_CONSENTED_PERSONAL_INFO: 'hasConsentedPersonalInfo', + // 새 키 추가 시 여기에 등록 +} as const; +``` + +**사용 패턴**: + +```typescript +import { STORAGE_KEYS } from '@/constants/storageKeys'; + +// 저장 +localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, token); + +// 조회 +const token = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN); + +// 삭제 +localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); +``` + +**현재 사용 중인 키**: + +| 키 | 유지 기간 | 용도 | +| -------------------------------------- | ------------- | ------------------------- | +| `accessToken` | 로그아웃까지 | JWT 인증 토큰 | +| `hasConsentedPersonalInfo` | 영구 | 개인정보 활용 동의 여부 | +| `promotion_last_checked_time` | 영구 | 프로모션 마지막 확인 시간 | +| `moadong_experiments` | 영구 | A/B 테스트 배정값 캐시 | +| `applicationAnswers_{clubId}_{formId}` | 지원 완료까지 | 지원서 임시 저장 | + +--- + +## 4. URL 상태 → `useSearchParams` / `useParams` + +**기준**: 공유/북마크 가능해야 하거나 뒤로가기로 복원되어야 하는 상태 + +### useSearchParams (쿼리 파라미터) + +```typescript +import { useSearchParams } from 'react-router-dom'; + +const [searchParams, setSearchParams] = useSearchParams(); + +// 읽기 +const tabParam = searchParams.get('tab'); + +// 쓰기 (히스토리 추가) +setSearchParams({ tab: 'photos' }); + +// 쓰기 (히스토리 교체 - 뒤로가기 없애기) +setSearchParams({ tab: 'intro' }, { replace: true }); +``` + +**사용 예시**: `/clubDetail/@clubName?tab=photos` + +### useParams (경로 파라미터) + +```typescript +import { useParams } from 'react-router-dom'; + +const { clubName } = useParams<{ clubName: string }>(); +const { clubId, applicationFormId } = useParams<{ + clubId: string; + applicationFormId: string; +}>(); +``` + +**URL vs useState 판단**: + +- 공유/북마크 필요 → URL +- 뒤로가기 복원 필요 → URL +- 그 외 임시 UI 상태 → useState + +--- + +## 5. 실시간 이벤트 → 커스텀 훅 + +**기준**: SSE/WebSocket 연결처럼 생명주기 관리가 필요한 실시간 상태 + +Context가 아닌 **커스텀 훅**으로 처리한다. 연결/해제 로직을 훅 내부에 캡슐화하고, 필요한 컴포넌트에서 직접 호출한다. + +**파일 위치**: `src/hooks/use도메인SSE.ts` + +```typescript +// src/hooks/useApplicantSSE.ts +export const useApplicantSSE = (applicationFormId: string | undefined) => { + const [applicantsData, setApplicantsData] = useState( + null, + ); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + useEffect(() => { + if (!applicationFormId) return; + + const sseConnect = () => { + eventSourceRef.current?.close(); + eventSourceRef.current = createApplicantSSE(applicationFormId, { + onStatusChange: handleApplicantStatusChange, + onError: () => { + // 2초 후 자동 재연결 + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; + sseConnect(); + }, 2000); + }, + }); + }; + + sseConnect(); + + return () => { + clearTimeout(reconnectTimeoutRef.current ?? undefined); + eventSourceRef.current?.close(); + }; + }, [applicationFormId]); + + return { applicantsData, setApplicantsData }; +}; +``` + +**현재 커스텀 SSE 훅**: + +- `useApplicantSSE` — 지원자 SSE 연결 + 상태 변경 실시간 반영 (`src/hooks/useApplicantSSE.ts`) + +**React Context 사용 기준**: + +Context는 현재 프로젝트에서 사용하지 않는다. 아래 기준으로 판단: + +- 전역 공유 상태 → Zustand +- 실시간 이벤트 → 커스텀 훅 +- 특정 서브트리 공유 + Provider 패턴이 명확히 필요한 경우에만 Context 고려 + +--- + +## 도구별 비교 요약 + +| 상황 | 도구 | +| ------------------------------- | ------------------------------------ | +| 모달 열기/닫기, 임시 UI | `useState` | +| 여러 컴포넌트 공유, 서버 무관 | `Zustand` | +| 세션 동안 유지 (탭 닫으면 삭제) | `Zustand + persist + sessionStorage` | +| 브라우저 닫아도 유지 | `localStorage` (직접) | +| 공유/북마크/뒤로가기 | `useSearchParams` | +| 경로 식별자 | `useParams` | +| 서버 데이터 | `React Query` → API훅부서 | +| 실시간 이벤트 (SSE/WebSocket) | 커스텀 훅 (`useXxxSSE`) | + +--- + +## 주의사항 + +### Zustand에 서버 데이터 담지 않기 + +```typescript +// ❌ 잘못된 예: 서버 데이터를 Zustand에 저장 +const useClubStore = create((set) => ({ + clubList: [], + fetchClubs: async () => { ... } +})); + +// ✅ 올바른 예: 서버 데이터는 React Query +const { data: clubList } = useGetCardList({ ... }); +``` + +### 상태 범위 최소화 + +```typescript +// ❌ 잘못된 예: 컴포넌트 로컬 상태를 전역으로 +const useModalStore = create((set) => ({ + isOpen: false, + toggle: () => set((s) => ({ isOpen: !s.isOpen })), +})); + +// ✅ 올바른 예: 로컬이면 useState +const [isOpen, setIsOpen] = useState(false); +``` + +### localStorage 키는 반드시 상수로 + +```typescript +// ❌ 하드코딩 금지 +localStorage.setItem('myKey', value); + +// ✅ 상수 사용 +localStorage.setItem(STORAGE_KEYS.MY_KEY, value); +``` + +--- + +## 체크리스트 + +새 상태 추가 시 확인: + +- [ ] 서버 데이터인가? → React Query (API훅부서 위임) +- [ ] URL 공유/뒤로가기 필요한가? → useSearchParams +- [ ] 컴포넌트 로컬로 충분한가? → useState +- [ ] 전역 필요 시 적절한 persist 전략을 선택했는가? +- [ ] localStorage 키를 `storageKeys.ts`에 등록했는가? +- [ ] Zustand 스토어에 `subscribeWithSelector`를 적용했는가? +- [ ] 불필요하게 전역 상태로 끌어올리진 않았는가? + +--- + +## 참고 파일 + +- `src/store/useSearchStore.ts` — 메모리 Zustand 스토어 예시 +- `src/store/useCategoryStore.ts` — sessionStorage persist 예시 +- `src/store/useAdminClubStore.ts` — selector 훅 export 패턴 예시 +- `src/hooks/useApplicantSSE.ts` — SSE 커스텀 훅 패턴 예시 +- `src/constants/storageKeys.ts` — localStorage 키 관리 +- `src/constants/queryKeys.ts` — React Query 키 관리 + +## 기술 스택 + +- Zustand (전역 클라이언트 상태) +- React Router v7 `useSearchParams` / `useParams` (URL 상태) +- 커스텀 훅 (SSE 실시간 이벤트 처리) +- localStorage / sessionStorage (브라우저 영속 상태) +- @tanstack/react-query v5 (서버 상태 → API훅부서 담당) diff --git a/frontend/.claude/commands/commit.md b/frontend/.claude/commands/commit.md index 3e883cf48..c0ea0f7dc 100644 --- a/frontend/.claude/commands/commit.md +++ b/frontend/.claude/commands/commit.md @@ -1,6 +1,6 @@ --- description: 세션 작업 기록 + 기능 문서화 + 변경 내용 커밋 -allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep +allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(npm run format), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep --- # 작업 지시 @@ -107,15 +107,18 @@ allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(g 기록이 완료되면 커밋을 수행합니다. -1. `git status`로 변경된 파일 확인 -2. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행) -3. `git log --oneline -5`로 최근 커밋 스타일 참고 -4. 변경 내용을 분석하여 커밋 메시지 작성 -5. 관련 파일만 `git add`로 스테이징 - - `docs/features/` 문서 파일 포함 +1. `npm run format` 실행하여 코드 포맷팅 +2. `git status`로 변경된 파일 확인 +3. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행) +4. `git log --oneline -5`로 최근 커밋 스타일 참고 +5. 변경된 파일을 **기능(scope) 단위로 그룹핑** + - 같은 기능/도메인에 속하는 파일끼리 묶음 (예: store 변경, admin UI 변경, hooks 변경 등) + - 논리적으로 독립적인 변경은 별도 커밋으로 분리 + - `docs/features/` 문서 파일은 관련 기능 커밋에 포함 - `dailyNote/`는 gitignore 대상이므로 제외 -6. **커밋 전에 변경 내용과 커밋 메시지를 사용자에게 확인 요청** -7. 사용자 승인 후 커밋 실행 +6. **커밋 전에 그룹핑 계획과 각 커밋 메시지를 사용자에게 확인 요청** +7. 사용자 승인 후 그룹별로 순서대로 커밋 실행 + - 각 그룹: `git add <관련 파일들>` → `git commit -m "..."` 순으로 반복 **커밋 메시지 형식:** diff --git a/frontend/docs/features/store/useAdminClubStore.md b/frontend/docs/features/store/useAdminClubStore.md new file mode 100644 index 000000000..58666e3bb --- /dev/null +++ b/frontend/docs/features/store/useAdminClubStore.md @@ -0,0 +1,54 @@ +# useAdminClubStore — 어드민 전역 상태 관리 + +`AdminClubContext`를 Zustand로 마이그레이션하여 SSE 업데이트로 인한 불필요한 리렌더링을 제거한 store. + +## 배경 + +기존 `AdminClubContext`에는 4개 상태가 묶여 있었다: +- `clubId` — 로그인한 관리자의 클럽 ID +- `hasConsented` — 개인정보 동의 여부 +- `applicantsData` — SSE로 실시간 업데이트되는 지원자 데이터 +- `applicationFormId` — SSE 연결 트리거용 ID + +`applicantsData`가 SSE 이벤트마다 변경되면서 Context를 구독하는 9개 컴포넌트 전부가 리렌더링되었다. `applicantsData`의 실제 소비자는 `ApplicantsTab` 하나뿐이었기 때문에 나머지 8개 컴포넌트의 리렌더링은 불필요했다. + +## 구조 + +``` +src/store/useAdminClubStore.ts — clubId, hasConsented (전역 공유 필요) +src/hooks/useApplicantSSE.ts — applicantsData + SSE 연결 (ApplicantsTab 스코프) +``` + +`applicationFormId`는 store에 포함하지 않는다. SSE 훅의 인자로 직접 전달한다. + +## Selector 훅 + +```typescript +import { useAdminClubId } from '@/store/useAdminClubStore'; +import { useAdminHasConsented } from '@/store/useAdminClubStore'; + +const { clubId, setClubId } = useAdminClubId(); +const { hasConsented, setHasConsented } = useAdminHasConsented(); +``` + +각 selector 훅은 해당 상태만 구독하므로, 다른 상태가 변경되어도 리렌더링이 발생하지 않는다. + +## useApplicantSSE + +```typescript +import { useApplicantSSE } from '@/hooks/useApplicantSSE'; + +const { applicantsData, setApplicantsData } = useApplicantSSE(applicationFormId); +``` + +- `applicationFormId`가 변경되면 SSE 연결을 재수립한다 +- 컴포넌트 언마운트 시 SSE 연결을 자동으로 닫는다 +- 에러 발생 시 2초 후 자동 재연결한다 +- `setApplicantsData`로 초기 데이터(`useGetApplicants` 결과)를 주입할 수 있다 + +## 관련 코드 + +- `src/store/useAdminClubStore.ts` — Zustand store 정의 +- `src/hooks/useApplicantSSE.ts` — SSE 연결 관리 및 applicantsData 상태 +- `src/apis/clubSSE.ts` — EventSource 생성 유틸 +- `src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx` — useApplicantSSE 사용처 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f21bd9e4..c0b1b98d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from 'styled-components'; import { ScrollToTopButton } from '@/components/common/ScrollToTopButton/ScrollToTopButton'; -import { AdminClubProvider } from '@/context/AdminClubContext'; import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop'; import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; @@ -120,11 +119,9 @@ const App = () => { path='/admin/*' element={ - - - - - + + + } /> diff --git a/frontend/src/components/common/Header/admin/AdminProfile.tsx b/frontend/src/components/common/Header/admin/AdminProfile.tsx index b6565be39..6b5b94374 100644 --- a/frontend/src/components/common/Header/admin/AdminProfile.tsx +++ b/frontend/src/components/common/Header/admin/AdminProfile.tsx @@ -1,10 +1,10 @@ import DefaultMoadongLogo from '@/assets/images/logos/default_profile_image.svg'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from '../Header.styles'; const AdminProfile = () => { - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const { data: clubDetail } = useGetClubDetail(clubId || ''); const { name, logo } = clubDetail || {}; diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/hooks/useApplicantSSE.ts similarity index 59% rename from frontend/src/context/AdminClubContext.tsx rename to frontend/src/hooks/useApplicantSSE.ts index f99cf3953..47d2593ac 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/hooks/useApplicantSSE.ts @@ -1,11 +1,4 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createApplicantSSE } from '@/apis/clubSSE'; import { ApplicantsInfo, @@ -13,38 +6,13 @@ import { ApplicationStatus, } from '@/types/applicants'; -interface AdminClubContextType { - clubId: string | null; - setClubId: (id: string | null) => void; - applicantsData: ApplicantsInfo | null; - setApplicantsData: (data: ApplicantsInfo | null) => void; - applicationFormId: string | null; - setApplicationFormId: (id: string | null) => void; - hasConsented: boolean; - setHasConsented: (value: boolean) => void; -} - -const AdminClubContext = createContext( - undefined, -); - -export const AdminClubProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [clubId, setClubId] = useState(null); +export const useApplicantSSE = (applicationFormId: string | undefined) => { const [applicantsData, setApplicantsData] = useState( null, ); - const [applicationFormId, setApplicationFormId] = useState( - null, - ); - const [hasConsented, setHasConsented] = useState(true); const eventSourceRef = useRef(null); const reconnectTimeoutRef = useRef(null); - // SSE 이벤트 핸들러 const handleApplicantStatusChange = useCallback( (event: ApplicantStatusEvent) => { setApplicantsData((prevData) => { @@ -77,6 +45,8 @@ export const AdminClubProvider = ({ useEffect(() => { if (!applicationFormId) return; + setApplicantsData(null); + const sseConnect = () => { eventSourceRef.current?.close(); @@ -107,29 +77,5 @@ export const AdminClubProvider = ({ }; }, [applicationFormId, handleApplicantStatusChange]); - return ( - - {children} - - ); -}; - -export const useAdminClubContext = () => { - const context = useContext(AdminClubContext); - if (!context) - throw new Error( - 'useAdminClubContext는 AdminClubProvider 내부에서만 사용할 수 있습니다', - ); - return context; + return { applicantsData, setApplicantsData }; }; diff --git a/frontend/src/pages/AdminPage/AdminPage.tsx b/frontend/src/pages/AdminPage/AdminPage.tsx index 4347feae4..aa8b3e4a9 100644 --- a/frontend/src/pages/AdminPage/AdminPage.tsx +++ b/frontend/src/pages/AdminPage/AdminPage.tsx @@ -1,13 +1,19 @@ +import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import Header from '@/components/common/Header/Header'; -import { useAdminClubContext } from '@/context/AdminClubContext'; +import { STORAGE_KEYS } from '@/constants/storageKeys'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; import PersonalInfoConsentModal from '@/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal'; import SideBar from '@/pages/AdminPage/components/SideBar/SideBar'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './AdminPage.styles'; const AdminPage = () => { - const { clubId, hasConsented } = useAdminClubContext(); + const { clubId } = useAdminClubId(); + const [hasConsented, setHasConsented] = useState( + () => + localStorage.getItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO) === 'true', + ); const { data: clubDetail, error } = useGetClubDetail(clubId || ''); if (!clubDetail) { @@ -19,7 +25,12 @@ const AdminPage = () => { return ( <>
- {!hasConsented && } + {!hasConsented && ( + setHasConsented(true)} + /> + )} diff --git a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx index 2e72b3e5e..6e344f663 100644 --- a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx +++ b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx @@ -1,27 +1,21 @@ import { useEffect } from 'react'; import { Navigate } from 'react-router-dom'; import Spinner from '@/components/common/Spinner/Spinner'; -import { STORAGE_KEYS } from '@/constants/storageKeys'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import useAuth from '@/hooks/useAuth'; +import { useAdminClubId } from '@/store/useAdminClubStore'; // import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants'; const PrivateRoute = ({ children }: { children: React.ReactNode }) => { const { isLoading, isAuthenticated, clubId } = useAuth(); - const { setClubId, setHasConsented } = useAdminClubContext(); - // const { setClubId, setApplicantsData } = useAdminClubContext(); + const { setClubId } = useAdminClubId(); // const { data: applicantsData } = useGetApplicants(clubId ?? ''); useEffect(() => { if (clubId) { setClubId(clubId); - const consented = - localStorage.getItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO) === - 'true'; - setHasConsented(consented); } - }, [clubId, setClubId, setHasConsented]); + }, [clubId, setClubId]); // useEffect(() => { // if (clubId && applicantsData) { diff --git a/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx b/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx index b1f69a190..29f9f7f5c 100644 --- a/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx +++ b/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx @@ -2,9 +2,9 @@ import { useRef } from 'react'; import defaultCover from '@/assets/images/logos/default_profile_image.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useDeleteCover, useUploadCover } from '@/hooks/Queries/useClubCover'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './ClubCoverEditor.styles'; interface ClubCoverEditorProps { @@ -13,7 +13,7 @@ interface ClubCoverEditorProps { const ClubCoverEditor = ({ coverImage }: ClubCoverEditorProps) => { const trackEvent = useMixpanelTrack(); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const fileInputRef = useRef(null); if (!clubId) return null; diff --git a/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx b/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx index ab0252c30..b395dc1f6 100644 --- a/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx +++ b/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx @@ -2,9 +2,9 @@ import React, { useRef } from 'react'; import defaultLogo from '@/assets/images/logos/default_profile_image.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useDeleteLogo, useUploadLogo } from '@/hooks/Queries/useClubImages'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './ClubLogoEditor.styles'; interface ClubLogoEditorProps { @@ -14,7 +14,7 @@ interface ClubLogoEditorProps { const ClubLogoEditor = ({ clubLogo }: ClubLogoEditorProps) => { const trackEvent = useMixpanelTrack(); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); if (!clubId) return null; const fileInputRef = useRef(null); diff --git a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx index f853e74b5..79714d0ce 100644 --- a/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx +++ b/frontend/src/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal.tsx @@ -3,7 +3,6 @@ import { allowPersonalInformation } from '@/apis/auth'; import Button from '@/components/common/Button/Button'; import PortalModal from '@/components/common/Modal/PortalModal'; import { STORAGE_KEYS } from '@/constants/storageKeys'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import * as Styled from './PersonalInfoConsentModal.styles'; const GUIDE_ITEMS = [ @@ -20,12 +19,13 @@ const GUIDE_ITEMS = [ interface PersonalInfoConsentModalProps { clubName: string; + onConsent: () => void; } const PersonalInfoConsentModal = ({ clubName, + onConsent, }: PersonalInfoConsentModalProps) => { - const { setHasConsented } = useAdminClubContext(); const [loading, setLoading] = useState(false); const handleConsent = async () => { @@ -34,7 +34,7 @@ const PersonalInfoConsentModal = ({ try { await allowPersonalInformation(); localStorage.setItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO, 'true'); - setHasConsented(true); + onConsent(); } catch (error) { console.error('서비스 동의 실패:', error); alert('동의 처리에 실패했습니다. 다시 시도해주세요.'); diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx index 099c2b04c..4a094c69d 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx @@ -5,7 +5,6 @@ import PrevApplicantButton from '@/assets/images/icons/prev_applicant.svg'; import Header from '@/components/common/Header/Header'; import Spinner from '@/components/common/Spinner/Spinner'; import { AVAILABLE_STATUSES } from '@/constants/status'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetApplicants, useUpdateApplicant, @@ -13,6 +12,7 @@ import { import { useGetApplication } from '@/hooks/Queries/useApplication'; import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer'; import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { ApplicationStatus } from '@/types/applicants'; import { Question } from '@/types/application'; import mapStatusToGroup from '@/utils/mapStatusToGroup'; @@ -51,7 +51,7 @@ const ApplicantDetailPage = () => { const [applicantStatus, setApplicantStatus] = useState( ApplicationStatus.SUBMITTED, ); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const { data: applicantsData, isLoading: isApplicantsLoading, diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx index 3f9f0227c..78c49ada1 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -6,13 +6,14 @@ import selectIcon from '@/assets/images/icons/selectArrow.svg'; import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; import SearchField from '@/components/common/SearchField/SearchField'; import { AVAILABLE_STATUSES } from '@/constants/status'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useDeleteApplicants, useGetApplicants, useUpdateApplicant, } from '@/hooks/Queries/useApplicants'; +import { useApplicantSSE } from '@/hooks/useApplicantSSE'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { Applicant, ApplicationStatus } from '@/types/applicants'; import mapStatusToGroup from '@/utils/mapStatusToGroup'; import * as Styled from './ApplicantsTab.styles'; @@ -23,9 +24,10 @@ const sortOptions = [ ] as const; const ApplicantsTab = () => { - const { clubId, applicantsData, setApplicantsData, setApplicationFormId } = - useAdminClubContext(); + const { clubId } = useAdminClubId(); const { applicationFormId } = useParams<{ applicationFormId: string }>(); + const { applicantsData, setApplicantsData } = + useApplicantSSE(applicationFormId); const navigate = useNavigate(); const statusOptions = AVAILABLE_STATUSES.map((status) => ({ @@ -63,12 +65,6 @@ const ApplicantsTab = () => { (typeof sortOptions)[number] >(sortOptions[0]); - // SSE 연결 활성화 - useEffect(() => { - setApplicationFormId(applicationFormId ?? null); - return () => setApplicationFormId(null); - }, [applicationFormId, setApplicationFormId]); - // 초기 데이터 로드 useEffect(() => { if (fetchData) { diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx index 12a73b6e1..976f4f203 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx @@ -7,13 +7,13 @@ import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; import { APPLICATION_FORM } from '@/constants/applicationForm'; import INITIAL_FORM_DATA from '@/constants/initialFormData'; import { queryKeys } from '@/constants/queryKeys'; -import { useAdminClubContext } from '@/context/AdminClubContext'; import { useGetApplication } from '@/hooks/Queries/useApplication'; import QuestionBuilder from '@/pages/AdminPage/components/QuestionBuilder/QuestionBuilder'; import { hasErrors, validateApplicationForm, } from '@/pages/AdminPage/validation/validateApplicationForm'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { PageContainer } from '@/styles/PageContainer.styles'; import { ApplicationFormData, @@ -30,7 +30,7 @@ const ApplicationEditTab = () => { const { applicationFormId: formId } = useParams<{ applicationFormId?: string; }>(); - const { clubId } = useAdminClubContext(); + const { clubId } = useAdminClubId(); const { data: existingFormData, diff --git a/frontend/src/store/useAdminClubStore.ts b/frontend/src/store/useAdminClubStore.ts new file mode 100644 index 000000000..4f79e4a47 --- /dev/null +++ b/frontend/src/store/useAdminClubStore.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +interface AdminClubStore { + clubId: string | null; + setClubId: (id: string | null) => void; +} + +export const useAdminClubStore = create()( + subscribeWithSelector((set) => ({ + clubId: null, + setClubId: (id) => set({ clubId: id }), + })), +); + +export const useAdminClubId = () => { + const clubId = useAdminClubStore((state) => state.clubId); + const setClubId = useAdminClubStore((state) => state.setClubId); + return { clubId, setClubId }; +};