diff --git a/apps/timo-web/app/[locale]/(main)/layout.tsx b/apps/timo-web/app/[locale]/(main)/layout.tsx index e6a6086f..88d9c6b0 100644 --- a/apps/timo-web/app/[locale]/(main)/layout.tsx +++ b/apps/timo-web/app/[locale]/(main)/layout.tsx @@ -1,6 +1,15 @@ +import type { Metadata } from "next"; + import { MainShellContainer } from "@/app/[locale]/(main)/_containers/MainShellContainer"; import { AuthGuardProvider } from "@/providers/auth/AuthGuardProvider"; +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + interface MainLayoutProps { children: React.ReactNode; } diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index dfd9f6da..8f27108d 100644 --- a/apps/timo-web/app/[locale]/layout.tsx +++ b/apps/timo-web/app/[locale]/layout.tsx @@ -2,7 +2,7 @@ import "./globals.css"; import localFont from "next/font/local"; import { notFound } from "next/navigation"; import { hasLocale, NextIntlClientProvider } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; +import { getTranslations, setRequestLocale } from "next-intl/server"; import type { Metadata } from "next"; @@ -19,9 +19,11 @@ const pretendard = localFont({ display: "swap", }); -export const metadata: Metadata = { - title: "Timo", - description: "Timo web", +const SITE_URL = "https://timo.kr"; + +const OG_LOCALE: Record<(typeof routing.locales)[number], string> = { + en: "en_US", + ko: "ko_KR", }; interface RootLayoutProps { @@ -33,6 +35,54 @@ export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } +export async function generateMetadata({ + params, +}: Omit): Promise { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + const t = await getTranslations({ locale, namespace: "Metadata" }); + const title = t("title"); + const description = t("description"); + + return { + metadataBase: new URL(SITE_URL), + title: { + default: title, + template: `%s | ${title}`, + }, + description, + icons: { + icon: "/favicon.png", + }, + openGraph: { + type: "website", + siteName: title, + title, + description, + locale: OG_LOCALE[locale], + alternateLocale: routing.locales + .filter((otherLocale) => otherLocale !== locale) + .map((otherLocale) => OG_LOCALE[otherLocale]), + }, + twitter: { + card: "summary", + title, + description, + }, + robots: { + index: true, + follow: true, + }, + formatDetection: { + telephone: false, + }, + }; +} + export default async function RootLayout({ children, params, diff --git a/apps/timo-web/app/[locale]/login/page.tsx b/apps/timo-web/app/[locale]/login/page.tsx index bdb5f78e..bdfda844 100644 --- a/apps/timo-web/app/[locale]/login/page.tsx +++ b/apps/timo-web/app/[locale]/login/page.tsx @@ -1,6 +1,37 @@ +import { notFound } from "next/navigation"; +import { hasLocale } from "next-intl"; +import { getTranslations } from "next-intl/server"; + +import type { Metadata } from "next"; + import { LoginContainer } from "@/app/[locale]/login/_containers/LoginContainer"; +import { routing } from "@/i18n/routing"; import { GuestGuardProvider } from "@/providers/auth/GuestGuardProvider"; +interface LoginPageProps { + params: Promise<{ locale: string }>; +} + +export async function generateMetadata({ + params, +}: LoginPageProps): Promise { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + const t = await getTranslations({ locale, namespace: "Login" }); + + return { + title: t("title"), + description: t("description"), + alternates: { + canonical: `/${locale}/login`, + }, + }; +} + export default function LoginPage() { return ( diff --git a/apps/timo-web/app/[locale]/policy/page.tsx b/apps/timo-web/app/[locale]/policy/page.tsx index b3169765..2366061b 100644 --- a/apps/timo-web/app/[locale]/policy/page.tsx +++ b/apps/timo-web/app/[locale]/policy/page.tsx @@ -1,16 +1,47 @@ import timoTextLogo from "@repo/timo-design-system/assets/images/logo/timo-text-logo.svg"; import Image from "next/image"; +import { notFound } from "next/navigation"; +import { hasLocale } from "next-intl"; +import { getTranslations } from "next-intl/server"; + +import type { Metadata } from "next"; import { PolicyContainer } from "@/app/[locale]/policy/_containers/PolicyContainer"; import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; import { ROUTES } from "@/constants/routes"; import { Link } from "@/i18n/navigation"; +import { routing } from "@/i18n/routing"; import { termsTypeSchema } from "@/types/terms-type"; interface PolicyPageProps { + params: Promise<{ locale: string }>; searchParams: Promise<{ type?: string }>; } +export async function generateMetadata({ + params, + searchParams, +}: PolicyPageProps): Promise { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + const { type } = await searchParams; + const parsedType = termsTypeSchema.safeParse(type); + const isPrivacy = parsedType.success && parsedType.data === "PRIVACY"; + + const t = await getTranslations({ locale, namespace: "Settings" }); + + return { + title: isPrivacy ? t("nav.privacy") : t("nav.policy"), + alternates: { + canonical: `/${locale}/policy`, + }, + }; +} + export default async function PolicyPage({ searchParams }: PolicyPageProps) { const { type } = await searchParams; const parsedType = termsTypeSchema.safeParse(type); diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 7b4ecd84..31a48364 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -1,4 +1,8 @@ { + "Metadata": { + "title": "timo", + "description": "Manage your day with clear timeboxes." + }, "Navigation": { "home": "Home", "today": "Today", @@ -148,6 +152,7 @@ "timerStartFailed": "This to-do has no estimated duration set." }, "Login": { + "title": "Log in", "animationLabel": "Login animation", "headline": "Manage your day with clear timeboxes.", "description": "Please log in to your timo account.", diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 1d2051bf..32c8bf08 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -1,4 +1,8 @@ { + "Metadata": { + "title": "timo", + "description": "흩어진 시간을, 실행 가능한 흐름으로." + }, "Navigation": { "home": "홈", "today": "오늘", @@ -148,6 +152,7 @@ "timerStartFailed": "예상 소요 시간이 설정되지 않은 투두입니다." }, "Login": { + "title": "로그인", "animationLabel": "로그인 애니메이션", "headline": "흩어진 시간을, 실행 가능한 흐름으로.", "description": "timo 계정에 로그인하세요.", diff --git a/apps/timo-web/public/favicon.png b/apps/timo-web/public/favicon.png new file mode 100644 index 00000000..3cda5b79 Binary files /dev/null and b/apps/timo-web/public/favicon.png differ diff --git a/docs/architecture/components.md b/docs/architecture/components.md index 98a066e3..652351e8 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -7,23 +7,35 @@ app/(domain)/page.tsx ← 라우팅·레이아웃 조 ``` - **pages** (`app/.../page.tsx`): 라우팅 단위. 데이터 페칭(React Query) 및 레이아웃만 담당 -- **도메인 종속** (`_components/`, `_containers/`, `_queries/`): 해당 도메인 라우트 내부에서만 사용. `_` 접두사로 Next.js 라우팅에서 제외됨 +- **도메인 종속** (`_components/`, `_containers/`, `_hooks/`, `_queries/`, `_types/`, `_utils/`): 해당 도메인 라우트 내부에서만 사용. `_` 접두사로 Next.js 라우팅에서 제외됨 - `_components/` — props만 받는 순수 UI. `'use client'` 없이도 동작 가능 - `_containers/` — `'use client'` 필수. useQuery·zustand 등 외부 상태와 결합된 클라이언트 컨테이너 + - `_hooks/` — 해당 도메인 전용 커스텀 훅 (React Query가 아닌 로컬 상태·이펙트 로직) - `_queries/` — 해당 도메인 전용 React Query hooks (useQuery · useMutation · queryKey 정의) -- **앱 전역** (`components/`, `hooks/`): 여러 도메인에서 공유하는 presentational 컴포넌트·훅 + - `_types/` — 해당 도메인 전용 타입 + - `_utils/` — 해당 도메인 전용 순수 함수 +- **앱 전역** (`components/`, `containers/`, `hooks/`, `queries/`): 여러 도메인에서 공유하는 presentational 컴포넌트·클라이언트 컨테이너·훅·쿼리 - **@repo/timo-design-system**: 앱 간 공유되는 범용 UI (Button, Card 등) -## 예시 — auth 도메인 +## 예시 — focus 도메인 ```text -app/ - auth/ - _components/ # LoginForm, SocialLoginButton 등 순수 UI - _containers/ # LoginFormContainer (useQuery·zustand 결합) - _queries/ # use-login-mutation, use-user-profile-query 등 - page.tsx # 레이아웃 조합만 (Server Component) - layout.tsx +app/[locale]/(main)/focus/ + _components/ + FocusTaskItem.tsx # 순수 UI + FocusEmptyTaskItem.tsx + _containers/ + FocusHeaderContainer.tsx # 'use client', useQuery·zustand 결합 + FocusSessionContainer.tsx + _hooks/ + use-focus-session.ts # 세션 타이머 등 로컬 로직 + _queries/ + use-focus-todo.ts # useQuery·useMutation 정의 + _types/ + task-type.ts + _utils/ + date.ts + page.tsx # 레이아웃 조합만 (Server Component) ``` -앱 전역 `queries/`는 여러 도메인에서 공유하는 쿼리(예: 유저 프로필)에 사용하고, 특정 도메인에만 쓰이는 쿼리는 `_queries/`로 해당 도메인에 코로케이션한다. +앱 전역 `queries/`는 여러 도메인에서 공유하는 쿼리(예: 유저 프로필 — `queries/use-my-profile.ts`)에 사용하고, 특정 도메인에만 쓰이는 쿼리는 `_queries/`로 해당 도메인에 코로케이션한다. 동일한 원칙이 컨테이너에도 적용되어, 여러 도메인이 공유하는 컨테이너는 앱 전역 `containers/`에 둔다. diff --git a/docs/architecture/stack.md b/docs/architecture/stack.md index 17c28e59..09e310fd 100644 --- a/docs/architecture/stack.md +++ b/docs/architecture/stack.md @@ -1,18 +1,18 @@ # 기술 스택 -| 분류 | 도구 | 비고 | -| ------------ | -------------------------- | ----------------------------- | -| Monorepo | Turborepo + pnpm workspace | 빌드 캐싱, 파이프라인 최적화 | -| Framework | Next.js 16 (App Router) | SSR/SSG, Server Components | -| Language | TypeScript 5.9 | strict 모드 | -| Styling | Tailwind CSS | 유틸리티 클래스 기반 | -| State — 전역 | Zustand | 클라이언트 전역 상태 | -| State — 서버 | TanStack React Query | API 캐싱, 동기화 | -| API Client | swagger-typescript-api | OpenAPI 스펙 → 타입 자동 생성 | -| Monitoring | Sentry | 런타임 에러 및 성능 추적 | -| Formatter | Prettier | 팀 컨벤션 자동 적용 | -| Linter | ESLint 9 (flat config) | `--max-warnings 0` | -| Git Hooks | Husky | 커밋 전 lint/format | -| Code Review | CodeRabbit | PR마다 AI 리뷰 | -| Assets | SVG sprite | SVG → React 컴포넌트 | -| Deployment | Vercel | Turborepo Remote Cache 연동 | +| 분류 | 도구 | 비고 | +| ------------ | --------------------------------- | -------------------------------------------------- | +| Monorepo | Turborepo + pnpm workspace | 빌드 캐싱, 파이프라인 최적화 | +| Framework | Next.js 16 (App Router) | SSR/SSG, Server Components | +| Language | TypeScript 5.9 | strict 모드 | +| Styling | Tailwind CSS | 유틸리티 클래스 기반 | +| State — 전역 | Zustand | 클라이언트 전역 상태 | +| State — 서버 | TanStack React Query | API 캐싱, 동기화 | +| API Client | Orval (axios + react-query + zod) | OpenAPI 스펙 → React Query 훅·Zod 스키마 자동 생성 | +| Monitoring | Sentry | 런타임 에러 및 성능 추적 | +| Formatter | Prettier | 팀 컨벤션 자동 적용 | +| Linter | ESLint 9 (flat config) | `--max-warnings 0` | +| Git Hooks | Husky + lint-staged | 커밋 전 변경 파일만 lint/format | +| Code Review | CodeRabbit | PR마다 AI 리뷰 | +| Assets | SVGR | SVG → React 아이콘 컴포넌트 자동 생성 | +| Deployment | Vercel | Turborepo Remote Cache 연동 | diff --git a/docs/architecture/structure.md b/docs/architecture/structure.md index ebe8d8c6..727f35cb 100644 --- a/docs/architecture/structure.md +++ b/docs/architecture/structure.md @@ -5,20 +5,31 @@ timo-client/ ├── apps/ │ └── timo-web/ # Next.js 앱 (port 3000) │ ├── app/ # App Router 페이지·레이아웃 -│ │ └── (domain)/ # 도메인 라우트 (예: auth, onboarding) -│ │ ├── _components/ # 해당 도메인 전용 컴포넌트 +│ │ └── [locale]/(그룹)/(domain)/ # 도메인 라우트 (예: home, focus, settings, onboarding) +│ │ ├── _components/ # 해당 도메인 전용 순수 UI +│ │ ├── _containers/ # 해당 도메인 전용 클라이언트 컨테이너 ('use client') │ │ ├── _hooks/ # 해당 도메인 전용 커스텀 훅 +│ │ ├── _queries/ # 해당 도메인 전용 React Query 훅 +│ │ ├── _types/ # 해당 도메인 전용 타입 │ │ ├── _utils/ # 해당 도메인 전용 유틸 │ │ └── page.tsx -│ ├── components/ # 앱 전역 공유 컴포넌트 -│ ├── hooks/ # 앱 전역 공유 커스텀 훅 -│ ├── stores/ # Zustand 스토어 -│ ├── queries/ # React Query 훅 -│ └── lib/ # API 클라이언트, 유틸 +│ ├── api/ # Orval 생성 API 클라이언트·타입 + 커스텀 axios 인스턴스 +│ ├── components/ # 앱 전역 공유 컴포넌트 +│ ├── constants/ # 앱 전역 상수 (라우트, 시간 단위 등) +│ ├── containers/ # 여러 도메인에서 공유하는 클라이언트 컨테이너 +│ ├── hooks/ # 앱 전역 공유 커스텀 훅 +│ ├── i18n/ # next-intl 라우팅·요청·네비게이션 설정 +│ ├── messages/ # 로케일별 번역 리소스 +│ ├── providers/ # 전역 Provider (auth, query, overlay, locale 등) +│ ├── queries/ # 여러 도메인에서 공유하는 React Query 훅 +│ ├── stores/ # Zustand 스토어 +│ ├── types/ # 앱 전역 공유 타입 +│ └── utils/ # 앱 전역 공유 유틸 ├── packages/ │ ├── timo-design-system/ # 공유 UI 컴포넌트 (@repo/timo-design-system) -│ ├── eslint-config/ # 공유 ESLint 설정 (@repo/eslint-config) -│ └── typescript-config/ # 공유 tsconfig (@repo/typescript-config) +│ ├── eslint-config/ # 공유 ESLint 설정 (@repo/eslint-config) +│ ├── tailwind-config/ # 공유 Tailwind 설정 (@repo/tailwind-config) +│ └── typescript-config/ # 공유 tsconfig (@repo/typescript-config) └── docs/ ``` @@ -27,4 +38,4 @@ timo-client/ - `apps/timo-web` → `packages/timo-design-system` 참조 가능 - `packages/timo-design-system` → `apps/*` 참조 금지 - 패키지 간 참조는 반드시 `workspace:*`로 선언 -- 앱 내부 도메인 간 직접 import 금지 — 공유 로직은 `lib/` 또는 `packages/`로 추출 +- 앱 내부 도메인 간 직접 import 금지 — 공유 로직은 앱 전역 디렉터리(`components/`, `hooks/`, `queries/`, `utils/`, `api/` 등) 또는 `packages/`로 추출 diff --git a/docs/conventions/commit.md b/docs/conventions/commit.md index 2003e822..3c0ef7ac 100644 --- a/docs/conventions/commit.md +++ b/docs/conventions/commit.md @@ -19,12 +19,12 @@ 변경된 파일 위치에 따라 스코프를 결정한다. -| 스코프 | 기준 | -| -------- | ---------------------------------------------------------------------------- | -| `web` | `apps/timo-web/**` 변경 | -| `ui` | `packages/timo-design-system/**` 변경 | -| `root` | 루트 설정 파일 (`turbo.json`, `pnpm-workspace.yaml`, 루트 `package.json` 등) | -| `config` | `packages/eslint-config/**`, `packages/typescript-config/**` 변경 | +| 스코프 | 기준 | +| -------- | ------------------------------------------------------------------------------------------------ | +| `web` | `apps/timo-web/**` 변경 | +| `ui` | `packages/timo-design-system/**` 변경 | +| `root` | 루트 설정 파일 (`turbo.json`, `pnpm-workspace.yaml`, 루트 `package.json` 등) | +| `config` | `packages/eslint-config/**`, `packages/typescript-config/**`, `packages/tailwind-config/**` 변경 | ## 커밋 형식 diff --git a/docs/design/tokens.md b/docs/design/tokens.md index 30760170..ee37d89c 100644 --- a/docs/design/tokens.md +++ b/docs/design/tokens.md @@ -48,9 +48,10 @@ Tailwind 클래스 사용 예: `bg-timo-blue-50`, `text-timo-gray-900` ### Base -| 토큰 | CSS 변수 | 값 | -| ------------ | -------------------- | --------- | -| `timo-black` | `--color-timo-black` | `#121212` | +| 토큰 | CSS 변수 | 값 | +| -------------- | ---------------------- | ------------------ | +| `timo-black` | `--color-timo-black` | `#121212` | +| `timo-overlay` | `--color-timo-overlay` | `rgb(0 0 0 / 10%)` | ## Typography @@ -94,3 +95,33 @@ Tailwind 클래스 사용 예: `typo-headline-b-24`, `typo-body-r-12` | 토큰 | 클래스 | 크기 | 굵기 | | -------------- | ------------------- | ---- | ---- | | `caption-r-10` | `typo-caption-r-10` | 10px | 400 | + +## Radius + +소스: `packages/tailwind-config/tokens/radius.css` +Tailwind 클래스 사용 예: `rounded-4`, `rounded-8`, `rounded-12` + +| 토큰 | CSS 변수 | 값 | +| ----------- | ------------- | ------ | +| `radius-4` | `--radius-4` | `4px` | +| `radius-8` | `--radius-8` | `8px` | +| `radius-12` | `--radius-12` | `12px` | + +## Shadow + +소스: `packages/tailwind-config/theme.css` +Tailwind 클래스 사용 예: `shadow-timo` + +| 토큰 | CSS 변수 | 값 | +| ------------- | --------------- | ------------------------------------- | +| `shadow-timo` | `--shadow-timo` | `0px 0px 3px 0px rgba(0, 0, 0, 0.12)` | + +## Z-Index + +소스: `packages/tailwind-config/theme.css` +모달 오버레이·패널이 겹칠 때 스택 순서를 고정하기 위한 토큰입니다. + +| 토큰 | CSS 변수 | 값 | +| ----------------------- | ------------------------- | ---- | +| `z-index-modal-overlay` | `--z-index-modal-overlay` | `40` | +| `z-index-modal-panel` | `--z-index-modal-panel` | `50` |