- {/* 비행기 애니메이션 */}
+ {/* 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회만 가능해요.
+
+ 회원가입하고 계속 이용해보세요.
+
+
navigate('/signup')}
+ className="rounded-[0.75rem] bg-[#0D2571] px-[1.5rem] py-[0.75rem] text-white"
+ >
+ 회원가입하기
+
+
+ );
+ }
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);
+};