Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5eccca3
feat(web): 구글 소셜 로그인 및 accessToken 재발급 처리 구현 (#147)
ehye1 Jul 12, 2026
a84ddbf
refactor(web): 하드코딩된 경로를 ROUTES 상수로 교체 (#147)
ehye1 Jul 12, 2026
c04aaa9
feat(web): ROUTES 상수에 LOGIN, ONBOARDING 경로 추가 (#147)
ehye1 Jul 12, 2026
64f9ce6
fix(web): 코드래빗 리뷰 반영 - 인증 실패 엣지 케이스 처리 및 query key 정합성 수정 (#147)
ehye1 Jul 12, 2026
97576c6
ci(root): 빌드 단계에 NEXT_PUBLIC_API_BASE_URL 환경변수 주입 (#147)
ehye1 Jul 12, 2026
5b0080d
ci(root): 성능 분석 빌드 단계에 NEXT_PUBLIC_API_BASE_URL 환경변수 주입 (#147)
ehye1 Jul 12, 2026
44263ea
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
ehye1 Jul 12, 2026
4bb860d
fix(web): 구글 로그인 토큰 재발급 응답 처리 수정 (#147)
ehye1 Jul 12, 2026
7da9f11
fix(web): 로그인 카드 고정 너비 제거 및 패딩 기반 레이아웃으로 수정 (#147)
ehye1 Jul 12, 2026
46ea500
feat(web): 온보딩 완료 API 연동 및 홈 리다이렉트 구현 (#147)
ehye1 Jul 12, 2026
c087d48
refactor(web): OauthCallbackPage Suspense를 AsyncBoundary로 교체 (#147)
ehye1 Jul 12, 2026
4f1df0e
fix(web): 온보딩 완료 버튼 중복 제출 방지 (#147)
ehye1 Jul 12, 2026
c261863
feat(web): 온보딩 페이지 인증 가드 추가 (#147)
ehye1 Jul 12, 2026
c39acc0
feat(web): 온보딩 완료 실패 시 에러 토스트 표시 (#147)
ehye1 Jul 12, 2026
39aaecc
feat(web): 홈 섹션 AsyncBoundary 에러 폴백 추가 (#147)
ehye1 Jul 12, 2026
1aa0548
fix(web): CI 빌드 에러 수정 - 홈 섹션 에러 폴백 제거 (#147)
ehye1 Jul 12, 2026
cafecd7
refactor(web): 온보딩 시간 드롭다운 AM/PM 제거 및 스크롤바 개선 (#147)
ehye1 Jul 12, 2026
169a67e
fix(web): 취침·기상 시간 동일 여부 검증 및 에러 메시지 수정 (#147)
ehye1 Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,6 @@ jobs:

- name: Build
if: steps.affected.outputs.has_changes == 'true'
env:
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
run: pnpm build
1 change: 1 addition & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
env:
TURBO_UI: false
NO_COLOR: '1'
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
run: pnpm turbo run build --filter=timo-web

- name: Next.js 서버 시작
Expand Down
64 changes: 61 additions & 3 deletions apps/timo-web/api/client/axios.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,77 @@
import axios from "axios";
import axios, { AxiosError } from "axios";

import type { BaseResponseAuthReissueResponse } from "@/api/generated/models";

import { parseApiError } from "@/api/error/api-error";
import { ROUTES } from "@/constants/routes";
import { useAuthStore } from "@/stores/auth/useAuthStore";

const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL;
if (!baseURL)
throw new Error("NEXT_PUBLIC_API_BASE_URL이 설정되지 않았습니다.");

export const instance = axios.create({
baseURL,
withCredentials: true,
});

instance.interceptors.request.use((config) => {
const accessToken = useAuthStore.getState().accessToken;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});

const REISSUE_URL = "/api/v1/auth/reissue";
const retriedRequests = new WeakSet<object>();
let reissuePromise: Promise<string | undefined> | null = null;

const reissueAccessToken = () => {
if (!reissuePromise) {
reissuePromise = instance
.post<BaseResponseAuthReissueResponse>(REISSUE_URL)
.then(({ data }) => {
const reissueData = data.data;
if (reissueData)
useAuthStore.getState().setAccessToken(reissueData.accessToken);
return reissueData?.accessToken;
})
.catch((error) => {
useAuthStore.getState().clearAccessToken();
throw error;
})
.finally(() => {
reissuePromise = null;
});
}
return reissuePromise;
};

instance.interceptors.response.use(
(response) => response,
(error) => {
// TODO: 추후 인증 방식 확정 후 status(401 등) 기반 사이드 이펙트(리다이렉트 등) 추가
async (error: AxiosError) => {
const originalRequest = error.config;
const isReissueRequest = originalRequest?.url === REISSUE_URL;

if (
error.response?.status === 401 &&
originalRequest &&
!isReissueRequest &&
!retriedRequests.has(originalRequest)
) {
retriedRequests.add(originalRequest);
try {
const newAccessToken = await reissueAccessToken();
if (newAccessToken) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return instance(originalRequest);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
window.location.href = ROUTES.LOGIN;
} catch {
window.location.href = ROUTES.LOGIN;
}
}
return Promise.reject(parseApiError(error));
},
);
5 changes: 4 additions & 1 deletion apps/timo-web/app/[locale]/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MainShellContainer } from "@/app/[locale]/(main)/_containers/MainShellContainer";
import { AuthGuardProvider } from "@/providers/auth/AuthGuardProvider";

interface MainLayoutProps {
children: React.ReactNode;
Expand All @@ -7,7 +8,9 @@ interface MainLayoutProps {
export default function MainLayout({ children }: Readonly<MainLayoutProps>) {
return (
<div className="bg-timo-gray-300 h-screen overflow-hidden py-5">
<MainShellContainer>{children}</MainShellContainer>
<AuthGuardProvider>
<MainShellContainer>{children}</MainShellContainer>
</AuthGuardProvider>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export const LoginContainer = () => {
ariaLabel={t("animationLabel")}
/>

<div className="border-timo-gray-500 shadow-timo flex h-110 w-101 flex-col items-center justify-center gap-16 rounded-[4px] border bg-white px-12.5 py-13">
<div className="flex w-76 flex-col gap-6">
<div className="border-timo-gray-500 shadow-timo flex flex-col items-center justify-center gap-16 rounded-[4px] border bg-white px-12.5 py-13">
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-4">
<Image src={timoTextLogo} alt="Timo" width={92} height={35} />
<div className="flex w-full flex-col items-center gap-0.5">
Expand All @@ -44,7 +44,7 @@ export const LoginContainer = () => {
<OnboardingGoogleButtonContainer
variant="login"
onClick={() => {
// TODO: 백엔드 OAuth 로그인 URL 확정 후 리다이렉트
window.location.href = `${process.env.NEXT_PUBLIC_API_BASE_URL}/oauth2/authorization/google`;
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";

import { useToken } from "@/api/generated/endpoints/auth/auth";
import { getGetMyProfileQueryKey } from "@/api/generated/endpoints/user/user";
import { ROUTES } from "@/constants/routes";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth/useAuthStore";

export const OauthCallbackContainer = () => {
const code = useSearchParams().get("code");
const router = useRouter();
const queryClient = useQueryClient();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const { mutate } = useToken();
const hasRequested = useRef(false);

useEffect(() => {
if (!code) {
router.replace(ROUTES.LOGIN);
return;
}
if (hasRequested.current) return;
hasRequested.current = true;

mutate(
{ data: { code } },
{
onSuccess: ({ data }) => {
if (!data?.accessToken) {
router.replace(ROUTES.LOGIN);
return;
}
setAccessToken(data.accessToken);
queryClient.setQueryData(getGetMyProfileQueryKey(), data.user);
router.replace(data.isNewUser ? ROUTES.ONBOARDING : ROUTES.HOME);
},
onError: () => {
router.replace(ROUTES.LOGIN);
},
},
);
}, [code, mutate, queryClient, router, setAccessToken]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return null;
};
11 changes: 11 additions & 0 deletions apps/timo-web/app/[locale]/oauth/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { OauthCallbackContainer } from "@/app/[locale]/oauth/callback/_containers/OauthCallbackContainer";
import { AsyncBoundary } from "@/components/boundary/AsyncBoundary";


export default function OauthCallbackPage() {
return (
<AsyncBoundary>
<OauthCallbackContainer />
</AsyncBoundary>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const OnboardingGoogleButton = ({
onClick={onClick}
className={cn(
"flex w-full items-center justify-center rounded-[4px] border py-2.5",
"active:border-timo-blue-300 active:bg-timo-blue-50",
isSelected
? "border-timo-blue-300 bg-timo-blue-50"
: "border-timo-gray-500 bg-white",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import {
import { Dropdown } from "@repo/timo-design-system/ui";
import { cn } from "@repo/timo-design-system/utils";

import { getAmPm } from "@/utils/get-am-pm";

const TIME_OPTIONS = Array.from(
{ length: 25 },
(_, i) => `${String(i).padStart(2, "0")}:00`,
Expand All @@ -27,12 +25,7 @@ export const OnboardingTimeDropdown = ({
<Dropdown className="w-[150px]">
<Dropdown.Trigger className="group border-timo-gray-500 flex w-full items-center justify-between rounded-[4px] border bg-white px-4 py-3 text-left">
{value ? (
<div className="flex items-center gap-1">
<span className="typo-headline-b-16 text-timo-black">{value}</span>
<span className="typo-headline-b-16 text-timo-black">
{getAmPm(value)}
</span>
</div>
<span className="typo-headline-b-16 text-timo-black">{value}</span>
) : (
<span className="typo-headline-b-16 text-timo-gray-700">
{placeholder}
Expand All @@ -43,18 +36,17 @@ export const OnboardingTimeDropdown = ({
</Dropdown.Trigger>

<Dropdown.Panel className="border-timo-gray-500 mt-1 h-[220px] w-full rounded-[4px] border py-3 pr-2.5 pl-4">
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-auto p-1 pr-3.5">
<div className="scrollbar-sm flex h-full w-full flex-col gap-2.5 overflow-y-auto p-1 pr-3.5">
{TIME_OPTIONS.map((time) => (
<Dropdown.Item
key={time}
onClick={() => onChange(time)}
className={cn(
"typo-headline-b-16 flex w-full justify-between rounded-none",
"typo-headline-b-16 w-full rounded-none",
value === time ? "text-timo-blue-300" : "text-timo-gray-700",
)}
>
<span>{time}</span>
<span>{getAmPm(time)}</span>
{time}
</Dropdown.Item>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { OnboardingButtonContainer } from "@/app/[locale]/onboarding/_containers
import { OnboardingGoogleButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer";

interface CalendarConnectStepContainerProps {
isPending?: boolean;
onPrev: () => void;
onStart: () => void;
}

export const CalendarConnectStepContainer = ({
isPending,
onPrev,
onStart,
}: CalendarConnectStepContainerProps) => {
Expand Down Expand Up @@ -58,7 +60,11 @@ export const CalendarConnectStepContainer = ({

<div className="mt-auto flex justify-between">
<OnboardingButtonContainer variant="prev" onClick={onPrev} />
<OnboardingButtonContainer variant="start" onClick={onStart} />
<OnboardingButtonContainer
variant="start"
disabled={isPending}
onClick={onStart}
/>
</div>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const LifePatternStepContainer = ({
const t = useTranslations("Onboarding");

const isBedTimeInvalid = Boolean(
wakeUpTime && bedTime && bedTime <= wakeUpTime,
wakeUpTime && bedTime && bedTime === wakeUpTime,
);
const canProceed = Boolean(wakeUpTime && bedTime && !isBedTimeInvalid);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
"use client";

import { useFunnel } from "@use-funnel/browser";
import { useTranslations } from "next-intl";
import { useState } from "react";

import { useCompleteOnboarding } from "@/api/generated/endpoints/onboarding/onboarding";
import { OnboardingRequestLanguage } from "@/api/generated/models";
import { OnboardingStepButton } from "@/app/[locale]/onboarding/_components/OnboardingStepButton";
import { CalendarConnectStepContainer } from "@/app/[locale]/onboarding/_containers/CalendarConnectStepContainer";
import { LanguageStepContainer } from "@/app/[locale]/onboarding/_containers/LanguageStepContainer";
import { LifePatternStepContainer } from "@/app/[locale]/onboarding/_containers/LifePatternStepContainer";
import { TimePredictionStepContainer } from "@/app/[locale]/onboarding/_containers/TimePredictionStepContainer";
import { OnboardingFunnelSteps } from "@/app/[locale]/onboarding/_types/onboarding-funnel";
import { LottiePlayer } from "@/components/lottie/LottiePlayer";
import { AnimatedToast } from "@/components/toast/AnimatedToast";
import { ROUTES } from "@/constants/routes";
import { useRouter } from "@/i18n/navigation";

const STEP_NUMBER: Record<keyof OnboardingFunnelSteps, 1 | 2 | 3 | 4> = {
Language: 1,
Expand All @@ -18,18 +24,33 @@ const STEP_NUMBER: Record<keyof OnboardingFunnelSteps, 1 | 2 | 3 | 4> = {
CalendarConnect: 4,
};

const ONBOARDING_LANGUAGE_MAP: Record<"ko" | "en", OnboardingRequestLanguage> =
{
ko: OnboardingRequestLanguage.KO,
en: OnboardingRequestLanguage.EN,
};

export const OnboardingFunnelContainer = () => {
const t = useTranslations("Toast");
const router = useRouter();
const [answers, setAnswers] = useState<
Partial<OnboardingFunnelSteps["CalendarConnect"]>
>({});
const [isErrorToastOpen, setIsErrorToastOpen] = useState(false);

const funnel = useFunnel<OnboardingFunnelSteps>({
id: "onboarding",
initial: { step: "Language", context: {} },
});
const { mutate: completeOnboarding, isPending } = useCompleteOnboarding();

return (
<section className="flex min-h-screen items-center justify-center gap-10 bg-white px-8 lg:gap-16 xl:gap-36 2xl:gap-[225px]">
<AnimatedToast
isOpen={isErrorToastOpen}
onClose={() => setIsErrorToastOpen(false)}
message={t("onboardingSubmitFailed")}
/>
<LottiePlayer
src="/lottie/onboarding.json"
className="hidden shrink-0 lg:block lg:size-[350px] xl:size-[430px] 2xl:size-[500px]"
Expand Down Expand Up @@ -100,6 +121,7 @@ export const OnboardingFunnelContainer = () => {
)}
CalendarConnect={({ history }) => (
<CalendarConnectStepContainer
isPending={isPending}
onPrev={() => history.back()}
onStart={() => {
if (
Expand All @@ -109,7 +131,26 @@ export const OnboardingFunnelContainer = () => {
!answers.bedTime
)
return;
// TODO: 실제 제출 API 연동 + 완료 후 answers.language로 locale 리다이렉트
completeOnboarding(
{
data: {
language: ONBOARDING_LANGUAGE_MAP[answers.language],
predictionAccuracy: answers.predictionAccuracy,
wakeUpTime: answers.wakeUpTime,
bedTime: answers.bedTime,
},
},
{
onSuccess: () => {
router.replace(ROUTES.HOME, {
locale: answers.language,
});
},
onError: () => {
setIsErrorToastOpen(true);
},
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +136 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

온보딩 API가 아직 검증되지 않은 상태라고 To Reviewers에 명시하셨는데, 정작 실패 핸들러가 없어요. 실패하면 사용자가 CalendarConnect 단계에서 아무 피드백 없이 멈춰요. 간단하게 .alert 토스트로 피드백 주는건 어떤가요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아주 좋은 의견입니다. 온보딩 API에 onError 추가하고 Home 넘어갈 때 errorFallback 추가하겠습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next.js는 error.tsx를 통한 라우트 단위 에러 처리만 지원하고, 현재 구조에서는 Server Component → Client Component로 fallback 함수를 전달할 수 없어서 Home의 errorFallback 다시 제거했습니다 ~

}}
/>
)}
Expand Down
7 changes: 6 additions & 1 deletion apps/timo-web/app/[locale]/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { OnboardingFunnelContainer } from "@/app/[locale]/onboarding/_containers/OnboardingFunnelContainer";
import { AuthGuardProvider } from "@/providers/auth/AuthGuardProvider";

export default function OnboardingPage() {
return <OnboardingFunnelContainer />;
return (
<AuthGuardProvider>
<OnboardingFunnelContainer />
</AuthGuardProvider>
);
}
2 changes: 2 additions & 0 deletions apps/timo-web/constants/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const ROUTES = {
LOGIN: "/login",
ONBOARDING: "/onboarding",
HOME: "/home",
TODAY: "/today",
FOCUS: "/focus",
Expand Down
Loading
Loading