-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 에러/로딩/온보딩 상태 페이지 Lottie 애니메이션 적용 #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5167161
bfb7595
5ee988e
cb0b3bf
512096e
ba898b0
c0fb743
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { notFound } from "next/navigation"; | ||
|
|
||
| export default function CatchAll() { | ||
| notFound(); | ||
| } |
| 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> | ||
| } | ||
| /> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } | ||
| 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> | ||
| } | ||
| /> | ||
| ); | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.tsxRepository: 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' || trueRepository: 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 -nRepository: Team-Timo/Timo-client Length of output: 3587 온보딩 애니메이션에 반응형/접근성 레일을 깔아주세요 🤖 Prompt for AI Agents |
||
| </div> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win 액션 버튼 코드 중복 — 공통 컴포넌트 추출을 제안합니다.
공통 ♻️ 공통 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 |
||
| } | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| 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> | ||
| ); | ||
|
|
||
| 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 }) | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(() => { | ||||||||||||||||||||||||||||||
| // 애니메이션 로드 실패 시 컨테이너만 비운 상태로 유지 (레이아웃 시프트 방지) | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lottie 파일이 렌더링 되기 전까지 CLS가 발생하는 것 같은데 나중에 높이 고정해놓거나 placeholder 설정해두면 좋을 것 같아요!
Comment on lines
+25
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
fetch는 404/500에서도 성공으로 resolve되기 때문에, response.ok 체크 없이 바로 .json()을 호출하면 에러 페이지의 HTML이 animationData로 들어가서 Lottie가 깨질 수 있을 것 같아요!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. response.ok가 false면 에러를 던져서 기존 .catch(() => {})가 그대로 잡아주고, animationData는 null로 유지되어 Lottie가 렌더링되지 않는 안전한 상태를 유지하게 변경하겠습니다! |
||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return () => controller.abort(); | ||||||||||||||||||||||||||||||
| }, [src]); | ||||||||||||||||||||||||||||||
|
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> | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| 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> | ||
| ); | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.