diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendar.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendar.tsx new file mode 100644 index 00000000..72549302 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendar.tsx @@ -0,0 +1,38 @@ +import { PillButton } from "@repo/timo-design-system/ui"; +import Image from "next/image"; + +interface SettingsCalendarProps { + iconSrc: string; + name: string; + isConnected: boolean; + connectLabel: string; + disconnectLabel: string; + onClick: () => void; +} + +export const SettingsCalendar = ({ + iconSrc, + name, + isConnected, + connectLabel, + disconnectLabel, + onClick, +}: SettingsCalendarProps) => { + return ( +
+
+ + + {name} + +
+ + + {isConnected ? disconnectLabel : connectLabel} + +
+ ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx index a5e8f4a4..5f26ab3d 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx @@ -1,4 +1,3 @@ -import { PillButton } from "@repo/timo-design-system/ui"; import Image from "next/image"; import type { @@ -7,6 +6,7 @@ import type { SettingsTagItem, } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; +import { SettingsCalendar } from "@/app/[locale]/(main)/settings/_components/account/SettingsCalendar"; import { SettingsLanguageSectionContainer } from "@/app/[locale]/(main)/settings/_containers/account/SettingsLanguageSectionContainer"; import { SettingsLogoutModalContainer } from "@/app/[locale]/(main)/settings/_containers/account/SettingsLogoutModalContainer"; import { SettingsTagsSectionContainer } from "@/app/[locale]/(main)/settings/_containers/account/SettingsTagsSectionContainer"; @@ -73,6 +73,7 @@ export const SettingsProfileView = ({ alt="Google" width={20} height={20} + unoptimized /> {googleEmail} @@ -88,26 +89,23 @@ export const SettingsProfileView = ({

{labels.calendarSection}

-
-
- - - Google Calendar - -
- - - {isCalendarConnected ? labels.disconnect : labels.connect} - -
+ + {/* TODO: Apple 캘린더 연동 API 연결 후 연결 상태·onClick 연동 */} + {}} + />
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx index d05bfab9..d469cff7 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; import { overlay } from "overlay-kit"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { TagLimitToastContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer"; import { SettingsCalendarDisconnectModal } from "@/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal"; @@ -12,6 +12,10 @@ import { useSettingsProfileLabels } from "@/app/[locale]/(main)/settings/_hooks/ import { CreateTagModalContainer } from "@/components/tag/CreateTagModalContainer"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { MAX_CUSTOM_TAG_COUNT } from "@/schemas/tag/tag-schema"; +import { + clearCalendarConnectFailed, + hasCalendarConnectFailed, +} from "@/utils/calendar/calendar-connect-failed"; export const SettingsProfileContainer = () => { const tToast = useTranslations("Toast"); @@ -23,7 +27,7 @@ export const SettingsProfileContainer = () => { profileState.calendarConnected, ); const [isCalendarConnectErrorToastOpen, setIsCalendarConnectErrorToastOpen] = - useState(false); + useState(hasCalendarConnectFailed); const [ isCalendarDisconnectErrorToastOpen, setIsCalendarDisconnectErrorToastOpen, @@ -33,6 +37,10 @@ export const SettingsProfileContainer = () => { const [isLanguageErrorToastOpen, setIsLanguageErrorToastOpen] = useState(false); + useEffect(() => { + clearCalendarConnectFailed(); + }, []); + const handleConnectCalendar = () => { if (isCalendarConnected) { overlay.open(({ isOpen, close, unmount }) => ( diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts index 0fede7f1..cc90ef20 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts @@ -7,6 +7,7 @@ import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/acc import { useSettingsLanguageParam } from "@/app/[locale]/(main)/settings/_hooks/account/use-settings-language-param"; import { useLogoutMutation } from "@/app/[locale]/(main)/settings/_queries/account/use-logout-mutation"; +import { CALENDAR_CONNECT_ORIGIN } from "@/constants/calendar"; import { authorize, useDisconnectCalendar, @@ -25,6 +26,7 @@ import { MAX_CUSTOM_TAG_COUNT, tagCreateDataSchema, } from "@/schemas/tag/tag-schema"; +import { setCalendarConnectOrigin } from "@/utils/calendar/calendar-connect-origin"; import { getDefaultTagLabelKey, isDefaultTagId } from "@/utils/todo/tag-label"; const LANGUAGE_REQUEST_MAP: Record< @@ -99,13 +101,15 @@ export const useSettingsProfile = () => { } try { - const response = await authorize(); + const response = await authorize({ + redirectOrigin: window.location.origin, + }); const url = response.data?.authorizationUrl; if (!url) { handlers.onConnectError?.(); return; } - localStorage.setItem("calendarConnectOrigin", "settings"); + setCalendarConnectOrigin(CALENDAR_CONNECT_ORIGIN.SETTINGS); window.location.assign(url); } catch { handlers.onConnectError?.(); diff --git a/apps/timo-web/app/[locale]/login/_containers/LoginContainer.tsx b/apps/timo-web/app/[locale]/login/_containers/LoginContainer.tsx index 8811637b..5d922658 100644 --- a/apps/timo-web/app/[locale]/login/_containers/LoginContainer.tsx +++ b/apps/timo-web/app/[locale]/login/_containers/LoginContainer.tsx @@ -3,9 +3,9 @@ import { LogoTimoIcon } from "@repo/timo-design-system/icons"; import { useTranslations } from "next-intl"; -import { OnboardingGoogleButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer"; import { LottiePlayer } from "@/components/lottie/LottiePlayer"; import { ROUTES } from "@/constants/routes"; +import { AuthButtonContainer } from "@/containers/auth/AuthButtonContainer"; import { Link } from "@/i18n/navigation"; export const LoginContainer = () => { @@ -42,8 +42,8 @@ export const LoginContainer = () => {

{t("connectLabel")}

- { const redirectOrigin = window.location.origin; window.location.href = `${process.env.NEXT_PUBLIC_API_BASE_URL}/oauth2/authorization/google?redirect_origin=${encodeURIComponent(redirectOrigin)}`; diff --git a/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx b/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx index 7df1e0ec..b905564e 100644 --- a/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx +++ b/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx @@ -4,10 +4,24 @@ import { useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; +import type { CalendarConnectOrigin } from "@/constants/calendar"; + +import { CALENDAR_CONNECT_ORIGIN } from "@/constants/calendar"; import { ROUTES } from "@/constants/routes"; import { useConnectCalendar } from "@/generated/endpoints/calendar/calendar"; import { getGetMyProfileQueryKey } from "@/generated/endpoints/user/user"; import { useRouter } from "@/i18n/navigation"; +import { markCalendarConnectFailed } from "@/utils/calendar/calendar-connect-failed"; +import { consumeCalendarConnectOrigin } from "@/utils/calendar/calendar-connect-origin"; + +type Route = (typeof ROUTES)[keyof typeof ROUTES]; + +const ACCESS_DENIED_ERROR = "access_denied"; + +const CALENDAR_CONNECT_REDIRECT: Record = { + [CALENDAR_CONNECT_ORIGIN.ONBOARDING]: ROUTES.ONBOARDING, + [CALENDAR_CONNECT_ORIGIN.SETTINGS]: ROUTES.SETTINGS, +}; export const CalendarCallbackContainer = () => { const searchParams = useSearchParams(); @@ -21,16 +35,25 @@ export const CalendarCallbackContainer = () => { const hasRequested = useRef(false); useEffect(() => { - const origin = localStorage.getItem("calendarConnectOrigin"); - const redirectTarget = - origin === "settings" ? ROUTES.SETTINGS : ROUTES.HOME; + if (hasRequested.current) return; + hasRequested.current = true; + + const origin = consumeCalendarConnectOrigin(); + const hasOrigin = origin !== null; + const redirectTarget = hasOrigin + ? CALENDAR_CONNECT_REDIRECT[origin] + : ROUTES.HOME; + + const notifyConnectFailed = () => { + if (hasOrigin) markCalendarConnectFailed(); + }; if (error || !code || !state) { + // 사용자가 권한 동의를 취소한 경우(access_denied)는 실패로 보지 않습니다. + if (error !== ACCESS_DENIED_ERROR) notifyConnectFailed(); router.replace(redirectTarget); return; } - if (hasRequested.current) return; - hasRequested.current = true; connectCalendar( { data: { authorizationCode: code, state } }, @@ -39,10 +62,10 @@ export const CalendarCallbackContainer = () => { await queryClient.invalidateQueries({ queryKey: getGetMyProfileQueryKey(), }); - localStorage.removeItem("calendarConnectOrigin"); router.replace(redirectTarget); }, onError: () => { + notifyConnectFailed(); router.replace(redirectTarget); }, }, diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx index 9c6a6a14..a7409194 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx @@ -34,34 +34,14 @@ export const OnboardingButton = ({ } if (variant === "start") { - // TODO: 원래 start 버튼 (항상 활성 스타일) — CalendarConnect 단계 재활성화 시 아래 주석 해제 후 isActive 분기 제거 - // return ( - // - // ); return ( ); } diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingSelectCard.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingSelectCard.tsx index 3cff299a..0d28e331 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingSelectCard.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingSelectCard.tsx @@ -31,7 +31,7 @@ export const OnboardingSelectCard = ({ type="button" onClick={onClick} className={cn( - "flex rounded-[4px] border px-4 py-2 text-left transition-colors duration-200 ease-in-out", + "flex rounded-[4px] border px-[15px] py-[7px] text-left transition-colors duration-200 ease-in-out", selected ? "border-timo-blue-300 bg-timo-blue-50" : "border-timo-gray-500 bg-white", diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx index ae23dac2..2be07709 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx @@ -4,8 +4,7 @@ interface OnboardingStepButtonProps { step: 1 | 2 | 3 | 4; } -// TODO: 구글 캘린더 연동 단계 재활성화 시 4 추가 -const STEPS = [1, 2, 3] as const; +const STEPS = [1, 2, 3, 4] as const; export const OnboardingStepButton = ({ step }: OnboardingStepButtonProps) => { return ( @@ -20,7 +19,7 @@ export const OnboardingStepButton = ({ step }: OnboardingStepButtonProps) => { > diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx index 55e0026c..5a4b3fc6 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx @@ -2,33 +2,51 @@ import { useTranslations } from "next-intl"; +import type { OnboardingFunnelSteps } from "@/app/[locale]/onboarding/_types/onboarding-funnel"; + import { OnboardingButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingButtonContainer"; -import { OnboardingGoogleButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer"; +import { setOnboardingAnswers } from "@/app/[locale]/onboarding/_utils/onboarding-answers-storage"; +import { CALENDAR_CONNECT_ORIGIN } from "@/constants/calendar"; +import { AuthButtonContainer } from "@/containers/auth/AuthButtonContainer"; import { authorize } from "@/generated/endpoints/calendar/calendar"; +import { useMyProfileQuery } from "@/queries/auth/use-my-profile-query"; +import { setCalendarConnectOrigin } from "@/utils/calendar/calendar-connect-origin"; interface CalendarConnectStepContainerProps { + answers: OnboardingFunnelSteps["CalendarConnect"]; isPending?: boolean; + onConnectError: () => void; onPrev: () => void; onStart: () => void; } export const CalendarConnectStepContainer = ({ + answers, isPending, + onConnectError, onPrev, onStart, }: CalendarConnectStepContainerProps) => { const t = useTranslations("Onboarding"); - const isCalendarConnected = false; + const { data: profile } = useMyProfileQuery(); const handleGoogleConnect = async () => { + if (profile.calendarConnected) return; + try { - const response = await authorize(); + const response = await authorize({ + redirectOrigin: window.location.origin, + }); const url = response.data?.authorizationUrl; - if (!url) return; - localStorage.setItem("calendarConnectOrigin", "onboarding"); + if (!url) { + onConnectError(); + return; + } + setOnboardingAnswers(answers); + setCalendarConnectOrigin(CALENDAR_CONNECT_ORIGIN.ONBOARDING); window.location.assign(url); } catch { - // authorize 실패 시 아무 동작 없음 — 사용자가 재시도 가능 + onConnectError(); } }; @@ -49,11 +67,15 @@ export const CalendarConnectStepContainer = ({

{t("calendarConnect.connectLabel")}

- +
+ + {/* TODO: Apple 캘린더 연동 API 연결 후 onClick·isSelected 추가 */} + +
diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx index 77dd2918..2b8f8394 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx @@ -86,9 +86,8 @@ export const LifePatternStepContainer = ({
- {/* TODO: 구글 캘린더 연동 단계 재활성화 시 variant="next"로 복구 */} = { Language: 1, @@ -37,23 +46,32 @@ export const OnboardingFunnelContainer = () => { const setOnboardingCompleted = useAuthStore( (state) => state.setOnboardingCompleted, ); + const [restoredAnswers] = useState(getOnboardingAnswers); const [answers, setAnswers] = useState< Partial - >({}); - const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); + >(() => restoredAnswers ?? {}); + const [errorToastMessage, setErrorToastMessage] = useState( + () => (hasCalendarConnectFailed() ? t("calendarConnectFailed") : null), + ); + + useEffect(() => { + clearCalendarConnectFailed(); + }, []); const funnel = useFunnel({ id: "onboarding", - initial: { step: "Language", context: {} }, + initial: restoredAnswers + ? { step: "CalendarConnect", context: restoredAnswers } + : { step: "Language", context: {} }, }); const { mutate: completeOnboarding, isPending } = useCompleteOnboarding(); return (
setIsErrorToastOpen(false)} - message={t("onboardingSubmitFailed")} + isOpen={errorToastMessage !== null} + onClose={() => setErrorToastMessage(null)} + message={errorToastMessage ?? ""} /> { ariaLabel="온보딩 애니메이션" />
-
+
- ( - - setAnswers((prev) => ({ ...prev, language })) - } - onNext={() => { - if (!answers.language) return; - history.push("TimePrediction", { - language: answers.language, - }); - }} - /> - )} - TimePrediction={({ history }) => ( - - setAnswers((prev) => ({ ...prev, predictionAccuracy })) - } - onPrev={() => history.back()} - onNext={() => { - if (!answers.language || !answers.predictionAccuracy) return; - history.push("LifePattern", { - language: answers.language, - predictionAccuracy: answers.predictionAccuracy, - }); - }} - /> - )} - LifePattern={({ history }) => ( - - setAnswers((prev) => ({ ...prev, wakeUpTime })) - } - onSelectBedTime={(bedTime) => - setAnswers((prev) => ({ ...prev, bedTime })) - } - onPrev={() => history.back()} - onNext={() => { - if ( - !answers.language || - !answers.predictionAccuracy || - !answers.wakeUpTime || - !answers.bedTime - ) - return; - // TODO: 구글 캘린더 연동 단계 재활성화 시 아래 completeOnboarding 블록을 제거하고 - // history.push("CalendarConnect", { language: answers.language, predictionAccuracy: answers.predictionAccuracy, wakeUpTime: answers.wakeUpTime, bedTime: answers.bedTime }) 로 복구 - completeOnboarding( - { - data: { - language: ONBOARDING_LANGUAGE_MAP[answers.language], - predictionAccuracy: answers.predictionAccuracy, - wakeUpTime: answers.wakeUpTime, - bedTime: answers.bedTime, - }, - }, - { - onSuccess: () => { - setOnboardingCompleted(true); - router.replace(ROUTES.HOME, { - locale: answers.language, - }); - }, - onError: () => { - setIsErrorToastOpen(true); - }, - }, - ); - }} - /> - )} - CalendarConnect={({ history }) => ( - history.back()} - onStart={() => { - if ( - !answers.language || - !answers.predictionAccuracy || - !answers.wakeUpTime || - !answers.bedTime - ) - return; - completeOnboarding( - { - data: { - language: ONBOARDING_LANGUAGE_MAP[answers.language], - predictionAccuracy: answers.predictionAccuracy, - wakeUpTime: answers.wakeUpTime, - bedTime: answers.bedTime, - }, - }, - { - onSuccess: () => { - setOnboardingCompleted(true); - router.replace(ROUTES.HOME, { - locale: answers.language, - }); - }, - onError: () => { - setIsErrorToastOpen(true); - }, - }, - ); - }} - /> - )} - /> +
+ ( + + setAnswers((prev) => ({ ...prev, language })) + } + onNext={() => { + if (!answers.language) return; + history.push("TimePrediction", { + language: answers.language, + }); + }} + /> + )} + TimePrediction={({ history, index, context }) => ( + + setAnswers((prev) => ({ ...prev, predictionAccuracy })) + } + onPrev={() => + index > 0 + ? history.back() + : history.replace("Language", { + language: context.language, + }) + } + onNext={() => { + if (!answers.language || !answers.predictionAccuracy) + return; + history.push("LifePattern", { + language: answers.language, + predictionAccuracy: answers.predictionAccuracy, + }); + }} + /> + )} + LifePattern={({ history, index, context }) => ( + + setAnswers((prev) => ({ ...prev, wakeUpTime })) + } + onSelectBedTime={(bedTime) => + setAnswers((prev) => ({ ...prev, bedTime })) + } + onPrev={() => + index > 0 + ? history.back() + : history.replace("TimePrediction", context) + } + onNext={() => { + if ( + !answers.language || + !answers.predictionAccuracy || + !answers.wakeUpTime || + !answers.bedTime + ) + return; + history.push("CalendarConnect", { + language: answers.language, + predictionAccuracy: answers.predictionAccuracy, + wakeUpTime: answers.wakeUpTime, + bedTime: answers.bedTime, + }); + }} + /> + )} + CalendarConnect={({ history, index, context }) => ( + + + setErrorToastMessage(t("calendarConnectFailed")) + } + onPrev={() => + index > 0 + ? history.back() + : history.replace("LifePattern", context) + } + onStart={() => { + completeOnboarding( + { + data: { + language: ONBOARDING_LANGUAGE_MAP[context.language], + predictionAccuracy: context.predictionAccuracy, + wakeUpTime: context.wakeUpTime, + bedTime: context.bedTime, + }, + }, + { + onSuccess: () => { + removeOnboardingAnswers(); + setOnboardingCompleted(true); + router.replace(ROUTES.HOME, { + locale: context.language, + }); + }, + onError: () => { + setErrorToastMessage(t("onboardingSubmitFailed")); + }, + }, + ); + }} + /> + + )} + /> +
diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer.tsx deleted file mode 100644 index 821d5cb6..00000000 --- a/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; - -import { OnboardingGoogleButton } from "@/app/[locale]/onboarding/_components/OnboardingGoogleButton"; - -interface OnboardingGoogleButtonContainerProps { - variant: "login" | "connectCalendar"; - isSelected?: boolean; - onClick?: () => void; -} - -export const OnboardingGoogleButtonContainer = ({ - variant, - isSelected, - onClick, -}: OnboardingGoogleButtonContainerProps) => { - const t = useTranslations("Onboarding"); - - return ( - - ); -}; diff --git a/apps/timo-web/app/[locale]/onboarding/_utils/onboarding-answers-storage.ts b/apps/timo-web/app/[locale]/onboarding/_utils/onboarding-answers-storage.ts new file mode 100644 index 00000000..1e5a4613 --- /dev/null +++ b/apps/timo-web/app/[locale]/onboarding/_utils/onboarding-answers-storage.ts @@ -0,0 +1,54 @@ +import type { OnboardingFunnelSteps } from "@/app/[locale]/onboarding/_types/onboarding-funnel"; + +const ONBOARDING_ANSWERS_KEY = "timo:onboarding-answers"; + +type OnboardingAnswers = OnboardingFunnelSteps["CalendarConnect"]; + +const isOnboardingAnswers = (value: unknown): value is OnboardingAnswers => { + if (typeof value !== "object" || value === null) return false; + + const { language, predictionAccuracy, wakeUpTime, bedTime } = value as Record< + string, + unknown + >; + return ( + (language === "ko" || language === "en") && + (predictionAccuracy === 1 || + predictionAccuracy === 2 || + predictionAccuracy === 3 || + predictionAccuracy === 4) && + typeof wakeUpTime === "string" && + typeof bedTime === "string" + ); +}; + +export const getOnboardingAnswers = (): OnboardingAnswers | null => { + try { + const raw = window.sessionStorage.getItem(ONBOARDING_ANSWERS_KEY); + if (raw === null) return null; + + const parsed: unknown = JSON.parse(raw); + return isOnboardingAnswers(parsed) ? parsed : null; + } catch { + return null; + } +}; + +export const setOnboardingAnswers = (answers: OnboardingAnswers): void => { + try { + window.sessionStorage.setItem( + ONBOARDING_ANSWERS_KEY, + JSON.stringify(answers), + ); + } catch { + // 저장소 접근 불가 시 무시 + } +}; + +export const removeOnboardingAnswers = (): void => { + try { + window.sessionStorage.removeItem(ONBOARDING_ANSWERS_KEY); + } catch { + // 저장소 접근 불가 시 무시 + } +}; diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingGoogleButton.tsx b/apps/timo-web/components/auth/AuthButton.tsx similarity index 57% rename from apps/timo-web/app/[locale]/onboarding/_components/OnboardingGoogleButton.tsx rename to apps/timo-web/components/auth/AuthButton.tsx index 12322e21..ce282b7b 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingGoogleButton.tsx +++ b/apps/timo-web/components/auth/AuthButton.tsx @@ -1,21 +1,30 @@ -import googleLogo from "@repo/timo-design-system/assets/images/google-logo.svg"; import { cn } from "@repo/timo-design-system/utils"; import Image from "next/image"; -type OnboardingGoogleButtonVariant = "login" | "connectCalendar"; +export type AuthButtonVariant = + | "googleLogin" + | "googleCalendar" + | "appleCalendar"; -interface OnboardingGoogleButtonProps { - variant: OnboardingGoogleButtonVariant; +const AUTH_BUTTON_LOGO_SRC: Record = { + googleLogin: "/images/google-logo.png", + googleCalendar: "/images/google-calendar.png", + appleCalendar: "/images/apple-calendar.png", +}; + +interface AuthButtonProps { + variant: AuthButtonVariant; label: string; isSelected?: boolean; onClick?: () => void; } -export const OnboardingGoogleButton = ({ +export const AuthButton = ({ + variant, label, isSelected = false, onClick, -}: OnboardingGoogleButtonProps) => { +}: AuthButtonProps) => { return (

diff --git a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx index 5a5d1573..80679715 100644 --- a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx +++ b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx @@ -17,6 +17,7 @@ export const TodayCalendarEventCard = ({ alt="Google Calendar" width={14} height={14} + unoptimized />

diff --git a/apps/timo-web/constants/calendar.ts b/apps/timo-web/constants/calendar.ts new file mode 100644 index 00000000..2d61b502 --- /dev/null +++ b/apps/timo-web/constants/calendar.ts @@ -0,0 +1,7 @@ +export const CALENDAR_CONNECT_ORIGIN = { + ONBOARDING: "onboarding", + SETTINGS: "settings", +} as const; + +export type CalendarConnectOrigin = + (typeof CALENDAR_CONNECT_ORIGIN)[keyof typeof CALENDAR_CONNECT_ORIGIN]; diff --git a/apps/timo-web/containers/auth/AuthButtonContainer.tsx b/apps/timo-web/containers/auth/AuthButtonContainer.tsx new file mode 100644 index 00000000..74f10202 --- /dev/null +++ b/apps/timo-web/containers/auth/AuthButtonContainer.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import type { AuthButtonVariant } from "@/components/auth/AuthButton"; + +import { AuthButton } from "@/components/auth/AuthButton"; + +interface AuthButtonContainerProps { + variant: AuthButtonVariant; + isSelected?: boolean; + onClick?: () => void; +} + +export const AuthButtonContainer = ({ + variant, + isSelected, + onClick, +}: AuthButtonContainerProps) => { + const t = useTranslations("Common.authButton"); + + return ( + + ); +}; diff --git a/apps/timo-web/generated/endpoints/calendar/calendar.ts b/apps/timo-web/generated/endpoints/calendar/calendar.ts index adfd5310..52db1c69 100644 --- a/apps/timo-web/generated/endpoints/calendar/calendar.ts +++ b/apps/timo-web/generated/endpoints/calendar/calendar.ts @@ -11,6 +11,7 @@ import { customInstance } from "../../../http/custom-instance"; import type { ErrorType, BodyType } from "../../../http/custom-instance"; import type { + AuthorizeParams, BaseResponseCalendarAuthorizeResponse, BaseResponseCalendarConnectResponse, BaseResponseCalendarDisconnectResponse, @@ -412,38 +413,48 @@ export function useGetCalendarEvents< * 구글 캘린더 연동을 시작하는 구글 인증 URL을 발급합니다. * * 프론트는 이 응답의 authorizationUrl로 window.location.assign 등을 통해 직접 이동해야 합니다. + * + * redirectOrigin을 전달하면 {redirectOrigin}/oauth/calendar/callback을 redirect_uri로 사용합니다. + * 미입력이면 기본 프론트 주소로 redirect되고, 허용되지 않은 origin이면 400(COMMON_400)을 반환합니다. * @summary 구글 캘린더 연동 시작 */ export const authorize = ( + params?: AuthorizeParams, options?: SecondParameter, signal?: AbortSignal, ) => { return customInstance( - { url: `/api/v1/users/calendar/authorize`, method: "GET", signal }, + { url: `/api/v1/users/calendar/authorize`, method: "GET", params, signal }, options, ); }; -export const getAuthorizeQueryKey = () => { - return [`/api/v1/users/calendar/authorize`] as const; +export const getAuthorizeQueryKey = (params?: AuthorizeParams) => { + return [ + `/api/v1/users/calendar/authorize`, + ...(params ? [params] : []), + ] as const; }; export const getAuthorizeQueryOptions = < TData = Awaited>, TError = ErrorType, ->(options?: { - query?: Partial< - UseQueryOptions>, TError, TData> - >; - request?: SecondParameter; -}) => { +>( + params?: AuthorizeParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getAuthorizeQueryKey(); + const queryKey = queryOptions?.queryKey ?? getAuthorizeQueryKey(params); const queryFn: QueryFunction>> = ({ signal, - }) => authorize(requestOptions, signal); + }) => authorize(params, requestOptions, signal); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -461,6 +472,7 @@ export function useAuthorize< TData = Awaited>, TError = ErrorType, >( + params: undefined | AuthorizeParams, options: { query: Partial< UseQueryOptions>, TError, TData> @@ -483,6 +495,7 @@ export function useAuthorize< TData = Awaited>, TError = ErrorType, >( + params?: AuthorizeParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> @@ -505,6 +518,7 @@ export function useAuthorize< TData = Awaited>, TError = ErrorType, >( + params?: AuthorizeParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> @@ -523,6 +537,7 @@ export function useAuthorize< TData = Awaited>, TError = ErrorType, >( + params?: AuthorizeParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> @@ -533,7 +548,7 @@ export function useAuthorize< ): UseQueryResult & { queryKey: DataTag; } { - const queryOptions = getAuthorizeQueryOptions(options); + const queryOptions = getAuthorizeQueryOptions(params, options); const query = useQuery(queryOptions, queryClient) as UseQueryResult< TData, diff --git a/apps/timo-web/generated/endpoints/calendar/calendar.zod.ts b/apps/timo-web/generated/endpoints/calendar/calendar.zod.ts index f23f846e..d7da0e03 100644 --- a/apps/timo-web/generated/endpoints/calendar/calendar.zod.ts +++ b/apps/timo-web/generated/endpoints/calendar/calendar.zod.ts @@ -34,7 +34,10 @@ export const ConnectCalendarResponse = zod.object({ .object({ calendarConnected: zod.boolean().optional(), calendarEmail: zod.string().optional(), - connectedAt: zod.string().optional().describe("UTC 기준 연동 시각"), + connectedAt: zod + .string() + .optional() + .describe("사용자 시간대(zoneId) 기준 연동 시각"), }) .optional(), }); @@ -103,8 +106,20 @@ export const GetCalendarEventsResponse = zod.object({ * 구글 캘린더 연동을 시작하는 구글 인증 URL을 발급합니다. * * 프론트는 이 응답의 authorizationUrl로 window.location.assign 등을 통해 직접 이동해야 합니다. + * + * redirectOrigin을 전달하면 {redirectOrigin}/oauth/calendar/callback을 redirect_uri로 사용합니다. + * 미입력이면 기본 프론트 주소로 redirect되고, 허용되지 않은 origin이면 400(COMMON_400)을 반환합니다. * @summary 구글 캘린더 연동 시작 */ +export const AuthorizeQueryParams = zod.object({ + redirectOrigin: zod + .string() + .optional() + .describe( + "연동 완료 후 돌아올 프론트 origin (미입력 시 기본 프론트 주소, 미허용 시 400)", + ), +}); + export const AuthorizeResponse = zod.object({ status: zod.number().optional(), message: zod.string().optional(), diff --git a/apps/timo-web/generated/models/authorizeParams.ts b/apps/timo-web/generated/models/authorizeParams.ts new file mode 100644 index 00000000..0b1b1d8e --- /dev/null +++ b/apps/timo-web/generated/models/authorizeParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface AuthorizeParams { + /** + * 연동 완료 후 돌아올 프론트 origin (미입력 시 기본 프론트 주소, 미허용 시 400) + */ + redirectOrigin?: string; +} diff --git a/apps/timo-web/generated/models/calendarConnectResponse.ts b/apps/timo-web/generated/models/calendarConnectResponse.ts index 8149bba2..5e626006 100644 --- a/apps/timo-web/generated/models/calendarConnectResponse.ts +++ b/apps/timo-web/generated/models/calendarConnectResponse.ts @@ -9,6 +9,6 @@ export interface CalendarConnectResponse { calendarConnected?: boolean; calendarEmail?: string; - /** UTC 기준 연동 시각 */ + /** 사용자 시간대(zoneId) 기준 연동 시각 */ connectedAt?: string; } diff --git a/apps/timo-web/generated/models/index.ts b/apps/timo-web/generated/models/index.ts index 712369de..33ab2de9 100644 --- a/apps/timo-web/generated/models/index.ts +++ b/apps/timo-web/generated/models/index.ts @@ -6,6 +6,7 @@ * OpenAPI spec version: v1 */ +export * from "./authorizeParams"; export * from "./authReissueResponse"; export * from "./authTokenRequest"; export * from "./authTokenResponse"; diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 180de03d..31c720a8 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -34,7 +34,12 @@ "LOW": "Low" }, "close": "Close", - "delete": "Delete" + "delete": "Delete", + "authButton": { + "googleLogin": "Continue with your Google account", + "googleCalendar": "Connect Google Calendar", + "appleCalendar": "Connect Apple Calendar" + } }, "Home": { "today": "Today", @@ -87,7 +92,7 @@ "profile": { "title": "Personal information", "profileSection": "Profile", - "calendarSection": "Google Calendar integration", + "calendarSection": "Calendar integration", "connect": "Integrate", "disconnect": "Disconnect", "languageSection": "Language", @@ -193,10 +198,6 @@ "prev": "Previous", "start": "Get Started" }, - "onboardingGoogleButton": { - "login": "Continue with your Google account", - "connectCalendar": "Connect Google Calendar" - }, "language": { "title": "Please select a language to use.", "description": "timo will be displayed in your selected language.\nYou can change it later in settings." @@ -231,9 +232,9 @@ "bedTimeError": "Please set different times for bedtime and wake-up." }, "calendarConnect": { - "title": "Connect your Google Calendar.", + "title": "Connect your Calendar.", "description": "You can import your existing schedules into timo and manage them in one place.", - "connectLabel": "Google Calendar integration", + "connectLabel": "Calendar integration", "consentNotice": "Clicking will display the Google authentication and calendar access permission screen.", "permissionNotice": "timo only requests permission to view your schedules." } diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 7e48164f..e1fd8cef 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -34,7 +34,12 @@ "LOW": "낮음" }, "close": "닫기", - "delete": "삭제" + "delete": "삭제", + "authButton": { + "googleLogin": "Google 계정으로 계속하기", + "googleCalendar": "Google 캘린더 연결하기", + "appleCalendar": "Apple 캘린더 연결하기" + } }, "Home": { "today": "오늘", @@ -87,7 +92,7 @@ "profile": { "title": "개인 정보", "profileSection": "프로필", - "calendarSection": "구글 캘린더 연동", + "calendarSection": "캘린더 연동", "connect": "연동하기", "disconnect": "연동 해제", "languageSection": "언어", @@ -193,10 +198,6 @@ "prev": "이전", "start": "시작하기" }, - "onboardingGoogleButton": { - "login": "Google 계정으로 계속하기", - "connectCalendar": "Google 캘린더 연결하기" - }, "language": { "title": "사용할 언어를 선택해 주세요.", "description": "선택한 언어로 timo의 모든 텍스트가 표시돼요.\n나중에 설정에서 변경할 수 있어요." @@ -228,9 +229,9 @@ "bedTimeError": "취침 시간과 기상 시간은 다르게 설정해 주세요." }, "calendarConnect": { - "title": "Google 캘린더를 연결하면\n일정을 함께 관리할 수 있어요.", + "title": "캘린더를 연결하면\n일정을 함께 관리할 수 있어요.", "description": "기존 일정을 timo에 불러와 한 곳에서 관리할 수 있어요.", - "connectLabel": "Google 캘린더 연동", + "connectLabel": "캘린더 연동", "consentNotice": "클릭 시 Google 인증 및 캘린더 접근 권한 동의 화면이 표시됩니다.", "permissionNotice": "timo는 일정 조회 권한만 요청합니다." } diff --git a/apps/timo-web/public/images/apple-calendar.png b/apps/timo-web/public/images/apple-calendar.png new file mode 100644 index 00000000..0037da1a Binary files /dev/null and b/apps/timo-web/public/images/apple-calendar.png differ diff --git a/apps/timo-web/public/images/google-calendar.png b/apps/timo-web/public/images/google-calendar.png index 76c7c564..80a6ce06 100644 Binary files a/apps/timo-web/public/images/google-calendar.png and b/apps/timo-web/public/images/google-calendar.png differ diff --git a/apps/timo-web/public/images/google-logo.png b/apps/timo-web/public/images/google-logo.png index 831de1aa..803afb29 100644 Binary files a/apps/timo-web/public/images/google-logo.png and b/apps/timo-web/public/images/google-logo.png differ diff --git a/apps/timo-web/utils/calendar/calendar-connect-failed.ts b/apps/timo-web/utils/calendar/calendar-connect-failed.ts new file mode 100644 index 00000000..a38b1434 --- /dev/null +++ b/apps/timo-web/utils/calendar/calendar-connect-failed.ts @@ -0,0 +1,25 @@ +const CALENDAR_CONNECT_FAILED_KEY = "timo:calendar-connect-failed"; + +export const markCalendarConnectFailed = (): void => { + try { + window.sessionStorage.setItem(CALENDAR_CONNECT_FAILED_KEY, "true"); + } catch { + // 저장소 접근 불가 시 무시 + } +}; + +export const hasCalendarConnectFailed = (): boolean => { + try { + return window.sessionStorage.getItem(CALENDAR_CONNECT_FAILED_KEY) !== null; + } catch { + return false; + } +}; + +export const clearCalendarConnectFailed = (): void => { + try { + window.sessionStorage.removeItem(CALENDAR_CONNECT_FAILED_KEY); + } catch { + // 저장소 접근 불가 시 무시 + } +}; diff --git a/apps/timo-web/utils/calendar/calendar-connect-origin.ts b/apps/timo-web/utils/calendar/calendar-connect-origin.ts new file mode 100644 index 00000000..80ccaa23 --- /dev/null +++ b/apps/timo-web/utils/calendar/calendar-connect-origin.ts @@ -0,0 +1,31 @@ +import type { CalendarConnectOrigin } from "@/constants/calendar"; + +import { CALENDAR_CONNECT_ORIGIN } from "@/constants/calendar"; + +const CALENDAR_CONNECT_ORIGIN_KEY = "timo:calendar-connect-origin"; + +const isCalendarConnectOrigin = ( + value: string | null, +): value is CalendarConnectOrigin => + Object.values(CALENDAR_CONNECT_ORIGIN).some((origin) => origin === value); + +export const setCalendarConnectOrigin = ( + origin: CalendarConnectOrigin, +): void => { + try { + window.sessionStorage.setItem(CALENDAR_CONNECT_ORIGIN_KEY, origin); + } catch { + // 저장소 접근 불가 시 무시 + } +}; + +export const consumeCalendarConnectOrigin = + (): CalendarConnectOrigin | null => { + try { + const origin = window.sessionStorage.getItem(CALENDAR_CONNECT_ORIGIN_KEY); + window.sessionStorage.removeItem(CALENDAR_CONNECT_ORIGIN_KEY); + return isCalendarConnectOrigin(origin) ? origin : null; + } catch { + return null; + } + }; diff --git a/packages/timo-design-system/src/assets/images/google-logo.svg b/packages/timo-design-system/src/assets/images/google-logo.svg deleted file mode 100644 index 8869c6c0..00000000 --- a/packages/timo-design-system/src/assets/images/google-logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - -