From d26ec722e5962b10d1db794f5cdd1a167f65bb56 Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 00:45:28 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(web):=20accessToken=EC=9D=84=20localSt?= =?UTF-8?q?orage=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=84=B8=EC=85=98=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EB=A3=A8=ED=8A=B8=EC=97=90=EC=84=9C=201?= =?UTF-8?q?=ED=9A=8C=EB=A7=8C=20=EC=88=98=ED=96=89=ED=95=9C=EB=8B=A4=20(#1?= =?UTF-8?q?79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accessToken을 zustand 인메모리에서 localStorage 기반으로 저장하도록 변경했습니다 - 로그인 사용자의 /login 접근과 온보딩 완료 사용자의 /onboarding 접근을 차단하는 가드를 추가했습니다 - 세션 초기화(reissue)를 루트 AuthProvider에서 1회만 수행하도록 통합해 하이드레이션 깜빡임과 중복 호출을 제거했습니다 --- apps/timo-web/app/[locale]/layout.tsx | 5 +- apps/timo-web/app/[locale]/login/page.tsx | 7 ++- .../_containers/OauthCallbackContainer.tsx | 13 +++- .../_containers/OnboardingFunnelContainer.tsx | 5 ++ .../timo-web/app/[locale]/onboarding/page.tsx | 5 +- .../providers/auth/AuthGuardProvider.tsx | 31 +++------- apps/timo-web/providers/auth/AuthProvider.tsx | 40 +++++++++++++ .../providers/auth/GuestGuardProvider.tsx | 27 +++++++++ .../auth/OnboardingCompletedGuardProvider.tsx | 31 ++++++++++ apps/timo-web/stores/auth/useAuthStore.ts | 33 +++++++++- apps/timo-web/utils/auth/token-manager.ts | 60 +++++++++++++++++++ 11 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 apps/timo-web/providers/auth/AuthProvider.tsx create mode 100644 apps/timo-web/providers/auth/GuestGuardProvider.tsx create mode 100644 apps/timo-web/providers/auth/OnboardingCompletedGuardProvider.tsx create mode 100644 apps/timo-web/utils/auth/token-manager.ts diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index fb722c20..64306b63 100644 --- a/apps/timo-web/app/[locale]/layout.tsx +++ b/apps/timo-web/app/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { setRequestLocale } from "next-intl/server"; import type { Metadata } from "next"; import { routing } from "@/i18n/routing"; +import { AuthProvider } from "@/providers/auth/AuthProvider"; import { OverlayProvider } from "@/providers/OverlayProvider"; import { QueryProvider } from "@/providers/query/QueryProvider"; @@ -48,7 +49,9 @@ export default async function RootLayout({ - {children} + + {children} + diff --git a/apps/timo-web/app/[locale]/login/page.tsx b/apps/timo-web/app/[locale]/login/page.tsx index 4abaaf3e..bdb5f78e 100644 --- a/apps/timo-web/app/[locale]/login/page.tsx +++ b/apps/timo-web/app/[locale]/login/page.tsx @@ -1,5 +1,10 @@ import { LoginContainer } from "@/app/[locale]/login/_containers/LoginContainer"; +import { GuestGuardProvider } from "@/providers/auth/GuestGuardProvider"; export default function LoginPage() { - return ; + return ( + + + + ); } diff --git a/apps/timo-web/app/[locale]/oauth/callback/_containers/OauthCallbackContainer.tsx b/apps/timo-web/app/[locale]/oauth/callback/_containers/OauthCallbackContainer.tsx index e642ca34..ad28378f 100644 --- a/apps/timo-web/app/[locale]/oauth/callback/_containers/OauthCallbackContainer.tsx +++ b/apps/timo-web/app/[locale]/oauth/callback/_containers/OauthCallbackContainer.tsx @@ -15,6 +15,9 @@ export const OauthCallbackContainer = () => { const router = useRouter(); const queryClient = useQueryClient(); const setAccessToken = useAuthStore((state) => state.setAccessToken); + const setOnboardingCompleted = useAuthStore( + (state) => state.setOnboardingCompleted, + ); const { mutate } = useToken(); const hasRequested = useRef(false); @@ -35,6 +38,7 @@ export const OauthCallbackContainer = () => { return; } setAccessToken(data.accessToken); + setOnboardingCompleted(data.user.onboardingCompleted); queryClient.setQueryData(getGetMyProfileQueryKey(), data.user); router.replace(data.isNewUser ? ROUTES.ONBOARDING : ROUTES.HOME); }, @@ -43,7 +47,14 @@ export const OauthCallbackContainer = () => { }, }, ); - }, [code, mutate, queryClient, router, setAccessToken]); + }, [ + code, + mutate, + queryClient, + router, + setAccessToken, + setOnboardingCompleted, + ]); return null; }; diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx index b1038f16..864eacec 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx @@ -16,6 +16,7 @@ import { LottiePlayer } from "@/components/lottie/LottiePlayer"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { ROUTES } from "@/constants/routes"; import { useRouter } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth/useAuthStore"; const STEP_NUMBER: Record = { Language: 1, @@ -33,6 +34,9 @@ const ONBOARDING_LANGUAGE_MAP: Record<"ko" | "en", OnboardingRequestLanguage> = export const OnboardingFunnelContainer = () => { const t = useTranslations("Toast"); const router = useRouter(); + const setOnboardingCompleted = useAuthStore( + (state) => state.setOnboardingCompleted, + ); const [answers, setAnswers] = useState< Partial >({}); @@ -142,6 +146,7 @@ export const OnboardingFunnelContainer = () => { }, { onSuccess: () => { + setOnboardingCompleted(true); router.replace(ROUTES.HOME, { locale: answers.language, }); diff --git a/apps/timo-web/app/[locale]/onboarding/page.tsx b/apps/timo-web/app/[locale]/onboarding/page.tsx index 81ea936d..b7dbca23 100644 --- a/apps/timo-web/app/[locale]/onboarding/page.tsx +++ b/apps/timo-web/app/[locale]/onboarding/page.tsx @@ -1,10 +1,13 @@ import { OnboardingFunnelContainer } from "@/app/[locale]/onboarding/_containers/OnboardingFunnelContainer"; import { AuthGuardProvider } from "@/providers/auth/AuthGuardProvider"; +import { OnboardingCompletedGuardProvider } from "@/providers/auth/OnboardingCompletedGuardProvider"; export default function OnboardingPage() { return ( - + + + ); } diff --git a/apps/timo-web/providers/auth/AuthGuardProvider.tsx b/apps/timo-web/providers/auth/AuthGuardProvider.tsx index 6ab4bbec..5a142b85 100644 --- a/apps/timo-web/providers/auth/AuthGuardProvider.tsx +++ b/apps/timo-web/providers/auth/AuthGuardProvider.tsx @@ -1,8 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; -import { useReissue } from "@/api/generated/endpoints/auth/auth"; import { ROUTES } from "@/constants/routes"; import { useRouter } from "@/i18n/navigation"; import { useAuthStore } from "@/stores/auth/useAuthStore"; @@ -13,32 +12,16 @@ interface AuthGuardProviderProps { export const AuthGuardProvider = ({ children }: AuthGuardProviderProps) => { const accessToken = useAuthStore((state) => state.accessToken); - const setAccessToken = useAuthStore((state) => state.setAccessToken); + const isInitialized = useAuthStore((state) => state.isInitialized); const router = useRouter(); - const { mutate } = useReissue(); - const hasChecked = useRef(false); - const [isReady, setIsReady] = useState(!!accessToken); useEffect(() => { - if (accessToken || hasChecked.current) return; - hasChecked.current = true; + if (isInitialized && !accessToken) { + router.replace(ROUTES.LOGIN); + } + }, [accessToken, isInitialized, router]); - mutate(undefined, { - onSuccess: ({ data }) => { - if (!data) { - router.replace(ROUTES.LOGIN); - return; - } - setAccessToken(data.accessToken); - setIsReady(true); - }, - onError: () => { - router.replace(ROUTES.LOGIN); - }, - }); - }, [accessToken, mutate, router, setAccessToken]); - - if (!isReady) return null; + if (!isInitialized || !accessToken) return null; return <>{children}; }; diff --git a/apps/timo-web/providers/auth/AuthProvider.tsx b/apps/timo-web/providers/auth/AuthProvider.tsx new file mode 100644 index 00000000..f76a9c62 --- /dev/null +++ b/apps/timo-web/providers/auth/AuthProvider.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useReissue } from "@/api/generated/endpoints/auth/auth"; +import { useAuthStore } from "@/stores/auth/useAuthStore"; + +interface AuthProviderProps { + children: React.ReactNode; +} + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const accessToken = useAuthStore((state) => state.accessToken); + const setAccessToken = useAuthStore((state) => state.setAccessToken); + const setInitialized = useAuthStore((state) => state.setInitialized); + const { mutate } = useReissue(); + const hasChecked = useRef(false); + + useEffect(() => { + if (accessToken || hasChecked.current) { + setInitialized(true); + return; + } + hasChecked.current = true; + + mutate(undefined, { + onSuccess: ({ data }) => { + if (data) { + setAccessToken(data.accessToken); + } + setInitialized(true); + }, + onError: () => { + setInitialized(true); + }, + }); + }, [accessToken, mutate, setAccessToken, setInitialized]); + + return <>{children}; +}; diff --git a/apps/timo-web/providers/auth/GuestGuardProvider.tsx b/apps/timo-web/providers/auth/GuestGuardProvider.tsx new file mode 100644 index 00000000..de1d27a4 --- /dev/null +++ b/apps/timo-web/providers/auth/GuestGuardProvider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; + +import { ROUTES } from "@/constants/routes"; +import { useRouter } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth/useAuthStore"; + +interface GuestGuardProviderProps { + children: React.ReactNode; +} + +export const GuestGuardProvider = ({ children }: GuestGuardProviderProps) => { + const accessToken = useAuthStore((state) => state.accessToken); + const isInitialized = useAuthStore((state) => state.isInitialized); + const router = useRouter(); + + useEffect(() => { + if (isInitialized && accessToken) { + router.replace(ROUTES.HOME); + } + }, [accessToken, isInitialized, router]); + + if (!isInitialized || accessToken) return null; + + return <>{children}; +}; diff --git a/apps/timo-web/providers/auth/OnboardingCompletedGuardProvider.tsx b/apps/timo-web/providers/auth/OnboardingCompletedGuardProvider.tsx new file mode 100644 index 00000000..e06c79c6 --- /dev/null +++ b/apps/timo-web/providers/auth/OnboardingCompletedGuardProvider.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; + +import { ROUTES } from "@/constants/routes"; +import { useRouter } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth/useAuthStore"; + +interface OnboardingCompletedGuardProviderProps { + children: React.ReactNode; +} + +export const OnboardingCompletedGuardProvider = ({ + children, +}: OnboardingCompletedGuardProviderProps) => { + const onboardingCompleted = useAuthStore( + (state) => state.onboardingCompleted, + ); + const isInitialized = useAuthStore((state) => state.isInitialized); + const router = useRouter(); + + useEffect(() => { + if (isInitialized && onboardingCompleted) { + router.replace(ROUTES.HOME); + } + }, [isInitialized, onboardingCompleted, router]); + + if (!isInitialized || onboardingCompleted) return null; + + return <>{children}; +}; diff --git a/apps/timo-web/stores/auth/useAuthStore.ts b/apps/timo-web/stores/auth/useAuthStore.ts index 83b25043..c9b2a6db 100644 --- a/apps/timo-web/stores/auth/useAuthStore.ts +++ b/apps/timo-web/stores/auth/useAuthStore.ts @@ -1,13 +1,40 @@ import { create } from "zustand"; +import { + clearAccessToken as clearStoredAccessToken, + clearOnboardingCompleted as clearStoredOnboardingCompleted, + getAccessToken, + getOnboardingCompleted, + setAccessToken as setStoredAccessToken, + setOnboardingCompleted as setStoredOnboardingCompleted, +} from "@/utils/auth/token-manager"; + interface AuthState { accessToken: string | null; + onboardingCompleted: boolean; + isInitialized: boolean; setAccessToken: (token: string) => void; clearAccessToken: () => void; + setOnboardingCompleted: (completed: boolean) => void; + setInitialized: (value: boolean) => void; } export const useAuthStore = create((set) => ({ - accessToken: null, - setAccessToken: (token) => set({ accessToken: token }), - clearAccessToken: () => set({ accessToken: null }), + accessToken: getAccessToken(), + onboardingCompleted: getOnboardingCompleted(), + isInitialized: false, + setAccessToken: (token) => { + setStoredAccessToken(token); + set({ accessToken: token }); + }, + clearAccessToken: () => { + clearStoredAccessToken(); + clearStoredOnboardingCompleted(); + set({ accessToken: null, onboardingCompleted: false }); + }, + setOnboardingCompleted: (completed) => { + setStoredOnboardingCompleted(completed); + set({ onboardingCompleted: completed }); + }, + setInitialized: (value) => set({ isInitialized: value }), })); diff --git a/apps/timo-web/utils/auth/token-manager.ts b/apps/timo-web/utils/auth/token-manager.ts new file mode 100644 index 00000000..592cf180 --- /dev/null +++ b/apps/timo-web/utils/auth/token-manager.ts @@ -0,0 +1,60 @@ +const ACCESS_TOKEN_KEY = "accessToken"; +const ONBOARDING_COMPLETED_KEY = "onboardingCompleted"; + +const isBrowser = typeof window !== "undefined"; + +/** + * localStorage에 저장된 accessToken을 가져옵니다. + * + * @returns 저장된 accessToken, 없거나 서버 환경이면 null + */ +export const getAccessToken = (): string | null => { + if (!isBrowser) return null; + return window.localStorage.getItem(ACCESS_TOKEN_KEY); +}; + +/** + * accessToken을 localStorage에 저장합니다. + * + * @param token - 저장할 accessToken + */ +export const setAccessToken = (token: string): void => { + if (!isBrowser) return; + window.localStorage.setItem(ACCESS_TOKEN_KEY, token); +}; + +/** + * localStorage에 저장된 accessToken을 제거합니다. + */ +export const clearAccessToken = (): void => { + if (!isBrowser) return; + window.localStorage.removeItem(ACCESS_TOKEN_KEY); +}; + +/** + * 온보딩 완료 여부를 localStorage에서 가져옵니다. + * + * @returns 온보딩 완료 여부, 저장된 값이 없거나 서버 환경이면 false + */ +export const getOnboardingCompleted = (): boolean => { + if (!isBrowser) return false; + return window.localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true"; +}; + +/** + * 온보딩 완료 여부를 localStorage에 저장합니다. + * + * @param completed - 저장할 온보딩 완료 여부 + */ +export const setOnboardingCompleted = (completed: boolean): void => { + if (!isBrowser) return; + window.localStorage.setItem(ONBOARDING_COMPLETED_KEY, String(completed)); +}; + +/** + * localStorage에 저장된 온보딩 완료 여부를 제거합니다. + */ +export const clearOnboardingCompleted = (): void => { + if (!isBrowser) return; + window.localStorage.removeItem(ONBOARDING_COMPLETED_KEY); +}; From e91b6550e75bb14e3d8651705c0a47554b7e6e1b Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 00:46:51 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(web):=20=ED=99=88=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=8B=9C=20=EC=9C=A0=EC=A0=80=20=EC=96=B8=EC=96=B4=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=20ko/en=20=EB=A1=9C=EC=BC=80=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=8F=99=EA=B8=B0=ED=99=94=ED=95=9C=EB=8B=A4=20(#1?= =?UTF-8?q?79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 접근 시 유저 프로필을 조회해 서버에 저장된 언어와 현재 로케일이 다르면 자동으로 전환하도록 했습니다 --- .../_containers/HomeLanguageSyncContainer.tsx | 27 +++++++++++++++++++ .../home/_queries/use-my-profile.ts | 15 +++++++++++ .../(main)/(with-time-sidebar)/home/page.tsx | 4 +++ 3 files changed, 46 insertions(+) create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx create mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx new file mode 100644 index 00000000..abca6f5d --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useLocale } from "next-intl"; +import { useEffect } from "react"; + +import { useMyProfile } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile"; +import { usePathname, useRouter } from "@/i18n/navigation"; + +const isSupportedLocale = (value: string): value is "ko" | "en" => + value === "ko" || value === "en"; + +export const HomeLanguageSyncContainer = () => { + const { data: profile } = useMyProfile(); + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + const language = profile?.language.toLowerCase(); + if (!language || !isSupportedLocale(language) || language === locale) + return; + + router.replace(pathname, { locale: language }); + }, [locale, pathname, profile, router]); + + return null; +}; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts new file mode 100644 index 00000000..22f02d6f --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { + getGetMyProfileQueryKey, + getMyProfile, +} from "@/api/generated/endpoints/user/user"; + +export const useMyProfile = () => + useSuspenseQuery({ + queryKey: getGetMyProfileQueryKey(), + queryFn: ({ signal }) => getMyProfile(undefined, signal), + select: ({ data }) => data, + }); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx index 7801a1d7..612c3c6f 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx @@ -1,10 +1,14 @@ import { HomeHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer"; +import { HomeLanguageSyncContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer"; import { HomeTodoContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer"; import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; export default function HomePage() { return (
+ + + From cbeb6a598cb4bc564fb809030802341db6c2a08a Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 00:48:09 +0900 Subject: [PATCH 3/9] =?UTF-8?q?file(web):=20utils=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20duration/date=20=ED=95=98=EC=9C=84=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - duration 변환 유틸 3개를 utils/duration/으로, date/시간 유틸 3개를 utils/date/로 이동했습니다 - 이동한 파일을 참조하던 import 경로를 전체 업데이트했습니다 - 더 이상 필요 없는 .gitkeep 플레이스홀더를 정리했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 2 +- .../home/_containers/todo-card/HomeDayHeaderContainer.tsx | 2 +- .../(with-time-sidebar)/home/_hooks/use-home-view-mode.ts | 7 ++++++- .../(with-time-sidebar)/home/_mocks/home-view-mock.ts | 2 +- .../(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts | 2 +- .../(main)/(with-time-sidebar)/home/_utils/date.ts | 2 +- .../(main)/(with-time-sidebar)/home/_utils/day-of-week.ts | 2 +- .../today/_containers/TodayDateHeaderContainer.tsx | 4 ++-- .../today/_containers/TodayTodoListContainer.tsx | 4 ++-- .../(main)/focus/_containers/FocusSessionContainer.tsx | 6 +++--- .../(main)/statistics/_components/StatisticsCalendar.tsx | 2 +- .../(main)/statistics/_containers/StatisticsContainer.tsx | 2 +- .../components/layout/sidebar/time/TimeSidebarHeader.tsx | 2 +- .../components/layout/sidebar/time/TimeboxPanel.tsx | 2 +- apps/timo-web/components/timer/TimerCompleteModalPanel.tsx | 2 +- apps/timo-web/components/timer/TimerStopModalPanel.tsx | 2 +- .../components/todo-modal/CreateTodoModalContent.tsx | 2 +- apps/timo-web/types/.gitkeep | 0 apps/timo-web/utils/.gitkeep | 0 apps/timo-web/utils/{ => date}/date.ts | 0 apps/timo-web/utils/{ => date}/get-day-of-week-key.ts | 0 apps/timo-web/utils/{ => date}/get-hour-label.ts | 0 .../utils/{ => duration}/convert-duration-to-minutes.ts | 0 .../utils/{ => duration}/convert-duration-to-time-text.ts | 0 .../timo-web/utils/{ => duration}/format-duration-label.ts | 0 25 files changed, 26 insertions(+), 21 deletions(-) delete mode 100644 apps/timo-web/types/.gitkeep delete mode 100644 apps/timo-web/utils/.gitkeep rename apps/timo-web/utils/{ => date}/date.ts (100%) rename apps/timo-web/utils/{ => date}/get-day-of-week-key.ts (100%) rename apps/timo-web/utils/{ => date}/get-hour-label.ts (100%) rename apps/timo-web/utils/{ => duration}/convert-duration-to-minutes.ts (100%) rename apps/timo-web/utils/{ => duration}/convert-duration-to-time-text.ts (100%) rename apps/timo-web/utils/{ => duration}/format-duration-label.ts (100%) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index 6b05153e..07602202 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -16,7 +16,7 @@ import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/ho import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode"; import { useHomeView } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; -import { formatDateKey } from "@/utils/date"; +import { formatDateKey } from "@/utils/date/date"; const TAG_LABEL_KEYS = [ "dailyLife", diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx index 53371868..25e791d6 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-card/HomeDayHeaderContainer.tsx @@ -7,7 +7,7 @@ import type { ApiDayOfWeek } from "@/app/[locale]/(main)/(with-time-sidebar)/hom import { DateInformation } from "@/app/[locale]/(main)/(with-time-sidebar)/_components/DateInformation"; import { CreateTodoModalContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/todo-modal/CreateTodoModalContainer"; import { convertDateToDateText } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; -import { getToday, parseDateKey } from "@/utils/date"; +import { getToday, parseDateKey } from "@/utils/date/date"; export interface HomeDayHeaderContainerProps { dateKey: string; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode.ts index 09a4dcd0..6ae7973d 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode.ts @@ -4,7 +4,12 @@ import { useSearchParams } from "next/navigation"; import { useCallback, useMemo } from "react"; import { usePathname, useRouter } from "@/i18n/navigation"; -import { addDays, formatDateKey, getToday, parseDateKey } from "@/utils/date"; +import { + addDays, + formatDateKey, + getToday, + parseDateKey, +} from "@/utils/date/date"; const VIEW_PARAM = "view"; const DATE_PARAM = "date"; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts index d68b8193..88c6a601 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/home-view-mock.ts @@ -13,7 +13,7 @@ import { getToday, isSameDate, parseDateKey, -} from "@/utils/date"; +} from "@/utils/date/date"; const BASIC_RANGE_DAYS_BEFORE = 7; const BASIC_RANGE_LENGTH = 15; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts index 0aa348ad..9e3abf30 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts @@ -1,6 +1,6 @@ import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import { formatDateKey, getToday, isSameDate } from "@/utils/date"; +import { formatDateKey, getToday, isSameDate } from "@/utils/date/date"; type TodoTemplate = Omit; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts index 8b89f877..ee69095d 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date.ts @@ -1,4 +1,4 @@ -import { formatDateKey } from "@/utils/date"; +import { formatDateKey } from "@/utils/date/date"; /** * 날짜를 화면 표시용 텍스트로 변환한다. 매월 1일에는 "M.D" 형식을, diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts index e1fb2e44..b07fa43f 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_utils/day-of-week.ts @@ -1,6 +1,6 @@ import type { ApiDayOfWeek } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; -import { getDayOfWeekKey } from "@/utils/get-day-of-week-key"; +import { getDayOfWeekKey } from "@/utils/date/get-day-of-week-key"; /** * 날짜를 홈 화면 API/i18n 키 형식의 요일 값으로 변환한다. diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx index 26b09927..7ed94e39 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer.tsx @@ -7,8 +7,8 @@ import type { TodoMock } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_ import { DateInformation } from "@/app/[locale]/(main)/(with-time-sidebar)/_components/DateInformation"; import { convertDateToDateText } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_utils/date"; import { CreateTodoModalContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/todo-modal/CreateTodoModalContainer"; -import { getToday } from "@/utils/date"; -import { getDayOfWeekKey } from "@/utils/get-day-of-week-key"; +import { getToday } from "@/utils/date/date"; +import { getDayOfWeekKey } from "@/utils/date/get-day-of-week-key"; interface TodayDateHeaderContainerProps { completedCount: number; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx index 81e4668e..43f6cd5c 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx @@ -3,8 +3,8 @@ import { TodayDateHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayDateHeaderContainer"; import { TodayTodoCardContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer"; import { useTodayTodoList } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList"; -import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; -import { formatDate } from "@/utils/date"; +import { formatDate } from "@/utils/date/date"; +import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; const PRIORITY_MAP = { URGENT: "VERY_HIGH", diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index 960bc9b6..c95e9254 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -33,9 +33,9 @@ import { type TimerSessionControlsHandle, } from "@/components/timer/TimerSessionControls"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; -import { convertDurationToMinutes } from "@/utils/convert-duration-to-minutes"; -import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; -import { formatDurationLabel } from "@/utils/format-duration-label"; +import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; +import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; +import { formatDurationLabel } from "@/utils/duration/format-duration-label"; export const FocusSessionContainer = () => { const queryClient = useQueryClient(); diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx index 8b4bca57..7f5151b3 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx @@ -17,7 +17,7 @@ import { getCalendarDates, getFirstDayOffset, } from "@/app/[locale]/(main)/statistics/_utils/statistics-calendar"; -import { formatDateKey } from "@/utils/date"; +import { formatDateKey } from "@/utils/date/date"; type CalendarIconStatus = "disabled" | "empty" | "outline" | "light" | "filled"; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx index 1923fa16..59a37332 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx @@ -14,7 +14,7 @@ import { MOCK_STATISTICS_MONTH_SUMMARY, } from "@/app/[locale]/(main)/statistics/_mocks/statistics-calendar"; import { formatStatisticsSidePanelDate } from "@/app/[locale]/(main)/statistics/_utils/format-statistics-date"; -import { formatDateKey } from "@/utils/date"; +import { formatDateKey } from "@/utils/date/date"; export const StatisticsContainer = () => { const locale = useLocale(); diff --git a/apps/timo-web/components/layout/sidebar/time/TimeSidebarHeader.tsx b/apps/timo-web/components/layout/sidebar/time/TimeSidebarHeader.tsx index 93b072a8..1ebfa372 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimeSidebarHeader.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimeSidebarHeader.tsx @@ -4,7 +4,7 @@ import { SidebarButton } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; import { useTranslations } from "next-intl"; -import { getDayOfWeekKey } from "@/utils/get-day-of-week-key"; +import { getDayOfWeekKey } from "@/utils/date/get-day-of-week-key"; export interface TimeSidebarHeaderProps { date: Date; diff --git a/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx index 5183f9db..605b8615 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimeboxPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -import { getHourLabel } from "@/utils/get-hour-label"; +import { getHourLabel } from "@/utils/date/get-hour-label"; const HOURS_IN_DAY = 24; const HOUR_LINES = HOURS_IN_DAY + 1; diff --git a/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx b/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx index 9e319855..babed0f6 100644 --- a/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx +++ b/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx @@ -1,7 +1,7 @@ import { Modal } from "@repo/timo-design-system/ui"; import { useTranslations } from "next-intl"; -import { formatDurationLabel } from "@/utils/format-duration-label"; +import { formatDurationLabel } from "@/utils/duration/format-duration-label"; export interface TimerCompleteModalPanelProps { plannedMinutes: number; diff --git a/apps/timo-web/components/timer/TimerStopModalPanel.tsx b/apps/timo-web/components/timer/TimerStopModalPanel.tsx index 97741d73..25f7c584 100644 --- a/apps/timo-web/components/timer/TimerStopModalPanel.tsx +++ b/apps/timo-web/components/timer/TimerStopModalPanel.tsx @@ -3,7 +3,7 @@ import { Modal, ModalButton } from "@repo/timo-design-system/ui"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { formatDurationLabel } from "@/utils/format-duration-label"; +import { formatDurationLabel } from "@/utils/duration/format-duration-label"; export interface TimerStopModalPanelProps { minutes: number; diff --git a/apps/timo-web/components/todo-modal/CreateTodoModalContent.tsx b/apps/timo-web/components/todo-modal/CreateTodoModalContent.tsx index c6493f29..fa1e9dad 100644 --- a/apps/timo-web/components/todo-modal/CreateTodoModalContent.tsx +++ b/apps/timo-web/components/todo-modal/CreateTodoModalContent.tsx @@ -21,7 +21,7 @@ import { useIconField } from "@/hooks/todo-modal/use-icon-field"; import { useRepeatField } from "@/hooks/todo-modal/use-repeat-field"; import { useTagField } from "@/hooks/todo-modal/use-tag-field"; import { useTimeField } from "@/hooks/todo-modal/use-time-field"; -import { formatDateKey } from "@/utils/date"; +import { formatDateKey } from "@/utils/date/date"; const createDefaultValues = (defaultDate?: Date): CreateTodoRequest => ({ icon: null, diff --git a/apps/timo-web/types/.gitkeep b/apps/timo-web/types/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/timo-web/utils/.gitkeep b/apps/timo-web/utils/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/timo-web/utils/date.ts b/apps/timo-web/utils/date/date.ts similarity index 100% rename from apps/timo-web/utils/date.ts rename to apps/timo-web/utils/date/date.ts diff --git a/apps/timo-web/utils/get-day-of-week-key.ts b/apps/timo-web/utils/date/get-day-of-week-key.ts similarity index 100% rename from apps/timo-web/utils/get-day-of-week-key.ts rename to apps/timo-web/utils/date/get-day-of-week-key.ts diff --git a/apps/timo-web/utils/get-hour-label.ts b/apps/timo-web/utils/date/get-hour-label.ts similarity index 100% rename from apps/timo-web/utils/get-hour-label.ts rename to apps/timo-web/utils/date/get-hour-label.ts diff --git a/apps/timo-web/utils/convert-duration-to-minutes.ts b/apps/timo-web/utils/duration/convert-duration-to-minutes.ts similarity index 100% rename from apps/timo-web/utils/convert-duration-to-minutes.ts rename to apps/timo-web/utils/duration/convert-duration-to-minutes.ts diff --git a/apps/timo-web/utils/convert-duration-to-time-text.ts b/apps/timo-web/utils/duration/convert-duration-to-time-text.ts similarity index 100% rename from apps/timo-web/utils/convert-duration-to-time-text.ts rename to apps/timo-web/utils/duration/convert-duration-to-time-text.ts diff --git a/apps/timo-web/utils/format-duration-label.ts b/apps/timo-web/utils/duration/format-duration-label.ts similarity index 100% rename from apps/timo-web/utils/format-duration-label.ts rename to apps/timo-web/utils/duration/format-duration-label.ts From b2ef26805a1f10361367534c413e0966d0996cb6 Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 01:09:40 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(web):=20=EC=96=B8=EC=96=B4=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EB=A5=BC=20=EC=95=B1=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=99=95=EC=9E=A5=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=B9=84=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=97=90=EA=B2=8C=20=EC=A0=91=EC=86=8D=20=EA=B5=AD?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EB=B0=98=20=EA=B8=B0=EB=B3=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 페이지 전용이던 언어 동기화를 루트 LanguageSyncProvider로 옮겨 로그인 상태면 어느 페이지에서든 프로필 언어를 따르도록 했습니다 - useMyProfile을 공용 쿼리로 승격하고 인증 초기화 전/비로그인 상태에서는 요청하지 않도록 했습니다 - 로케일 쿠키가 없는 첫 방문에 한해 Vercel 지오 헤더로 접속 국가가 한국이면 ko, 아니면 en을 기본 로케일로 적용하도록 proxy.ts를 수정했습니다 --- .../home/_queries/use-my-profile.ts | 15 -------- .../(main)/(with-time-sidebar)/home/page.tsx | 4 -- apps/timo-web/app/[locale]/layout.tsx | 5 ++- .../locale/LanguageSyncProvider.tsx} | 16 ++++++-- apps/timo-web/proxy.ts | 38 ++++++++++++++++++- apps/timo-web/queries/use-my-profile.ts | 25 ++++++++++++ 6 files changed, 79 insertions(+), 24 deletions(-) delete mode 100644 apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts rename apps/timo-web/{app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx => providers/locale/LanguageSyncProvider.tsx} (58%) create mode 100644 apps/timo-web/queries/use-my-profile.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts deleted file mode 100644 index 22f02d6f..00000000 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { useSuspenseQuery } from "@tanstack/react-query"; - -import { - getGetMyProfileQueryKey, - getMyProfile, -} from "@/api/generated/endpoints/user/user"; - -export const useMyProfile = () => - useSuspenseQuery({ - queryKey: getGetMyProfileQueryKey(), - queryFn: ({ signal }) => getMyProfile(undefined, signal), - select: ({ data }) => data, - }); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx index 612c3c6f..7801a1d7 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx @@ -1,14 +1,10 @@ import { HomeHeaderContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeHeaderContainer"; -import { HomeLanguageSyncContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer"; import { HomeTodoContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer"; import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; export default function HomePage() { return (
- - - diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index 64306b63..dfd9f6da 100644 --- a/apps/timo-web/app/[locale]/layout.tsx +++ b/apps/timo-web/app/[locale]/layout.tsx @@ -8,6 +8,7 @@ import type { Metadata } from "next"; import { routing } from "@/i18n/routing"; import { AuthProvider } from "@/providers/auth/AuthProvider"; +import { LanguageSyncProvider } from "@/providers/locale/LanguageSyncProvider"; import { OverlayProvider } from "@/providers/OverlayProvider"; import { QueryProvider } from "@/providers/query/QueryProvider"; @@ -50,7 +51,9 @@ export default async function RootLayout({ - {children} + + {children} + diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx b/apps/timo-web/providers/locale/LanguageSyncProvider.tsx similarity index 58% rename from apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx rename to apps/timo-web/providers/locale/LanguageSyncProvider.tsx index abca6f5d..69a1ae6b 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeLanguageSyncContainer.tsx +++ b/apps/timo-web/providers/locale/LanguageSyncProvider.tsx @@ -3,13 +3,23 @@ import { useLocale } from "next-intl"; import { useEffect } from "react"; -import { useMyProfile } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-my-profile"; import { usePathname, useRouter } from "@/i18n/navigation"; +import { useMyProfile } from "@/queries/use-my-profile"; const isSupportedLocale = (value: string): value is "ko" | "en" => value === "ko" || value === "en"; -export const HomeLanguageSyncContainer = () => { +interface LanguageSyncProviderProps { + children: React.ReactNode; +} + +/** + * 로그인한 유저의 프로필 언어와 현재 로케일이 다르면 앱 전역에서 동기화한다. + * 비로그인 상태에서는 useMyProfile이 비활성화되어 아무 동작도 하지 않는다. + */ +export const LanguageSyncProvider = ({ + children, +}: LanguageSyncProviderProps) => { const { data: profile } = useMyProfile(); const locale = useLocale(); const router = useRouter(); @@ -23,5 +33,5 @@ export const HomeLanguageSyncContainer = () => { router.replace(pathname, { locale: language }); }, [locale, pathname, profile, router]); - return null; + return <>{children}; }; diff --git a/apps/timo-web/proxy.ts b/apps/timo-web/proxy.ts index 5535ae2f..0388caee 100644 --- a/apps/timo-web/proxy.ts +++ b/apps/timo-web/proxy.ts @@ -1,8 +1,44 @@ +import { NextResponse, type NextRequest } from "next/server"; import createMiddleware from "next-intl/middleware"; import { routing } from "@/i18n/routing"; -export default createMiddleware(routing); +const LOCALE_COOKIE_NAME = "NEXT_LOCALE"; +const KOREA_COUNTRY_CODE = "KR"; +const KOREAN_LOCALE = "ko"; +const FALLBACK_LOCALE = "en"; + +const intlMiddleware = createMiddleware(routing); + +const hasLocalePrefix = (pathname: string): boolean => + routing.locales.some( + (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`), + ); + +/** + * 로케일 쿠키가 없는 첫 방문에 한해 접속 국가(Vercel 지오 헤더)로 기본 로케일을 정한다. + * 이미 쿠키가 있거나 URL에 로케일이 명시된 경우는 next-intl의 기본 동작을 따른다. + */ +export default function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (!request.cookies.has(LOCALE_COOKIE_NAME) && !hasLocalePrefix(pathname)) { + const country = request.headers.get("x-vercel-ip-country"); + const geoLocale = + country === KOREA_COUNTRY_CODE ? KOREAN_LOCALE : FALLBACK_LOCALE; + const targetPath = pathname === "/" ? "" : pathname; + + const redirectUrl = new URL( + `/${geoLocale}${targetPath}${request.nextUrl.search}`, + request.url, + ); + const response = NextResponse.redirect(redirectUrl); + response.cookies.set(LOCALE_COOKIE_NAME, geoLocale, { path: "/" }); + return response; + } + + return intlMiddleware(request); +} export const config = { matcher: ["/((?!api|_next|.*\\..*).*)"], diff --git a/apps/timo-web/queries/use-my-profile.ts b/apps/timo-web/queries/use-my-profile.ts new file mode 100644 index 00000000..0073c70a --- /dev/null +++ b/apps/timo-web/queries/use-my-profile.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { + getGetMyProfileQueryKey, + getMyProfile, +} from "@/api/generated/endpoints/user/user"; +import { useAuthStore } from "@/stores/auth/useAuthStore"; + +/** + * 로그인한 유저의 프로필을 조회한다. + * 인증 초기화가 끝나고 accessToken이 있을 때만 요청한다. + */ +export const useMyProfile = () => { + const isInitialized = useAuthStore((state) => state.isInitialized); + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: getGetMyProfileQueryKey(), + queryFn: ({ signal }) => getMyProfile(undefined, signal), + select: ({ data }) => data, + enabled: isInitialized && !!accessToken, + }); +}; From 83e6d99e7ba38dc524511ad6c3cd98788c572d1b Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 01:30:53 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor(web):=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EA=B3=B5=EC=9A=A9=20useMyProfile=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LanguageSyncProvider용 useMyProfile과 settings 전용 useSettingsProfileQuery가 같은 GET /api/v1/users를 각자 다른 방식으로 중복 조회하고 있어 하나로 합쳤습니다 - 응답 스키마를 types/user-profile-type.ts로 공용화하고, useMyProfile을 suspense 쿼리로 통일했습니다 - LanguageSyncProvider는 isInitialized && accessToken이 확정된 뒤에만 Suspense 서브트리를 마운트해 비로그인 상태에서 suspend되지 않도록 했습니다 --- .../settings/_hooks/useSettingsProfile.ts | 4 +- .../settings/_queries/use-settings-profile.ts | 16 ------ .../(main)/settings/_types/profile-type.ts | 12 ----- .../providers/locale/LanguageSyncProvider.tsx | 50 +++++++++++++------ apps/timo-web/queries/use-my-profile.ts | 17 +++---- apps/timo-web/types/user-profile-type.ts | 14 ++++++ 6 files changed, 56 insertions(+), 57 deletions(-) delete mode 100644 apps/timo-web/app/[locale]/(main)/settings/_queries/use-settings-profile.ts create mode 100644 apps/timo-web/types/user-profile-type.ts diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts index 6fdcd48b..29d589b9 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts @@ -13,7 +13,7 @@ import type { import { useUpdateLanguage } from "@/api/generated/endpoints/user/user"; import { UpdateLanguageRequestLanguage } from "@/api/generated/models"; import { useSettingsLanguageParam } from "@/app/[locale]/(main)/settings/_hooks/useSettingsLanguageParam"; -import { useSettingsProfileQuery } from "@/app/[locale]/(main)/settings/_queries/use-settings-profile"; +import { useMyProfile } from "@/queries/use-my-profile"; const LANGUAGE_REQUEST_MAP: Record< SettingsLanguage, @@ -38,7 +38,7 @@ export const useSettingsProfile = () => { const { language, locale, setLanguage, commitLanguage } = useSettingsLanguageParam(); - const { data: profile } = useSettingsProfileQuery(); + const { data: profile } = useMyProfile(); const [isCalendarConnected, setIsCalendarConnected] = useState( profile.calendarConnected, ); diff --git a/apps/timo-web/app/[locale]/(main)/settings/_queries/use-settings-profile.ts b/apps/timo-web/app/[locale]/(main)/settings/_queries/use-settings-profile.ts deleted file mode 100644 index a556f1d8..00000000 --- a/apps/timo-web/app/[locale]/(main)/settings/_queries/use-settings-profile.ts +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import { useSuspenseQuery } from "@tanstack/react-query"; - -import { - getGetMyProfileQueryKey, - getMyProfile, -} from "@/api/generated/endpoints/user/user"; -import { settingsProfileResponseSchema } from "@/app/[locale]/(main)/settings/_types/profile-type"; - -export const useSettingsProfileQuery = () => - useSuspenseQuery({ - queryKey: getGetMyProfileQueryKey(), - queryFn: ({ signal }) => getMyProfile(undefined, signal), - select: ({ data }) => settingsProfileResponseSchema.parse(data), - }); diff --git a/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts b/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts index 2279fcf9..ee2b62eb 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts @@ -1,5 +1,3 @@ -import { z } from "zod"; - export type SettingsLanguage = "ko" | "en"; export type SettingsDefaultTagKey = @@ -8,16 +6,6 @@ export type SettingsDefaultTagKey = | "exercise" | "dailyLife"; -export const settingsProfileResponseSchema = z.object({ - name: z.string(), - email: z.string(), - calendarConnected: z.boolean(), -}); - -export type SettingsProfileResponse = z.infer< - typeof settingsProfileResponseSchema ->; - export interface SettingsProfileFormValues { tags: string[]; } diff --git a/apps/timo-web/providers/locale/LanguageSyncProvider.tsx b/apps/timo-web/providers/locale/LanguageSyncProvider.tsx index 69a1ae6b..e951b41e 100644 --- a/apps/timo-web/providers/locale/LanguageSyncProvider.tsx +++ b/apps/timo-web/providers/locale/LanguageSyncProvider.tsx @@ -1,37 +1,55 @@ "use client"; import { useLocale } from "next-intl"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { usePathname, useRouter } from "@/i18n/navigation"; import { useMyProfile } from "@/queries/use-my-profile"; +import { useAuthStore } from "@/stores/auth/useAuthStore"; const isSupportedLocale = (value: string): value is "ko" | "en" => value === "ko" || value === "en"; -interface LanguageSyncProviderProps { - children: React.ReactNode; -} - -/** - * 로그인한 유저의 프로필 언어와 현재 로케일이 다르면 앱 전역에서 동기화한다. - * 비로그인 상태에서는 useMyProfile이 비활성화되어 아무 동작도 하지 않는다. - */ -export const LanguageSyncProvider = ({ - children, -}: LanguageSyncProviderProps) => { +const LanguageSync = () => { const { data: profile } = useMyProfile(); const locale = useLocale(); const router = useRouter(); const pathname = usePathname(); useEffect(() => { - const language = profile?.language.toLowerCase(); - if (!language || !isSupportedLocale(language) || language === locale) - return; + const language = profile.language.toLowerCase(); + if (!isSupportedLocale(language) || language === locale) return; router.replace(pathname, { locale: language }); }, [locale, pathname, profile, router]); - return <>{children}; + return null; +}; + +interface LanguageSyncProviderProps { + children: React.ReactNode; +} + +/** + * 로그인한 유저의 프로필 언어와 현재 로케일이 다르면 앱 전역에서 동기화한다. + * 인증 초기화가 끝나고 accessToken이 있을 때만 프로필을 조회하며, + * 비로그인 상태에서는 조회 자체를 하지 않는다. + */ +export const LanguageSyncProvider = ({ + children, +}: LanguageSyncProviderProps) => { + const isInitialized = useAuthStore((state) => state.isInitialized); + const accessToken = useAuthStore((state) => state.accessToken); + const shouldSync = isInitialized && !!accessToken; + + return ( + <> + {shouldSync && ( + + + + )} + {children} + + ); }; diff --git a/apps/timo-web/queries/use-my-profile.ts b/apps/timo-web/queries/use-my-profile.ts index 0073c70a..474727dd 100644 --- a/apps/timo-web/queries/use-my-profile.ts +++ b/apps/timo-web/queries/use-my-profile.ts @@ -1,25 +1,20 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { getGetMyProfileQueryKey, getMyProfile, } from "@/api/generated/endpoints/user/user"; -import { useAuthStore } from "@/stores/auth/useAuthStore"; +import { userProfileSchema } from "@/types/user-profile-type"; /** * 로그인한 유저의 프로필을 조회한다. - * 인증 초기화가 끝나고 accessToken이 있을 때만 요청한다. + * accessToken이 보장되는 화면(AuthGuardProvider 하위 등)에서만 사용해야 한다. */ -export const useMyProfile = () => { - const isInitialized = useAuthStore((state) => state.isInitialized); - const accessToken = useAuthStore((state) => state.accessToken); - - return useQuery({ +export const useMyProfile = () => + useSuspenseQuery({ queryKey: getGetMyProfileQueryKey(), queryFn: ({ signal }) => getMyProfile(undefined, signal), - select: ({ data }) => data, - enabled: isInitialized && !!accessToken, + select: ({ data }) => userProfileSchema.parse(data), }); -}; diff --git a/apps/timo-web/types/user-profile-type.ts b/apps/timo-web/types/user-profile-type.ts new file mode 100644 index 00000000..9821067f --- /dev/null +++ b/apps/timo-web/types/user-profile-type.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const userProfileSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + profileImageUrl: z.string().optional(), + language: z.string(), + zoneId: z.string(), + calendarConnected: z.boolean(), + calendarEmail: z.string().optional(), +}); + +export type UserProfile = z.infer; From 98827f7dc8532eabb72475ca5870b3dbb58c2adf Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 01:34:24 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(web):=20=EC=9C=A0=EC=A0=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=91=EB=8B=B5=EC=9D=98=20null=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=ED=95=84=EB=93=9C=20zod=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=98=A4=EB=A5=98=EB=A5=BC=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profileImageUrl/calendarEmail이 실제로는 null로 내려오는데 optional()만 허용해 런타임에서 ZodError가 발생했습니다 - nullish()로 바꿔 string | null | undefined를 모두 허용하도록 했습니다 --- apps/timo-web/types/user-profile-type.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/types/user-profile-type.ts b/apps/timo-web/types/user-profile-type.ts index 9821067f..c21934b4 100644 --- a/apps/timo-web/types/user-profile-type.ts +++ b/apps/timo-web/types/user-profile-type.ts @@ -4,11 +4,11 @@ export const userProfileSchema = z.object({ id: z.number(), name: z.string(), email: z.string(), - profileImageUrl: z.string().optional(), + profileImageUrl: z.string().nullish(), language: z.string(), zoneId: z.string(), calendarConnected: z.boolean(), - calendarEmail: z.string().optional(), + calendarEmail: z.string().nullish(), }); export type UserProfile = z.infer; From 63616c6eb682cc65eef8521f564440c091c6459f Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 01:42:47 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(web):=20=EB=A3=A8=ED=8A=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=91=EA=B7=BC=20=EC=8B=9C=20/home=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - '/' 진입 시 빈 화면 대신 서버에서 /home으로 리다이렉트하도록 했습니다 - (main)/home은 이미 AuthGuardProvider로 보호되고 있어 비로그인 사용자는 자동으로 /login으로 재리다이렉트됩니다 --- apps/timo-web/app/[locale]/page.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/app/[locale]/page.tsx b/apps/timo-web/app/[locale]/page.tsx index 6ff53738..1cec8e9e 100644 --- a/apps/timo-web/app/[locale]/page.tsx +++ b/apps/timo-web/app/[locale]/page.tsx @@ -1,3 +1,20 @@ -export default function Home() { - return <>; +import { notFound } from "next/navigation"; +import { hasLocale } from "next-intl"; + +import { ROUTES } from "@/constants/routes"; +import { redirect } from "@/i18n/navigation"; +import { routing } from "@/i18n/routing"; + +interface HomeProps { + params: Promise<{ locale: string }>; +} + +export default async function Home({ params }: HomeProps) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + redirect({ href: ROUTES.HOME, locale }); } From c45c17ded3edbe4ba2d95a965ca249b7c72bdec9 Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 01:57:27 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(web):=20=EC=84=A4=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=96=B8=EC=96=B4=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=BA=90=EC=8B=9C=EA=B0=80=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EB=90=98=EC=A7=80=20=EC=95=8A=EC=95=84=20?= =?UTF-8?q?=EB=A1=9C=EC=BC=80=EC=9D=BC=EC=9D=B4=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EC=95=84=EA=B0=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateLanguage 성공 후 useMyProfile 캐시를 갱신하지 않아, commitLanguage로 로케일을 바꿔도 LanguageSyncProvider가 예전 캐시값을 보고 다시 되돌리고 있었습니다 - updateLanguage 응답의 language 값으로 useMyProfile 캐시를 즉시 patch해 commitLanguage 이전에 최신 상태가 되도록 했습니다 --- .../settings/_hooks/useSettingsProfile.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts index 29d589b9..db1e587a 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts @@ -1,16 +1,21 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import type { BaseResponseUserProfileResponse } from "@/api/generated/models"; import type { SettingsDefaultTagKey, SettingsLanguage, SettingsProfileFormValues, } from "@/app/[locale]/(main)/settings/_types/profile-type"; -import { useUpdateLanguage } from "@/api/generated/endpoints/user/user"; +import { + getGetMyProfileQueryKey, + useUpdateLanguage, +} from "@/api/generated/endpoints/user/user"; import { UpdateLanguageRequestLanguage } from "@/api/generated/models"; import { useSettingsLanguageParam } from "@/app/[locale]/(main)/settings/_hooks/useSettingsLanguageParam"; import { useMyProfile } from "@/queries/use-my-profile"; @@ -43,6 +48,7 @@ export const useSettingsProfile = () => { profile.calendarConnected, ); const { mutateAsync: updateLanguage } = useUpdateLanguage(); + const queryClient = useQueryClient(); // TODO: 태그 목록 조회 API 연동 후 기본 태그 대신 실제 응답으로 교체 const { watch, setValue, handleSubmit, reset, formState } = @@ -106,9 +112,22 @@ export const useSettingsProfile = () => { const handleSave = handleSubmit(async (values) => { try { if (isLanguageDirty) { - await updateLanguage({ + const { data } = await updateLanguage({ data: { language: LANGUAGE_REQUEST_MAP[language] }, }); + + if (data) { + queryClient.setQueryData( + getGetMyProfileQueryKey(), + (cached?: BaseResponseUserProfileResponse) => + cached?.data + ? { + ...cached, + data: { ...cached.data, language: data.language }, + } + : cached, + ); + } } reset(values); commitLanguage(); From fd70993da2342e0a7f5e5993b5e29c7615498e6a Mon Sep 17 00:00:00 2001 From: kimminna Date: Tue, 14 Jul 2026 02:06:18 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor(web):=20LanguageSyncProvider?= =?UTF-8?q?=EA=B0=80=20Suspense=20=EB=8C=80=EC=8B=A0=20AsyncBoundary?= =?UTF-8?q?=EB=A5=BC=20=EC=93=B0=EB=8F=84=EB=A1=9D=20=EB=B0=94=EA=BE=BC?= =?UTF-8?q?=EB=8B=A4=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존엔 useMyProfile이 실패하면 근처에 ErrorBoundary가 없어 전역 error.tsx까지 전파되어 앱 전체가 깨질 수 있었습니다 - AsyncBoundary(+errorFallback=null)로 감싸 이 기능만 조용히 무시되도록 하고, 다른 suspense 쿼리들과 동일한 패턴으로 맞췄습니다 --- apps/timo-web/providers/locale/LanguageSyncProvider.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/timo-web/providers/locale/LanguageSyncProvider.tsx b/apps/timo-web/providers/locale/LanguageSyncProvider.tsx index e951b41e..b7377bb5 100644 --- a/apps/timo-web/providers/locale/LanguageSyncProvider.tsx +++ b/apps/timo-web/providers/locale/LanguageSyncProvider.tsx @@ -1,8 +1,9 @@ "use client"; import { useLocale } from "next-intl"; -import { Suspense, useEffect } from "react"; +import { useEffect } from "react"; +import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; import { usePathname, useRouter } from "@/i18n/navigation"; import { useMyProfile } from "@/queries/use-my-profile"; import { useAuthStore } from "@/stores/auth/useAuthStore"; @@ -34,6 +35,7 @@ interface LanguageSyncProviderProps { * 로그인한 유저의 프로필 언어와 현재 로케일이 다르면 앱 전역에서 동기화한다. * 인증 초기화가 끝나고 accessToken이 있을 때만 프로필을 조회하며, * 비로그인 상태에서는 조회 자체를 하지 않는다. + * 프로필 조회가 실패해도 앱 전체 에러 화면으로 전파되지 않고 이 기능만 조용히 무시한다. */ export const LanguageSyncProvider = ({ children, @@ -45,9 +47,9 @@ export const LanguageSyncProvider = ({ return ( <> {shouldSync && ( - + - + )} {children}