From c97b64a1cbf4d3297b2423fc1113ece5b628398d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=EC=9C=A0=EB=AF=B8?= Date: Sat, 11 Jul 2026 07:49:37 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20mvp1=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/client.ts | 20 ++++++ src/api/jetlagApi.ts | 16 +++++ src/pages/JetLagCalculator/ResultScreen.tsx | 30 +++++--- .../SleepCountryLoadingScreen.tsx | 71 ++++++++++++++++++- src/pages/JetLagCalculator/utils/time.ts | 18 +++++ src/stores/useSleepStore.ts | 32 +++++++++ src/utils/devideId.ts | 11 +++ 7 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 src/api/jetlagApi.ts create mode 100644 src/pages/JetLagCalculator/utils/time.ts create mode 100644 src/utils/devideId.ts diff --git a/src/api/client.ts b/src/api/client.ts index a5a52b1..21e7ae3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; +import { getOrCreateDeviceId } from '../utils/devideId'; interface ApiResponse { success: boolean; @@ -99,3 +100,22 @@ export const authorizedApiRequest = async ( headers, }); }; + +export const apiRequestWithGuestFallback = async ( + path: string, + options: RequestInit = {}, +): Promise => { + const headers = getRequestHeaders(options.headers); + const accessToken = getAccessToken(); + + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } else { + headers.set('X-Device-Id', getOrCreateDeviceId()); + } + + return apiRequest(path, { + ...options, + headers, + }); +}; diff --git a/src/api/jetlagApi.ts b/src/api/jetlagApi.ts new file mode 100644 index 0000000..fc64ccc --- /dev/null +++ b/src/api/jetlagApi.ts @@ -0,0 +1,16 @@ +import { apiRequestWithGuestFallback } from '@/api/client'; +import type { JetlagResult } from '../stores/useSleepStore'; + +export interface JetlagRequest { + currentBedtime: string; + currentWaketime: string; + targetBedtime: string; + targetWaketime: string; +} + +export const fetchJetlagResult = async (body: JetlagRequest): Promise => { + return apiRequestWithGuestFallback('/api/sleep/jetlag', { + method: 'POST', + body: JSON.stringify(body), + }); +}; diff --git a/src/pages/JetLagCalculator/ResultScreen.tsx b/src/pages/JetLagCalculator/ResultScreen.tsx index 94f10b0..fb60620 100644 --- a/src/pages/JetLagCalculator/ResultScreen.tsx +++ b/src/pages/JetLagCalculator/ResultScreen.tsx @@ -1,12 +1,10 @@ // pages/JetLagCalculator/ResultScreen.tsx -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSleepStore } from '../../stores/useSleepStore'; import arrowLeftIcon from '../../assets/icons/arrow-left.svg'; import ticketBackgroundImage from '../../assets/images/ticket-background.svg'; -const CARD_WIDTH = 342; -const CARD_HEIGHT = 563; const BARCODE_WIDTH = 267.64; const BARCODE_HEIGHT = 66.91; const BARCODE_BOTTOM_OFFSET = 13; @@ -46,15 +44,27 @@ const getCurrentDateLabel = (): string => { const ResultScreen = () => { const navigate = useNavigate(); + const currentSleepTime = useSleepStore((state) => state.sleepTime); const currentWakeTime = useSleepStore((state) => state.wakeTime); const targetSleepTime = useSleepStore((state) => state.desiredSleepTime); const targetWakeTime = useSleepStore((state) => state.desiredWakeTime); + const result = useSleepStore((state) => state.jetlagResult); + + // API 응답 없이 결과 화면에 직접 진입한 경우 방어 (새로고침 등) + useEffect(() => { + if (!result) { + navigate('/jetlag/onboarding', { replace: true }); + } + }, [result, navigate]); - const result = MOCK_RESULT; const dateLabel = useMemo(() => getCurrentDateLabel(), []); - const timeDifferenceHourLabel = Math.floor(result.timeDifferenceHour); - const timeDifferenceMinuteLabel = Math.round((result.timeDifferenceHour % 1) * 60); + + if (!result) return null; + + const timeDifferenceHourLabel = Math.floor(result.jetlagMinutes / 60); + const timeDifferenceMinuteLabel = result.jetlagMinutes % 60; + const isSlowerThanSeoul = result.direction === 'WEST'; const handleBackClick = () => { navigate(-1); @@ -105,7 +115,7 @@ const ResultScreen = () => { {timeDifferenceHourLabel}시간 {timeDifferenceMinuteLabel}분 {' '} - {result.isSlowerThanSeoul ? '느려요.' : '빨라요.'} + {isSlowerThanSeoul ? '느려요.' : '빨라요.'}

@@ -170,7 +180,7 @@ const ResultScreen = () => { letterSpacing: '0.9px', }} > - {result.countryNameEn} + {result.to.cityNameEn}

{ lineHeight: '18px', }} > - {result.countryNameKo} + {result.to.cityNameKr}

{/* 국가 코드 배지 */} @@ -207,7 +217,7 @@ const ResultScreen = () => { letterSpacing: '0.977px', }} > - {result.countryCode} + {result.to.airportCode} diff --git a/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx b/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx index efa349e..4100991 100644 --- a/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx +++ b/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx @@ -1,14 +1,77 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import GlobeBackground from './components/GlobeBackground'; import { AirplaneRoute } from './components/AirplaneRoute'; +import { useSleepStore } from '../../stores/useSleepStore'; +import { fetchJetlagResult } from '../../api/jetlagApi'; +import { toApiTimeFormat } from './utils/time'; const GLOBE_ALTITUDE = 1.2; const GLOBE_FOCUS_LAT = 10; const GLOBE_FOCUS_LNG = 20; +const MIN_LOADING_MS = 2500; // 애니메이션이 너무 빨리 끝나는 걸 방지 const SleepCountryLoadingScreen = () => { + console.log('API_BASE_URL:', import.meta.env.VITE_API_BASE_URL); + + const navigate = useNavigate(); + const sleepTime = useSleepStore((state) => state.sleepTime); + const wakeTime = useSleepStore((state) => state.wakeTime); + const desiredSleepTime = useSleepStore((state) => state.desiredSleepTime); + const desiredWakeTime = useSleepStore((state) => state.desiredWakeTime); + const setJetlagResult = useSleepStore((state) => state.setJetlagResult); + + const [error, setError] = useState(null); + const hasNavigatedRef = useRef(false); + + useEffect(() => { + const startedAt = Date.now(); + + const run = async () => { + try { + const result = await fetchJetlagResult({ + currentBedtime: toApiTimeFormat(sleepTime), + currentWaketime: toApiTimeFormat(wakeTime), + targetBedtime: toApiTimeFormat(desiredSleepTime), + targetWaketime: toApiTimeFormat(desiredWakeTime), + }); + + setJetlagResult(result); + + const elapsed = Date.now() - startedAt; + const remaining = Math.max(MIN_LOADING_MS - elapsed, 0); + + setTimeout(() => { + if (!hasNavigatedRef.current) { + hasNavigatedRef.current = true; + navigate('/jetlag/result', { replace: true }); + } + }, remaining); + } catch (err) { + setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했어요.'); + } + }; + + run(); + }, [sleepTime, wakeTime, desiredSleepTime, desiredWakeTime, navigate, setJetlagResult]); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + return (
- {/* 배경 지구본 */} { />
- {/* 비행기 애니메이션 */} + {/* navigateTo는 더이상 여기서 이동을 담당하지 않으므로, AirplaneRoute가 + navigateTo prop 없이도 애니메이션만 재생 가능한지 확인이 필요합니다. + 만약 AirplaneRoute 내부에서 반드시 navigateTo가 필요하다면, + 의미 없는 더미 경로를 주고 실제 이동은 위 useEffect가 처리하도록 두면 됩니다. */}
@@ -25,7 +91,6 @@ const SleepCountryLoadingScreen = () => {
찾고 있어요 -

현재 수면과 목표 수면의
diff --git a/src/pages/JetLagCalculator/utils/time.ts b/src/pages/JetLagCalculator/utils/time.ts new file mode 100644 index 0000000..0013db6 --- /dev/null +++ b/src/pages/JetLagCalculator/utils/time.ts @@ -0,0 +1,18 @@ +const MINUTES_PER_HOUR = 60; + +export const parseKoreanTime = (label: string): number => { + const match = label.match(/(오전|오후)\s*(\d{1,2}):(\d{2})/); + if (!match) return 0; + const [, period, hourString, minuteString] = match; + let hour = parseInt(hourString, 10) % 12; + if (period === '오후') hour += 12; + return hour * MINUTES_PER_HOUR + parseInt(minuteString, 10); +}; + +// "오전 3:00" -> "03:00" +export const toApiTimeFormat = (label: string): string => { + const totalMinutes = parseKoreanTime(label); + const hour = Math.floor(totalMinutes / MINUTES_PER_HOUR); + const minute = totalMinutes % MINUTES_PER_HOUR; + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +}; diff --git a/src/stores/useSleepStore.ts b/src/stores/useSleepStore.ts index 6f8f6e3..5bcafda 100644 --- a/src/stores/useSleepStore.ts +++ b/src/stores/useSleepStore.ts @@ -5,15 +5,44 @@ const DEFAULT_WAKE_TIME = '오전 10:00'; const DEFAULT_DESIRED_SLEEP_TIME = '오후 11:00'; const DEFAULT_DESIRED_WAKE_TIME = '오전 7:00'; +export interface JetlagCity { + countryName: string; + cityNameKr: string; + cityNameEn: string; + airportCode: string; + latitude: number; + longitude: number; +} + +export interface JetlagSleepInfo { + bedtime: string; + waketime: string; + sleepMinutes: number; +} + +export interface JetlagResult { + resultId: number; + from: JetlagCity; + to: JetlagCity; + currentSleep: JetlagSleepInfo; + targetSleep: JetlagSleepInfo; + jetlagMinutes: number; + jetlagLabel: string; + direction: 'EAST' | 'WEST'; + resultDate: string; +} + interface SleepState { sleepTime: string; wakeTime: string; desiredSleepTime: string; desiredWakeTime: string; + jetlagResult: JetlagResult | null; setSleepTime: (sleepTime: string) => void; setWakeTime: (wakeTime: string) => void; setDesiredSleepTime: (desiredSleepTime: string) => void; setDesiredWakeTime: (desiredWakeTime: string) => void; + setJetlagResult: (result: JetlagResult) => void; resetSleepState: () => void; } @@ -22,15 +51,18 @@ export const useSleepStore = create((set) => ({ wakeTime: DEFAULT_WAKE_TIME, desiredSleepTime: DEFAULT_DESIRED_SLEEP_TIME, desiredWakeTime: DEFAULT_DESIRED_WAKE_TIME, + jetlagResult: null, setSleepTime: (sleepTime) => set({ sleepTime }), setWakeTime: (wakeTime) => set({ wakeTime }), setDesiredSleepTime: (desiredSleepTime) => set({ desiredSleepTime }), setDesiredWakeTime: (desiredWakeTime) => set({ desiredWakeTime }), + setJetlagResult: (jetlagResult) => set({ jetlagResult }), resetSleepState: () => set({ sleepTime: DEFAULT_SLEEP_TIME, wakeTime: DEFAULT_WAKE_TIME, desiredSleepTime: DEFAULT_DESIRED_SLEEP_TIME, desiredWakeTime: DEFAULT_DESIRED_WAKE_TIME, + jetlagResult: null, }), })); diff --git a/src/utils/devideId.ts b/src/utils/devideId.ts new file mode 100644 index 0000000..e2cb05c --- /dev/null +++ b/src/utils/devideId.ts @@ -0,0 +1,11 @@ +// src/utils/deviceId.ts +const DEVICE_ID_STORAGE_KEY = 'deviceId'; + +export const getOrCreateDeviceId = (): string => { + const existing = localStorage.getItem(DEVICE_ID_STORAGE_KEY); + if (existing) return existing; + + const newDeviceId = crypto.randomUUID(); + localStorage.setItem(DEVICE_ID_STORAGE_KEY, newDeviceId); + return newDeviceId; +}; From e4b3ec24ac7c9aa61211fc9bbc08420e75ad176f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=EC=9C=A0=EB=AF=B8?= Date: Sat, 11 Jul 2026 08:17:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A1=9C=EB=94=A9=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EA=B2=B0=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/jetlagApi.ts | 1 + .../SleepCountryLoadingScreen.tsx | 100 ++++++++++++------ .../components/AirPlaneRoute.tsx | 36 ++----- 3 files changed, 80 insertions(+), 57 deletions(-) diff --git a/src/api/jetlagApi.ts b/src/api/jetlagApi.ts index fc64ccc..e45c370 100644 --- a/src/api/jetlagApi.ts +++ b/src/api/jetlagApi.ts @@ -1,3 +1,4 @@ +// src/services/jetlagApi.ts import { apiRequestWithGuestFallback } from '@/api/client'; import type { JetlagResult } from '../stores/useSleepStore'; diff --git a/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx b/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx index 4100991..86d58b2 100644 --- a/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx +++ b/src/pages/JetLagCalculator/SleepCountryLoadingScreen.tsx @@ -5,15 +5,16 @@ import { AirplaneRoute } from './components/AirplaneRoute'; import { useSleepStore } from '../../stores/useSleepStore'; import { fetchJetlagResult } from '../../api/jetlagApi'; import { toApiTimeFormat } from './utils/time'; +import { ApiError } from '@/api/client'; const GLOBE_ALTITUDE = 1.2; const GLOBE_FOCUS_LAT = 10; const GLOBE_FOCUS_LNG = 20; -const MIN_LOADING_MS = 2500; // 애니메이션이 너무 빨리 끝나는 걸 방지 +const MIN_LOADING_MS = 3000; // 최소 3초는 로딩 화면 유지 -const SleepCountryLoadingScreen = () => { - console.log('API_BASE_URL:', import.meta.env.VITE_API_BASE_URL); +type LoadingOutcome = 'success' | 'error' | 'guestExhausted'; +const SleepCountryLoadingScreen = () => { const navigate = useNavigate(); const sleepTime = useSleepStore((state) => state.sleepTime); const wakeTime = useSleepStore((state) => state.wakeTime); @@ -22,38 +23,76 @@ const SleepCountryLoadingScreen = () => { const setJetlagResult = useSleepStore((state) => state.setJetlagResult); const [error, setError] = useState(null); - const hasNavigatedRef = useRef(false); + const [isGuestTrialExhausted, setIsGuestTrialExhausted] = useState(false); + const pendingOutcomeRef = useRef(null); useEffect(() => { const startedAt = Date.now(); - const run = async () => { - try { - const result = await fetchJetlagResult({ - currentBedtime: toApiTimeFormat(sleepTime), - currentWaketime: toApiTimeFormat(wakeTime), - targetBedtime: toApiTimeFormat(desiredSleepTime), - targetWaketime: toApiTimeFormat(desiredWakeTime), - }); + const applyOutcome = () => { + const outcome = pendingOutcomeRef.current; - setJetlagResult(result); + if (outcome === 'success') { + navigate('/jetlag/result', { replace: true }); + return; + } - const elapsed = Date.now() - startedAt; - const remaining = Math.max(MIN_LOADING_MS - elapsed, 0); - - setTimeout(() => { - if (!hasNavigatedRef.current) { - hasNavigatedRef.current = true; - navigate('/jetlag/result', { replace: true }); - } - }, remaining); - } catch (err) { - setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했어요.'); + if (outcome === 'guestExhausted') { + setIsGuestTrialExhausted(true); + return; } + + setError('결과를 불러오지 못했어요. 다시 시도해주세요.'); }; - run(); - }, [sleepTime, wakeTime, desiredSleepTime, desiredWakeTime, navigate, setJetlagResult]); + const finishLoading = () => { + const elapsed = Date.now() - startedAt; + const remaining = Math.max(MIN_LOADING_MS - elapsed, 0); + + setTimeout(applyOutcome, remaining); + }; + + fetchJetlagResult({ + currentBedtime: toApiTimeFormat(sleepTime), + currentWaketime: toApiTimeFormat(wakeTime), + targetBedtime: toApiTimeFormat(desiredSleepTime), + targetWaketime: toApiTimeFormat(desiredWakeTime), + }) + .then((result) => { + setJetlagResult(result); + pendingOutcomeRef.current = 'success'; + }) + .catch((err) => { + if (err instanceof ApiError && err.code === 'GUEST_TRIAL_EXHAUSTED') { + pendingOutcomeRef.current = 'guestExhausted'; + return; + } + pendingOutcomeRef.current = 'error'; + }) + .finally(() => { + finishLoading(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (isGuestTrialExhausted) { + return ( +

+

+ 무료 체험은 1회만 가능해요. +
+ 회원가입하고 계속 이용해보세요. +

+ +
+ ); + } if (error) { return ( @@ -72,6 +111,7 @@ const SleepCountryLoadingScreen = () => { return (
+ {/* 배경 지구본 */} { />
- {/* navigateTo는 더이상 여기서 이동을 담당하지 않으므로, AirplaneRoute가 - navigateTo prop 없이도 애니메이션만 재생 가능한지 확인이 필요합니다. - 만약 AirplaneRoute 내부에서 반드시 navigateTo가 필요하다면, - 의미 없는 더미 경로를 주고 실제 이동은 위 useEffect가 처리하도록 두면 됩니다. */} - + {/* 비행기 애니메이션 (로딩이 길어져도 자연스럽도록 반복 재생) */} +

@@ -91,6 +128,7 @@ const SleepCountryLoadingScreen = () => {
찾고 있어요

+

현재 수면과 목표 수면의
diff --git a/src/pages/JetLagCalculator/components/AirPlaneRoute.tsx b/src/pages/JetLagCalculator/components/AirPlaneRoute.tsx index 0efe3f6..b15b4ff 100644 --- a/src/pages/JetLagCalculator/components/AirPlaneRoute.tsx +++ b/src/pages/JetLagCalculator/components/AirPlaneRoute.tsx @@ -1,7 +1,6 @@ // components/AirplaneRoute.tsx import { useEffect, useRef, useState } from 'react'; import { animate, useMotionValue } from 'framer-motion'; -import { useNavigate } from 'react-router-dom'; import airplaneIcon from '../../../assets/icons/airplane.svg'; import mapLocationIcon from '../../../assets/icons/map-location.svg'; @@ -9,24 +8,16 @@ const ROUTE_WIDTH = 220; const ROUTE_HEIGHT = 30; const ROUTE_PATH_D = 'M0.369873 34.3123C78.8694 -10.1872 145.869 -10.687 220.37 34.3123'; const ANIMATION_DURATION_SECOND = 2.4; -const ANIMATION_DURATION_MS = ANIMATION_DURATION_SECOND * 1000; const ROTATION_SAMPLE_DISTANCE = 1; interface AirplaneRouteProps { - /** 애니메이션 종료 후 이동할 경로 */ - navigateTo: string; /** 애니메이션 자동 재생 여부 (기본: true) */ isAutoPlay?: boolean; - /** 화면 이동 전에 실행할 부가 로직 (선택) */ - onBeforeNavigate?: () => void; + /** 도착할 때마다 반복 재생할지 여부 (기본: true) */ + isLooping?: boolean; } -export const AirplaneRoute = ({ - navigateTo, - isAutoPlay = true, - onBeforeNavigate, -}: AirplaneRouteProps) => { - const navigate = useNavigate(); +export const AirplaneRoute = ({ isAutoPlay = true, isLooping = true }: AirplaneRouteProps) => { const pathRef = useRef(null); const [pathLength, setPathLength] = useState(0); const progress = useMotionValue(0); @@ -61,29 +52,22 @@ export const AirplaneRoute = ({ return () => unsubscribe(); }, [progress, pathLength]); - // Framer Motion으로 progress를 0 → 1까지 애니메이션 + // Framer Motion으로 progress를 0 → 1까지 애니메이션 (필요 시 반복) useEffect(() => { if (!isAutoPlay || pathLength === 0) return; const controls = animate(progress, 1, { duration: ANIMATION_DURATION_SECOND, ease: 'easeInOut', + repeat: isLooping ? Infinity : 0, + repeatType: 'loop', + onRepeat: () => { + progress.set(0); + }, }); return () => controls.stop(); - }, [isAutoPlay, pathLength, progress]); - - // 애니메이션 종료 후 화면 이동 - useEffect(() => { - if (!isAutoPlay) return; - - const timer = setTimeout(() => { - onBeforeNavigate?.(); - navigate(navigateTo); - }, ANIMATION_DURATION_MS); - - return () => clearTimeout(timer); - }, [isAutoPlay, navigate, navigateTo, onBeforeNavigate]); + }, [isAutoPlay, isLooping, pathLength, progress]); return (

Date: Sat, 11 Jul 2026 08:23:06 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20loading=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/JetLagCalculator/ResultScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/JetLagCalculator/ResultScreen.tsx b/src/pages/JetLagCalculator/ResultScreen.tsx index fb60620..fcfb02b 100644 --- a/src/pages/JetLagCalculator/ResultScreen.tsx +++ b/src/pages/JetLagCalculator/ResultScreen.tsx @@ -45,11 +45,12 @@ const getCurrentDateLabel = (): string => { const ResultScreen = () => { const navigate = useNavigate(); + const result = useSleepStore((state) => state.jetlagResult); + const currentSleepTime = useSleepStore((state) => state.sleepTime); const currentWakeTime = useSleepStore((state) => state.wakeTime); const targetSleepTime = useSleepStore((state) => state.desiredSleepTime); const targetWakeTime = useSleepStore((state) => state.desiredWakeTime); - const result = useSleepStore((state) => state.jetlagResult); // API 응답 없이 결과 화면에 직접 진입한 경우 방어 (새로고침 등) useEffect(() => { From 233b5853a2aefb2788a752b52fe438238d74407f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=EC=9C=A0=EB=AF=B8?= Date: Sat, 11 Jul 2026 09:11:09 +0900 Subject: [PATCH 4/4] fix --- src/api/client.ts | 18 +++++++++++++++++- src/utils/{devideId.ts => deviceId.ts} | 0 src/utils/userId.ts | 21 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) rename src/utils/{devideId.ts => deviceId.ts} (100%) create mode 100644 src/utils/userId.ts diff --git a/src/api/client.ts b/src/api/client.ts index 21e7ae3..8ca4065 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; -import { getOrCreateDeviceId } from '../utils/devideId'; +import { getOrCreateDeviceId } from '../utils/deviceId'; +import { getOrCreateUserId, setUserId } from '../utils/userId'; interface ApiResponse { success: boolean; @@ -59,6 +60,16 @@ const getRequestHeaders = (headers?: HeadersInit) => { return requestHeaders; }; +// data에 userId가 실려오면 로컬 저장소 값을 서버 확정 값으로 갱신 +const syncUserIdFromResponseData = (data: unknown): void => { + if (data && typeof data === 'object' && 'userId' in data) { + const responseUserId = (data as { userId?: unknown }).userId; + if (typeof responseUserId === 'string' && responseUserId.length > 0) { + setUserId(responseUserId); + } + } +}; + export const apiRequest = async ( path: string, options: RequestInit = {}, @@ -80,6 +91,8 @@ export const apiRequest = async ( const result = (await response.json()) as ApiResponse; + syncUserIdFromResponseData(result.data); + return result.data; }; @@ -108,6 +121,9 @@ export const apiRequestWithGuestFallback = async ( const headers = getRequestHeaders(options.headers); const accessToken = getAccessToken(); + // userId는 로그인 여부와 무관하게 항상 전송 (없으면 새로 생성해서 전송) + headers.set('X-User-Id', getOrCreateUserId()); + if (accessToken) { headers.set('Authorization', `Bearer ${accessToken}`); } else { diff --git a/src/utils/devideId.ts b/src/utils/deviceId.ts similarity index 100% rename from src/utils/devideId.ts rename to src/utils/deviceId.ts diff --git a/src/utils/userId.ts b/src/utils/userId.ts new file mode 100644 index 0000000..4b9f142 --- /dev/null +++ b/src/utils/userId.ts @@ -0,0 +1,21 @@ +const USER_ID_STORAGE_KEY = 'userId'; + +export const getOrCreateUserId = (): string => { + const existingUserId = localStorage.getItem(USER_ID_STORAGE_KEY); + + if (existingUserId) { + return existingUserId; + } + + const newUserId = crypto.randomUUID(); + localStorage.setItem(USER_ID_STORAGE_KEY, newUserId); + return newUserId; +}; + +export const setUserId = (userId: string): void => { + localStorage.setItem(USER_ID_STORAGE_KEY, userId); +}; + +export const clearUserId = (): void => { + localStorage.removeItem(USER_ID_STORAGE_KEY); +};