diff --git a/src/api/interceptors.ts b/src/api/interceptors.ts index b024d78..ec413a0 100644 --- a/src/api/interceptors.ts +++ b/src/api/interceptors.ts @@ -16,6 +16,9 @@ import type { AxiosError } from 'axios' +import { ROUTES } from '@/shared/constants' +import { queryClient } from '@/shared/lib/tanstack-query/queryClient' + import { apiClient } from './client' import { ApiError } from './errors' import { logger } from './logger' @@ -97,21 +100,18 @@ export const setupInterceptors = (): void => { // 서버에서 JSESSIONID 쿠키 기반 세션 인증을 사용하므로, // 401 응답은 세션이 만료되었거나 유효하지 않음을 의미합니다. if (error.response?.status === 401) { - const isAlreadyOnLogin = window.location.pathname === '/login' - const requestUrl = error.config?.url ?? '' - - // 인증 상태 확인용 엔드포인트는 리다이렉트하지 않음 - // 이 엔드포인트들은 호출자가 직접 401 응답을 처리함 - const authProbeEndpoints = ['/api/auth/me', '/api/users/me'] - const isAuthProbeRequest = authProbeEndpoints.some( - (endpoint) => requestUrl === endpoint || requestUrl.endsWith(endpoint), - ) - - const shouldRedirect = !isAlreadyOnLogin && !isAuthProbeRequest + const currentPath = window.location.pathname + const isAlreadyOnLogin = currentPath === ROUTES.LOGIN + const isInvitePage = currentPath.startsWith(ROUTES.INVITE_BASE) - if (shouldRedirect) { - // TODO: 로그아웃 알림 토스트 표시 - window.location.href = '/login' + // 초대 페이지 또는 로그인 페이지에서는 리다이렉트하지 않음 + const shouldSkipRedirect = isInvitePage || isAlreadyOnLogin + if (!shouldSkipRedirect) { + // React Query 캐시 전체 삭제 (인증 정보 포함) + // 세션이 만료되었으므로 모든 캐시된 데이터는 더 이상 유효하지 않음 + queryClient.clear() + alert('로그인이 필요합니다. 로그인 페이지로 이동합니다.') + window.location.href = ROUTES.LOGIN } } diff --git a/src/features/auth/auth.api.ts b/src/features/auth/auth.api.ts index 74fae6d..f3e3184 100644 --- a/src/features/auth/auth.api.ts +++ b/src/features/auth/auth.api.ts @@ -1,13 +1,7 @@ import { apiClient, type ApiResponse } from '@/api' import { AUTH_ENDPOINTS } from './auth.endpoints' - -export type CurrentUser = { - userId: number - nickname: string - profileImageUrl: string | null - needsOnboarding: boolean -} +import type { CurrentUser } from './auth.types' export const getKakaoLoginUrl = () => { const apiUrl = import.meta.env.VITE_API_URL @@ -24,6 +18,9 @@ export const loginWithKakao = () => { window.location.href = getKakaoLoginUrl() } +/** + * 현재 로그인 사용자 정보 조회 - 인증 상태 및 온보딩 필요 여부 확인용 + */ export const fetchCurrentUser = async () => { const { data } = await apiClient.get>(AUTH_ENDPOINTS.ME) return data.data diff --git a/src/features/auth/auth.types.ts b/src/features/auth/auth.types.ts new file mode 100644 index 0000000..c404ccf --- /dev/null +++ b/src/features/auth/auth.types.ts @@ -0,0 +1,9 @@ +/** + * 현재 로그인한 사용자 정보 - 인증 상태 확인 및 온보딩 필요 여부 체크에 사용 + */ +export type CurrentUser = { + userId: number + nickname: string | null + profileImageUrl: string | null + needsOnboarding: boolean +} diff --git a/src/features/auth/hooks/useAuth.ts b/src/features/auth/hooks/useAuth.ts index 4611c5f..d3273bf 100644 --- a/src/features/auth/hooks/useAuth.ts +++ b/src/features/auth/hooks/useAuth.ts @@ -1,29 +1,26 @@ import { useQuery } from '@tanstack/react-query' +import { ApiError } from '@/api' + import { fetchCurrentUser } from '../auth.api' +import type { CurrentUser } from '../auth.types' +import { authQueryKeys } from './authQueryKeys' /** * 현재 로그인 사용자 정보를 조회하는 훅 * * @description - * - CurrentUser 타입 반환: { userId, nickname, profileImageUrl, needsOnboarding } * - 401 에러 시 재시도하지 않음 (로그인 페이지로 리다이렉트) - * - 5분 동안 stale 데이터로 유지 - * - * @example - * ```tsx - * const { data: user, isLoading } = useAuth() - * - * if (user?.needsOnboarding) { - * return - * } - * ``` + * - staleTime: Infinity - 수동 갱신(setQueryData) 전까지 캐시 데이터를 신선하게 유지 + * - refetchOnWindowFocus: 'always' - 창 포커스 시 항상 세션 유효성 확인 */ export function useAuth() { - return useQuery({ - queryKey: ['auth', 'me'], + return useQuery({ + queryKey: authQueryKeys.me(), queryFn: fetchCurrentUser, - retry: false, // 401 에러는 재시도하지 않음 - staleTime: 5 * 60 * 1000, // 5분 + retry: false, + staleTime: Infinity, + gcTime: Infinity, + refetchOnWindowFocus: 'always', }) } diff --git a/src/features/auth/hooks/useLogout.ts b/src/features/auth/hooks/useLogout.ts index 8d33044..8c9d42f 100644 --- a/src/features/auth/hooks/useLogout.ts +++ b/src/features/auth/hooks/useLogout.ts @@ -1,15 +1,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' +import { ApiError, type ApiResponse } from '@/api' import { ROUTES } from '@/shared/constants' import { logout } from '../auth.api' +/** + * 로그아웃 훅 - 성공 시 전체 캐시 초기화 후 로그인 페이지로 이동 + */ export function useLogout() { const navigate = useNavigate() const queryClient = useQueryClient() - return useMutation({ + return useMutation, ApiError, void>({ mutationFn: logout, onSuccess: () => { queryClient.clear() diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 7aea380..b571ab4 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -1,2 +1,3 @@ +export * from './auth.types' export * from './components' export * from './hooks' diff --git a/src/features/gatherings/gatherings.api.ts b/src/features/gatherings/gatherings.api.ts new file mode 100644 index 0000000..7c40713 --- /dev/null +++ b/src/features/gatherings/gatherings.api.ts @@ -0,0 +1,51 @@ +import { apiClient, type ApiResponse } from '@/api' + +import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints' +import type { + CreateGatheringRequest, + CreateGatheringResponse, + GatheringByInviteCodeResponse, + GatheringJoinResponse, +} from './gatherings.types' + +/** + * 독서모임 생성 + * + * @param data - 모임 생성 요청 데이터 + * @param data.gatheringName - 모임 이름 (최대 12자) + * @param data.gatheringDescription - 모임 설명 (최대 150자) + * @returns 생성된 모임 정보 + */ +export const createGathering = async (data: CreateGatheringRequest) => { + const response = await apiClient.post>( + GATHERINGS_ENDPOINTS.BASE, + data + ) + return response.data +} + +/** + * 초대 코드로 모임 정보 조회 (로그인 불필요) + * + * @param invitationCode - 초대 코드 + * @returns 모임 정보 + */ +export const getGatheringByInviteCode = async (invitationCode: string) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.JOIN_REQUEST(invitationCode) + ) + return response.data +} + +/** + * 모임 가입 신청 (로그인 필요) + * + * @param invitationCode - 초대 코드 + * @returns 가입 신청 결과 (gatheringId, gatheringName, memberStatus) + */ +export const joinGathering = async (invitationCode: string) => { + const response = await apiClient.post>( + GATHERINGS_ENDPOINTS.JOIN_REQUEST(invitationCode) + ) + return response.data +} diff --git a/src/features/gatherings/gatherings.endpoints.ts b/src/features/gatherings/gatherings.endpoints.ts new file mode 100644 index 0000000..8eb9a13 --- /dev/null +++ b/src/features/gatherings/gatherings.endpoints.ts @@ -0,0 +1,8 @@ +import { API_PATHS } from '@/api' + +export const GATHERINGS_ENDPOINTS = { + /** 모임 목록/생성 */ + BASE: API_PATHS.GATHERINGS, + /** 초대 코드로 모임 정보 조회 / 가입 신청 */ + JOIN_REQUEST: (invitationCode: string) => `${API_PATHS.GATHERINGS}/join-request/${invitationCode}`, +} as const diff --git a/src/features/gatherings/gatherings.types.ts b/src/features/gatherings/gatherings.types.ts new file mode 100644 index 0000000..0fddb93 --- /dev/null +++ b/src/features/gatherings/gatherings.types.ts @@ -0,0 +1,45 @@ +/** 모임 기본 정보 (공통) */ +export interface GatheringBase { + /** 모임 이름 */ + gatheringName: string + /** 모임 설명 */ + gatheringDescription?: string + /** 전체 멤버 수 */ + totalMembers: number + /** 모임 생성 후 경과 일수 */ + daysFromCreation: number + /** 전체 약속 수 */ + totalMeetings: number + /** 초대 링크 (초대 코드) */ + invitationLink: string +} + +/** 모임 생성 요청 */ +export interface CreateGatheringRequest { + /** 모임 이름 (최대 12자) */ + gatheringName: string + /** 모임 설명 (최대 150자) */ + gatheringDescription?: string +} + +/** 모임 생성 응답 */ +export interface CreateGatheringResponse extends GatheringBase { + /** 모임 ID */ + gatheringId: number +} + +/** 초대 코드로 모임 정보 조회 응답 */ +export type GatheringByInviteCodeResponse = GatheringBase + +/** 모임 가입 상태 */ +export type GatheringMemberStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' + +/** 모임 가입 응답 */ +export interface GatheringJoinResponse { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 가입 상태 */ + memberStatus: GatheringMemberStatus +} diff --git a/src/features/gatherings/hooks/gatheringQueryKeys.ts b/src/features/gatherings/hooks/gatheringQueryKeys.ts new file mode 100644 index 0000000..5110181 --- /dev/null +++ b/src/features/gatherings/hooks/gatheringQueryKeys.ts @@ -0,0 +1,10 @@ +/** + * 모임 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 + */ +export const gatheringQueryKeys = { + all: ['gatherings'] as const, + lists: () => [...gatheringQueryKeys.all, 'list'] as const, + detail: (id: number | string) => [...gatheringQueryKeys.all, 'detail', id] as const, + byInviteCode: (invitationCode: string) => + [...gatheringQueryKeys.all, 'invite', invitationCode] as const, +} as const diff --git a/src/features/gatherings/hooks/index.ts b/src/features/gatherings/hooks/index.ts new file mode 100644 index 0000000..49ddaab --- /dev/null +++ b/src/features/gatherings/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './gatheringQueryKeys' +export * from './useCreateGathering' +export * from './useGatheringByInviteCode' +export * from './useJoinGathering' diff --git a/src/features/gatherings/hooks/useCreateGathering.ts b/src/features/gatherings/hooks/useCreateGathering.ts new file mode 100644 index 0000000..43aaf56 --- /dev/null +++ b/src/features/gatherings/hooks/useCreateGathering.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { createGathering } from '../gatherings.api' +import type { CreateGatheringRequest, CreateGatheringResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 생성 mutation 훅 + */ +export const useCreateGathering = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, CreateGatheringRequest>({ + mutationFn: createGathering, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringByInviteCode.ts b/src/features/gatherings/hooks/useGatheringByInviteCode.ts new file mode 100644 index 0000000..9e5c38c --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringByInviteCode.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { getGatheringByInviteCode } from '../gatherings.api' +import type { GatheringByInviteCodeResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 초대 코드로 모임 정보 조회 훅 + * + * @param invitationCode - 초대 코드 + */ +export const useGatheringByInviteCode = (invitationCode: string | undefined) => { + return useQuery, ApiError>({ + queryKey: gatheringQueryKeys.byInviteCode(invitationCode ?? ''), + queryFn: () => getGatheringByInviteCode(invitationCode!), + enabled: !!invitationCode, + }) +} diff --git a/src/features/gatherings/hooks/useJoinGathering.ts b/src/features/gatherings/hooks/useJoinGathering.ts new file mode 100644 index 0000000..1fdbf0c --- /dev/null +++ b/src/features/gatherings/hooks/useJoinGathering.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { joinGathering } from '../gatherings.api' +import type { GatheringJoinResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 가입 신청 mutation 훅 + */ +export const useJoinGathering = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, string>({ + mutationFn: joinGathering, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all }) + }, + }) +} diff --git a/src/features/gatherings/index.ts b/src/features/gatherings/index.ts new file mode 100644 index 0000000..a356069 --- /dev/null +++ b/src/features/gatherings/index.ts @@ -0,0 +1,15 @@ +// Hooks +export * from './hooks' + +// API +export * from './gatherings.api' + +// Types +export type { + CreateGatheringRequest, + CreateGatheringResponse, + GatheringBase, + GatheringByInviteCodeResponse, + GatheringJoinResponse, + GatheringMemberStatus, +} from './gatherings.types' diff --git a/src/pages/Gatherings/CreateGatheringPage.tsx b/src/pages/Gatherings/CreateGatheringPage.tsx new file mode 100644 index 0000000..8e71465 --- /dev/null +++ b/src/pages/Gatherings/CreateGatheringPage.tsx @@ -0,0 +1,179 @@ +import { ChevronLeft, Link as LinkIcon } from 'lucide-react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import type { CreateGatheringResponse } from '@/features/gatherings' +import { useCreateGathering } from '@/features/gatherings' +import PaperPlane from '@/shared/assets/icon/paper-plane.svg' +import { ROUTES } from '@/shared/constants' +import { Button, Input, Textarea, TextButton } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +const MAX_NAME_LENGTH = 12 +const MAX_DESCRIPTION_LENGTH = 150 + +type Step = 'form' | 'complete' + +export default function CreateGatheringPage() { + const navigate = useNavigate() + const { openError } = useGlobalModalStore() + const [step, setStep] = useState('form') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [createdData, setCreatedData] = useState(null) + + const { mutate: createGathering, isPending } = useCreateGathering() + + const isValid = name.trim().length > 0 && name.length <= MAX_NAME_LENGTH + + const handleBack = () => { + navigate(-1) + } + + const handleSubmit = () => { + if (!isValid || isPending) return + + createGathering( + { gatheringName: name.trim(), gatheringDescription: description.trim() || undefined }, + { + onSuccess: (response) => { + setCreatedData(response.data) + setStep('complete') + }, + onError: () => { + openError('오류', '모임 생성 중 오류가 발생했습니다.') + }, + } + ) + } + + // 전체 초대 URL 생성 + const getFullInviteUrl = () => { + if (!createdData?.invitationLink) return '' + return `${window.location.origin}${ROUTES.INVITE(createdData.invitationLink)}` + } + + const handleCopyLink = async () => { + const fullUrl = getFullInviteUrl() + if (!fullUrl) return + + try { + await navigator.clipboard.writeText(fullUrl) + // TODO: toast 알림 표시 + } catch (error) { + console.error('클립보드 복사 실패:', error) + } + } + + const handleComplete = () => { + if (createdData?.gatheringId) { + navigate(ROUTES.GATHERING_DETAIL(createdData.gatheringId)) + } else { + navigate(ROUTES.GATHERINGS) + } + } + + return ( +
+ {/* 뒤로가기 */} +
+ + 뒤로가기 + +
+ + {step === 'form' ? ( + /* Step 1: 폼 입력 화면 */ +
+ {/* 제목 */} +

독서모임 만들기

+ + {/* 입력 폼 */} +
+ {/* 모임 이름 */} +
+
+ 모임 이름 + * +
+ setName(e.target.value)} + /> +
+ + {/* 모임 설명 */} +
+
+ 모임 설명 +
+