Skip to content
5 changes: 5 additions & 0 deletions apps/timo-web/app/[locale]/[...rest]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { notFound } from "next/navigation";

export default function CatchAll() {
notFound();
}
38 changes: 38 additions & 0 deletions apps/timo-web/app/[locale]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import * as Sentry from "@sentry/nextjs";
import { useTranslations } from "next-intl";
import { useEffect } from "react";

import { StatusScreen } from "@/components/status-screen/StatusScreen";
import { Link } from "@/i18n/navigation";

interface ErrorPageProps {
error: Error & { digest?: string };
}

export default function ErrorPage({ error }: ErrorPageProps) {
const t = useTranslations("Error");

useEffect(() => {
Sentry.captureException(error);
}, [error]);

return (
<StatusScreen
lottieSrc="/lottie/500.json"
title={t("title")}
description={t("description")}
action={
<div className="flex items-center gap-3">
<Link
href="/"
className="typo-headline-m-16 rounded-[8px] border border-gray-500 px-4 py-2.5 text-gray-900"
>
{t("homeButton")}
</Link>
</div>
}
/>
);
}
13 changes: 13 additions & 0 deletions apps/timo-web/app/[locale]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { LottiePlayer } from "@/components/lottie/LottiePlayer";

export default function Loading() {
return (
<div className="flex min-h-screen w-full items-center justify-center">
<LottiePlayer
src="/lottie/loading.json"
className="w-[143px]"
ariaLabel="로딩 중"
/>
</div>
Comment thread
kimminna marked this conversation as resolved.
);
}
24 changes: 24 additions & 0 deletions apps/timo-web/app/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getTranslations } from "next-intl/server";

import { StatusScreen } from "@/components/status-screen/StatusScreen";
import { Link } from "@/i18n/navigation";

export default async function NotFound() {
const t = await getTranslations("NotFound");

return (
<StatusScreen
lottieSrc="/lottie/404.json"
title={t("title")}
description={t("description")}
action={
<Link
href="/"
className="typo-headline-m-16 rounded-[8px] border border-gray-500 px-4 py-2.5 text-gray-900"
>
{t("homeButton")}
</Link>
}
/>
);
}
11 changes: 10 additions & 1 deletion apps/timo-web/app/[locale]/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import { LottiePlayer } from "@/components/lottie/LottiePlayer";

export default function OnboardingPage() {
return <></>;
return (
<div className="relative h-screen w-full">
<LottiePlayer
src="/lottie/onboarding.json"
className="absolute top-1/2 left-[197px] w-[500px] -translate-y-1/2"
/>
Comment on lines +5 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 온보딩 페이지가 데스크톱 전용인지, 반응형 대응이 필요한지 확인합니다.
# 1. 미디어 쿼리나 반응형 클래스 사용 여부 검색
rg -n 'md:|lg:|sm:|xl:' apps/timo-web/app/[locale]/onboarding/

# 2. 다른 페이지의 반응형 패턴 확인
rg -n 'left-\[|w-\[500' apps/timo-web/app/ --type=ts

# 3. Viewport meta 태그 또는 반응형 설정 확인
rg -n 'viewport|max-width|min-width' apps/timo-web/app/[locale]/layout.tsx

Repository: Team-Timo/Timo-client

Length of output: 285


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- onboarding page ---'
sed -n '1,200p' apps/timo-web/app/[locale]/onboarding/page.tsx | cat -n

echo
echo '--- find LottiePlayer definition/usages ---'
fd -a 'LottiePlayer' apps/timo-web components | sed -n '1,120p' || true
rg -n "export .*LottiePlayer|function LottiePlayer|const LottiePlayer|ariaLabel|aria-label" apps/timo-web components -g '!**/node_modules/**' | sed -n '1,200p' || true

echo
echo '--- list lottie component files ---'
fd -a '' components/lottie apps/timo-web/components/lottie 2>/dev/null | sed -n '1,200p' || true

Repository: Team-Timo/Timo-client

Length of output: 1763


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- onboarding-related files ---'
fd -a 'onboarding' apps/timo-web | sed -n '1,200p'

echo
echo '--- onboarding page references ---'
rg -n "onboarding" apps/timo-web -g '!**/node_modules/**' | sed -n '1,200p'

echo
echo '--- LottiePlayer definition ---'
sed -n '1,220p' apps/timo-web/components/lottie/LottiePlayer.tsx | cat -n

echo
echo '--- relevant layout/page sizing hints ---'
sed -n '1,220p' apps/timo-web/app/[locale]/layout.tsx | cat -n

Repository: Team-Timo/Timo-client

Length of output: 3587


온보딩 애니메이션에 반응형/접근성 레일을 깔아주세요
레이아웃 뼈대는 깔끔합니다. 다만 left-[197px] + w-[500px] 조합은 375px 폭에서 오른쪽으로 크게 넘쳐 보입니다. sm/md에서 위치·크기를 조정하거나, 정말 데스크톱 전용이면 의도를 코드에 드러내 주세요.
또한 LottiePlayerariaLabel을 지원하므로, 의미 있는 안내라면 라벨을 넣고 장식용이라면 숨김 처리하는 쪽이 맞습니다.
참고: MDN ARIA, Next.js Accessibility

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/timo-web/app/`[locale]/onboarding/page.tsx around lines 5 - 9, The
onboarding page animation in the LottiePlayer usage is hard-coded for desktop
and overflows on small screens, and it also lacks an accessibility decision.
Update the LottiePlayer configuration to use responsive positioning/sizing in
the onboarding page component (for example by adjusting the current absolute
placement across breakpoints or making the desktop-only intent explicit), and
add an appropriate ariaLabel if the animation conveys meaning or hide it from
assistive tech if it is decorative.

</div>
);
}
74 changes: 68 additions & 6 deletions apps/timo-web/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,86 @@
"use client";

import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
import localFont from "next/font/local";
import { NextIntlClientProvider, useTranslations } from "next-intl";
import { useEffect, useState } from "react";

import { StatusScreen } from "@/components/status-screen/StatusScreen";
import { Link } from "@/i18n/navigation";
import { routing } from "@/i18n/routing";
import enMessages from "@/messages/en.json";
import koMessages from "@/messages/ko.json";

import "./[locale]/globals.css";

type Locale = (typeof routing.locales)[number];

const messagesByLocale: Record<Locale, typeof enMessages> = {
en: enMessages,
ko: koMessages,
};

const pretendard = localFont({
src: "../fonts/PretendardVariable.woff2",
variable: "--font-family-pretendard",
weight: "45 920",
display: "swap",
});

interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}

export default function GlobalError({ error, reset }: GlobalErrorProps) {
function getLocaleFromPathname(): Locale {
const segment = window.location.pathname.split("/")[1];
return routing.locales.includes(segment as Locale)
? (segment as Locale)
: routing.defaultLocale;
}

function GlobalErrorContent() {
const t = useTranslations("GlobalError");

return (
<StatusScreen
lottieSrc="/lottie/error.json"
title={t("title")}
description={t("description")}
action={
<div className="flex items-center gap-3">
<Link
href="/"
className="typo-headline-m-16 rounded-[8px] border border-gray-500 px-4 py-2.5 text-gray-900"
>
{t("homeButton")}
</Link>
</div>
Comment on lines +50 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

액션 버튼 코드 중복 — 공통 컴포넌트 추출을 제안합니다.

global-error.tsx, error.tsx(26-34행), not-found.tsx(14-21행) 세 파일에서 동일한 Link 버튼(typo-headline-m-16 rounded-[8px] border border-gray-500 px-4 py-2.5 text-gray-900)이 반복됩니다. not-found.tsx는 wrapping <div> 없이 Link만 전달하고, 나머지 두 파일은 <div className="flex items-center gap-3">으로 감싸고 있어 구조 불일치도 있습니다.

공통 StatusActionButton 컴포넌트를 추출하면 중복 제거와 일관성 확보를 동시에 잡을 수 있습니다.

♻️ 공통 StatusActionButton 컴포넌트 추출 제안
// components/status-screen/StatusActionButton.tsx
import { Link } from "`@/i18n/navigation`";

interface StatusActionButtonProps {
  label: string;
  href?: string;
}

export const StatusActionButton = ({
  label,
  href = "/",
}: StatusActionButtonProps) => (
  <Link
    href={href}
    className="typo-headline-m-16 rounded-[8px] border border-gray-500 px-4 py-2.5 text-gray-900"
  >
    {label}
  </Link>
);

각 파일에서의 사용:

- action={
-   <div className="flex items-center gap-3">
-     <Link
-       href="/"
-       className="typo-headline-m-16 rounded-[8px] border border-gray-500 px-4 py-2.5 text-gray-900"
-     >
-       {t("homeButton")}
-     </Link>
-   </div>
- }
+ action={<StatusActionButton label={t("homeButton")} />}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/timo-web/app/global-error.tsx` around lines 50 - 58, 중복된 상태 화면 액션
버튼(Link) 구현을 공통 컴포넌트로 추출하세요. global-error.tsx의 action, error.tsx의 action,
not-found.tsx의 버튼이 동일한 스타일과 거의 같은 구조를 반복하므로, StatusActionButton 같은 재사용 컴포넌트를 만들어
href와 label만 받도록 정리하고 세 파일에서 이를 사용하세요. 또한 global-error.tsx와 error.tsx의 wrapping
div와 not-found.tsx의 단일 Link 구조 차이를 함께 정리해, 각 화면의 action 렌더링 형태가 일관되게 맞도록 수정하세요.

}
/>
);
}

export default function GlobalError({ error }: GlobalErrorProps) {
const [locale, setLocale] = useState<Locale>(routing.defaultLocale);

useEffect(() => {
Sentry.captureException(error);
}, [error]);

useEffect(() => {
setLocale(getLocaleFromPathname());
}, []);

return (
<html lang="ko">
<html lang={locale} className={pretendard.variable}>
<body>
<h1>문제가 발생했어요</h1>
<p>잠시 후 다시 시도해주세요.</p>
<button onClick={reset}>다시 시도</button>
<NextIntlClientProvider
locale={locale}
messages={messagesByLocale[locale]}
>
<GlobalErrorContent />
</NextIntlClientProvider>
</body>
</html>
);
Expand Down
52 changes: 52 additions & 0 deletions apps/timo-web/components/lottie/LottiePlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { cn } from "@repo/timo-design-system/utils";
import Lottie from "lottie-react";
import { useEffect, useState } from "react";

export interface LottiePlayerProps {
src: string;
loop?: boolean;
className?: string;
ariaLabel?: string;
}

export const LottiePlayer = ({
src,
loop = true,
className,
ariaLabel,
}: LottiePlayerProps) => {
const [animationData, setAnimationData] = useState<object | null>(null);

useEffect(() => {
const controller = new AbortController();

fetch(src, { signal: controller.signal })

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.

lottie json 파일을 어떻게 가져오나 궁금했는데 fetch로 가져오게 되면 필요할 때만 가져올 수 있어서 번들 크기를 줄일 수 있겠네요!

.then((response) => {
if (!response.ok) {
throw new Error(`Lottie 애니메이션 로드 실패: ${response.status}`);
}
return response.json();
})
.then(setAnimationData)
.catch(() => {
// 애니메이션 로드 실패 시 컨테이너만 비운 상태로 유지 (레이아웃 시프트 방지)

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.

lottie 파일이 렌더링 되기 전까지 CLS가 발생하는 것 같은데 나중에 높이 고정해놓거나 placeholder 설정해두면 좋을 것 같아요!

Comment on lines +25 to +34

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.

Suggested change
fetch(src, { signal: controller.signal })
.then((response) => response.json())
.then(setAnimationData)
.catch(() => {
// 애니메이션 로드 실패 시 컨테이너만 비운 상태로 유지 (레이아웃 시프트 방지)
fetch(src, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`Failed to load: ${response.status}`);
return response.json();
})
.then(setAnimationData)
.catch(() => {
// 애니메이션 로드 실패 시 컨테이너만 비운 상태로 유지 (레이아웃 시프트 방지)
});

fetch는 404/500에서도 성공으로 resolve되기 때문에, response.ok 체크 없이 바로 .json()을 호출하면 에러 페이지의 HTML이 animationData로 들어가서 Lottie가 깨질 수 있을 것 같아요!
response.ok가 false일 때 에러를 throw하면 기존 .catch(() => {}) 블록으로 떨어져서 animationData가 null인 상태로 유지되고, Lottie 컴포넌트가 렌더링되지 않아요. 기존 catch 로직을 재활용하면서 최소한의 변경으로 방어할 수 있을 것 같아요!
민아님은 어떻게 생각하시나요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

response.ok가 false면 에러를 던져서 기존 .catch(() => {})가 그대로 잡아주고, animationData는 null로 유지되어 Lottie가 렌더링되지 않는 안전한 상태를 유지하게 변경하겠습니다!

});

return () => controller.abort();
}, [src]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className={cn("w-full", className)}>
{animationData && (
<Lottie
animationData={animationData}
loop={loop}
role="img"
aria-label={ariaLabel}
/>
)}
</div>
);
};
30 changes: 30 additions & 0 deletions apps/timo-web/components/status-screen/StatusScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";

import { LottiePlayer } from "@/components/lottie/LottiePlayer";

export interface StatusScreenProps {
lottieSrc: string;
title: string;
description: string;
action?: ReactNode;
}

export const StatusScreen = ({
lottieSrc,
title,
description,
action,
}: StatusScreenProps) => {
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-[90px] px-6 text-center">
<LottiePlayer src={lottieSrc} className="w-[397px]" ariaLabel={title} />
<div className="flex w-full flex-col items-center gap-10">
<div className="flex flex-col gap-1.5">
<h1 className="typo-headline-b-30 text-timo-black">{title}</h1>
<p className="typo-body-r-12 text-timo-gray-900">{description}</p>
</div>
{action}
</div>
</div>
);
};
15 changes: 15 additions & 0 deletions apps/timo-web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@
"todoLimitLine1": "You can add up to <blue>20 incomplete to-dos</blue>.",
"todoLimitLine2": "To add a new to-do, please complete an existing one."
},
"Error": {
"title": "A problem has occurred.",
"description": "We are currently undergoing maintenance for a better experience. Please try again later.",
"homeButton": "Return to home"
},
"NotFound": {
"title": "Page not found",
"description": "You may have entered an invalid address or used an incorrect path.",
"homeButton": "Return to home"
},
"GlobalError": {
"title": "An issue has occurred.",
"description": "Please try again later or go back to home.",
"homeButton": "Return to home"
},
"Onboarding": {
"button": {
"next": "Next",
Expand Down
15 changes: 15 additions & 0 deletions apps/timo-web/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@
"todoLimitLine1": "완료되지 않은 투두는 <blue>최대 20개</blue>까지 추가할 수 있어요.",
"todoLimitLine2": "새로운 투두를 추가하려면 기존 투두를 완료해주세요."
},
"Error": {
"title": "잠시 문제가 발생했어요.",
"description": "더 나은 경험을 위해 잠시 점검 중입니다. 잠시후 다시 이용해 주세요.",
"homeButton": "홈으로 돌아가기"
},
"NotFound": {
"title": "페이지를 찾을 수 없어요.",
"description": "존재하지 않는 주소를 입력하셨거나, 잘못된 경로를 이용하셨어요",
"homeButton": "홈으로 돌아가기"
},
"GlobalError": {
"title": "문제가 발생했어요.",
"description": "잠시 후 다시 시도하거나 홈으로 이동해주세요.",
"homeButton": "홈으로 돌아가기"
Comment thread
kimminna marked this conversation as resolved.
},
"Onboarding": {
"button": {
"next": "다음",
Expand Down
1 change: 1 addition & 0 deletions apps/timo-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-query-devtools": "^5.101.1",
"axios": "^1.18.1",
"lottie-react": "^2.4.1",
"next": "16.2.0",
"next-intl": "^4.13.1",
"react": "^19.2.0",
Expand Down
Loading
Loading