From 64fa49dad783f0010152d88e7decd5d1f1fb5d77 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 15 Jul 2026 13:29:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(web):=20=ED=8C=8C=EB=B9=84=EC=BD=98=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=BC=80=EC=9D=BC=EB=B3=84=20SEO=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=ED=95=9C=EB=8B=A4=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - public/favicon.png를 next.js metadata.icons에 연결해 브라우저 탭 파비콘을 표시했습니다. - 정적 title/description을 generateMetadata로 전환해 로케일(en/ko)별 텍스트가 반영되도록 했습니다. - metadataBase, Open Graph, Twitter Card, hreflang(alternates), robots, formatDetection 등 SEO 메타데이터를 추가했습니다. - messages/en.json, messages/ko.json에 Metadata 네임스페이스를 추가했습니다. --- apps/timo-web/app/[locale]/layout.tsx | 67 ++++++++++++++++++++++++-- apps/timo-web/messages/en.json | 4 ++ apps/timo-web/messages/ko.json | 4 ++ apps/timo-web/public/favicon.png | Bin 0 -> 28513 bytes 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 apps/timo-web/public/favicon.png diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index dfd9f6da..18af4e77 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,63 @@ 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", + }, + alternates: { + canonical: `/${locale}`, + languages: { + en: "/en", + ko: "/ko", + "x-default": `/${routing.defaultLocale}`, + }, + }, + openGraph: { + type: "website", + url: `/${locale}`, + 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/messages/en.json b/apps/timo-web/messages/en.json index 7b4ecd84..e8851411 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", diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 1d2051bf..f3bc12bd 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": "오늘", diff --git a/apps/timo-web/public/favicon.png b/apps/timo-web/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..3cda5b79a434511b7fc7e8f2f2b9f8b3605739b3 GIT binary patch literal 28513 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 Date: Wed, 15 Jul 2026 13:29:55 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs(root):=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=C2=B7=EC=BB=A8=EB=B2=A4=EC=85=98=C2=B7=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=ED=86=A0=ED=81=B0=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EA=B5=AC=ED=98=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 클라이언트가 실제로는 swagger-typescript-api가 아닌 Orval 기반임을 반영해 stack.md를 수정했습니다. - Git Hooks를 Husky 단독이 아닌 Husky + lint-staged 조합으로 정정했습니다. - SVG 아이콘 생성 방식을 SVG sprite에서 실제 사용 중인 SVGR로 수정했습니다. - structure.md의 모노레포 트리에서 존재하지 않는 lib/를 제거하고 api/, containers/ 등 실제 디렉터리를 반영했습니다. - components.md의 예시 도메인을 존재하지 않는 auth에서 실제 존재하는 focus 도메인으로 교체했습니다. - commit.md의 config 스코프에 누락된 tailwind-config 패키지를 추가했습니다. - tokens.md에 실제 CSS에는 있지만 문서화되지 않았던 Radius, Shadow, Z-Index, overlay 색상 토큰을 추가했습니다. --- docs/architecture/components.md | 34 ++++++++++++++++++++---------- docs/architecture/stack.md | 32 ++++++++++++++-------------- docs/architecture/structure.md | 31 ++++++++++++++++++--------- docs/conventions/commit.md | 12 +++++------ docs/design/tokens.md | 37 ++++++++++++++++++++++++++++++--- 5 files changed, 100 insertions(+), 46 deletions(-) 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` | From 2b9cc889e2eea0e70b9e228fb681b55db153e634 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 15 Jul 2026 14:03:26 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(web):=20=EB=A3=A8=ED=8A=B8=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=A2=85=EC=86=8D=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=9C=EB=8B=A4=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/[locale]/layout.tsx는 locale 세그먼트까지의 params만 받아 하위 경로를 알 수 없는데도, alternates.canonical/languages와 openGraph.url을 항상 locale 루트 경로로 고정해 모든 하위 라우트가 홈 URL을 상속했습니다. - 경로 종속 필드를 제거하고, locale에만 의존하는 openGraph 필드(type, siteName, title, description, locale, alternateLocale)는 유지했습니다. --- apps/timo-web/app/[locale]/layout.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index 18af4e77..8f27108d 100644 --- a/apps/timo-web/app/[locale]/layout.tsx +++ b/apps/timo-web/app/[locale]/layout.tsx @@ -58,17 +58,8 @@ export async function generateMetadata({ icons: { icon: "/favicon.png", }, - alternates: { - canonical: `/${locale}`, - languages: { - en: "/en", - ko: "/ko", - "x-default": `/${routing.defaultLocale}`, - }, - }, openGraph: { type: "website", - url: `/${locale}`, siteName: title, title, description, From 61acec5ec81a2f128633d9e49f3be72b6973c1e9 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 15 Jul 2026 14:04:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(web):=20=EC=9D=B8=EC=A6=9D=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EA=B7=B8=EB=A3=B9=EC=9D=84=20=EB=B9=84?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EC=B2=98=EB=A6=AC=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7=EC=95=BD=EA=B4=80=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (main) 라우트 그룹은 AuthGuardProvider로 로그인이 필요한 개인화 화면이라 robots.index/follow를 false로 설정해 검색 엔진 노출에서 제외했습니다. - 로그인 페이지에 generateMetadata를 추가해 로케일별 title/description과 경로 고유 canonical(/{locale}/login)을 설정했습니다. messages의 Login 네임스페이스에 title 키를 새로 추가했습니다. - 약관 페이지에 generateMetadata를 추가해 type 쿼리 파라미터(SERVICE/PRIVACY)에 따라 기존 Settings.nav.policy/privacy 문자열을 재사용한 제목과 경로 고유 canonical(/{locale}/policy)을 설정했습니다. --- apps/timo-web/app/[locale]/(main)/layout.tsx | 9 ++++++ apps/timo-web/app/[locale]/login/page.tsx | 31 ++++++++++++++++++++ apps/timo-web/app/[locale]/policy/page.tsx | 31 ++++++++++++++++++++ apps/timo-web/messages/en.json | 1 + apps/timo-web/messages/ko.json | 1 + 5 files changed, 73 insertions(+) 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]/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 e8851411..31a48364 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -152,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 f3bc12bd..32c8bf08 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -152,6 +152,7 @@ "timerStartFailed": "예상 소요 시간이 설정되지 않은 투두입니다." }, "Login": { + "title": "로그인", "animationLabel": "로그인 애니메이션", "headline": "흩어진 시간을, 실행 가능한 흐름으로.", "description": "timo 계정에 로그인하세요.",