diff --git a/docs/plans/2026-02-21-project-afterlife-implementation.md b/docs/plans/2026-02-21-project-afterlife-implementation.md new file mode 100644 index 0000000..72e198c --- /dev/null +++ b/docs/plans/2026-02-21-project-afterlife-implementation.md @@ -0,0 +1,2844 @@ +# Project Afterlife — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build the Project Afterlife web platform — a multilingual site for game preservation with interactive documentaries, powered by Next.js + Strapi CMS, fully self-hosted. + +**Architecture:** Monorepo with Turborepo containing two apps (Next.js frontend + Strapi 5 CMS) and a shared types package. All services orchestrated via Docker Compose (Nginx, Node apps, PostgreSQL, MinIO). Content managed through Strapi's i18n plugin, consumed by Next.js via REST API. + +**Tech Stack:** Next.js 15 (App Router), TypeScript, Tailwind CSS, Framer Motion, next-intl, Howler.js, Strapi 5, PostgreSQL, MinIO, Docker, Nginx + +**Design doc:** `docs/plans/2026-02-21-project-afterlife-design.md` + +--- + +## Phase 1: Monorepo Scaffold + +### Task 1: Initialize monorepo root + +**Files:** +- Create: `package.json` +- Create: `turbo.json` +- Create: `.gitignore` +- Create: `.nvmrc` + +**Step 1: Create root package.json** + +```json +{ + "name": "project-afterlife", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "turbo dev", + "build": "turbo build", + "lint": "turbo lint", + "clean": "turbo clean" + }, + "devDependencies": { + "turbo": "^2" + }, + "packageManager": "npm@10.8.0" +} +``` + +**Step 2: Create turbo.json** + +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": {}, + "clean": { + "cache": false + } + } +} +``` + +**Step 3: Create .gitignore** + +``` +node_modules/ +.next/ +dist/ +.env +.env.local +.env.*.local +*.log +.turbo/ +.DS_Store +.tmp/ +build/ +``` + +**Step 4: Create .nvmrc** + +``` +20 +``` + +**Step 5: Install turbo** + +Run: `npm install` +Expected: turbo installed, node_modules created, package-lock.json generated + +**Step 6: Commit** + +```bash +git add package.json turbo.json .gitignore .nvmrc package-lock.json +git commit -m "feat: initialize monorepo with Turborepo" +``` + +--- + +### Task 2: Create shared types package + +**Files:** +- Create: `packages/shared/package.json` +- Create: `packages/shared/tsconfig.json` +- Create: `packages/shared/src/types/index.ts` +- Create: `packages/shared/src/types/game.ts` +- Create: `packages/shared/src/types/documentary.ts` +- Create: `packages/shared/src/types/chapter.ts` +- Create: `packages/shared/src/types/api.ts` +- Create: `packages/shared/src/index.ts` + +**Step 1: Create packages/shared/package.json** + +```json +{ + "name": "@afterlife/shared", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5" + } +} +``` + +**Step 2: Create packages/shared/tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} +``` + +**Step 3: Create type files** + +`packages/shared/src/types/game.ts`: +```typescript +export type Genre = "MMORPG" | "FPS" | "Casual" | "Strategy" | "Sports" | "Other"; +export type ServerStatus = "online" | "maintenance" | "coming_soon"; + +export interface Game { + id: number; + title: string; + slug: string; + description: string; + genre: Genre; + releaseYear: number; + shutdownYear: number; + developer: string; + publisher: string; + screenshots: StrapiMedia[]; + coverImage: StrapiMedia; + serverStatus: ServerStatus; + serverLink: string | null; + documentary: Documentary | null; + createdAt: string; + updatedAt: string; + locale: string; +} + +// Forward reference — defined in documentary.ts +import type { Documentary } from "./documentary"; +import type { StrapiMedia } from "./api"; +``` + +`packages/shared/src/types/documentary.ts`: +```typescript +import type { Chapter } from "./chapter"; + +export interface Documentary { + id: number; + title: string; + description: string; + chapters: Chapter[]; + publishedAt: string | null; + createdAt: string; + updatedAt: string; + locale: string; +} +``` + +`packages/shared/src/types/chapter.ts`: +```typescript +import type { StrapiMedia } from "./api"; + +export interface Chapter { + id: number; + title: string; + content: string; // Rich text (HTML/Markdown from Strapi) + audioFile: StrapiMedia | null; + audioDuration: number | null; // seconds + order: number; + coverImage: StrapiMedia | null; + locale: string; +} +``` + +`packages/shared/src/types/api.ts`: +```typescript +export interface StrapiMedia { + id: number; + url: string; + alternativeText: string | null; + width: number | null; + height: number | null; + mime: string; + name: string; +} + +export interface StrapiResponse { + data: T; + meta: { + pagination?: { + page: number; + pageSize: number; + pageCount: number; + total: number; + }; + }; +} + +export interface StrapiListResponse { + data: T[]; + meta: { + pagination: { + page: number; + pageSize: number; + pageCount: number; + total: number; + }; + }; +} +``` + +`packages/shared/src/types/index.ts`: +```typescript +export * from "./game"; +export * from "./documentary"; +export * from "./chapter"; +export * from "./api"; +``` + +`packages/shared/src/index.ts`: +```typescript +export * from "./types"; +``` + +**Step 4: Install shared package dependencies** + +Run: `cd packages/shared && npm install` + +**Step 5: Verify types compile** + +Run: `cd packages/shared && npx tsc --noEmit` +Expected: No errors + +**Step 6: Commit** + +```bash +git add packages/shared/ +git commit -m "feat: add shared TypeScript types for Game, Documentary, Chapter" +``` + +--- + +## Phase 2: Strapi CMS Setup + +### Task 3: Scaffold Strapi 5 app + +**Files:** +- Create: `apps/cms/` (Strapi scaffold) + +**Step 1: Create Strapi project** + +Run: `npx create-strapi@latest apps/cms --quickstart --no-run --typescript` + +> Note: After scaffold, remove the default SQLite config and configure PostgreSQL. + +**Step 2: Update apps/cms/package.json** — set name to `@afterlife/cms` + +Change name field to `"@afterlife/cms"`. + +**Step 3: Configure PostgreSQL database** + +Modify `apps/cms/config/database.ts`: +```typescript +export default ({ env }) => ({ + connection: { + client: "postgres", + connection: { + host: env("DATABASE_HOST", "127.0.0.1"), + port: env.int("DATABASE_PORT", 5432), + database: env("DATABASE_NAME", "afterlife"), + user: env("DATABASE_USERNAME", "afterlife"), + password: env("DATABASE_PASSWORD", "afterlife"), + ssl: env.bool("DATABASE_SSL", false), + }, + }, +}); +``` + +**Step 4: Create apps/cms/.env** + +``` +HOST=0.0.0.0 +PORT=1337 +APP_KEYS=key1,key2,key3,key4 +API_TOKEN_SALT=your-api-token-salt +ADMIN_JWT_SECRET=your-admin-jwt-secret +TRANSFER_TOKEN_SALT=your-transfer-token-salt +JWT_SECRET=your-jwt-secret +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 +DATABASE_NAME=afterlife +DATABASE_USERNAME=afterlife +DATABASE_PASSWORD=afterlife +``` + +**Step 5: Enable i18n plugin** + +Modify `apps/cms/config/plugins.ts`: +```typescript +export default () => ({ + i18n: { + enabled: true, + config: { + defaultLocale: "es", + locales: ["es", "en"], + }, + }, +}); +``` + +**Step 6: Add apps/cms/.env to .gitignore** (already covered by root .gitignore `*.env*`) + +**Step 7: Commit** + +```bash +git add apps/cms/ +git commit -m "feat: scaffold Strapi 5 CMS with PostgreSQL and i18n config" +``` + +--- + +### Task 4: Define Strapi content types + +**Files:** +- Create: `apps/cms/src/api/game/content-types/game/schema.json` +- Create: `apps/cms/src/api/documentary/content-types/documentary/schema.json` +- Create: `apps/cms/src/api/chapter/content-types/chapter/schema.json` + +> Note: Strapi 5 auto-generates routes, controllers, and services from schema.json files. + +**Step 1: Create Game content type** + +Create directory structure: `apps/cms/src/api/game/content-types/game/` + +`apps/cms/src/api/game/content-types/game/schema.json`: +```json +{ + "kind": "collectionType", + "collectionName": "games", + "info": { + "singularName": "game", + "pluralName": "games", + "displayName": "Game", + "description": "A preserved online game" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "attributes": { + "title": { + "type": "string", + "required": true, + "pluginOptions": { + "i18n": { "localized": true } + } + }, + "slug": { + "type": "uid", + "targetField": "title", + "required": true + }, + "description": { + "type": "richtext", + "pluginOptions": { + "i18n": { "localized": true } + } + }, + "genre": { + "type": "enumeration", + "enum": ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"], + "required": true + }, + "releaseYear": { + "type": "integer", + "required": true + }, + "shutdownYear": { + "type": "integer", + "required": true + }, + "developer": { + "type": "string", + "required": true + }, + "publisher": { + "type": "string" + }, + "screenshots": { + "type": "media", + "multiple": true, + "allowedTypes": ["images"] + }, + "coverImage": { + "type": "media", + "multiple": false, + "required": true, + "allowedTypes": ["images"] + }, + "serverStatus": { + "type": "enumeration", + "enum": ["online", "maintenance", "coming_soon"], + "default": "coming_soon", + "required": true + }, + "serverLink": { + "type": "string" + }, + "documentary": { + "type": "relation", + "relation": "oneToOne", + "target": "api::documentary.documentary", + "inversedBy": "game" + } + } +} +``` + +**Step 2: Create Documentary content type** + +Create directory structure: `apps/cms/src/api/documentary/content-types/documentary/` + +`apps/cms/src/api/documentary/content-types/documentary/schema.json`: +```json +{ + "kind": "collectionType", + "collectionName": "documentaries", + "info": { + "singularName": "documentary", + "pluralName": "documentaries", + "displayName": "Documentary", + "description": "Interactive documentary for a game" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "attributes": { + "title": { + "type": "string", + "required": true, + "pluginOptions": { + "i18n": { "localized": true } + } + }, + "description": { + "type": "text", + "pluginOptions": { + "i18n": { "localized": true } + } + }, + "game": { + "type": "relation", + "relation": "oneToOne", + "target": "api::game.game", + "mappedBy": "documentary" + }, + "chapters": { + "type": "relation", + "relation": "oneToMany", + "target": "api::chapter.chapter", + "mappedBy": "documentary" + }, + "publishedAt": { + "type": "datetime" + } + } +} +``` + +**Step 3: Create Chapter content type** + +Create directory structure: `apps/cms/src/api/chapter/content-types/chapter/` + +`apps/cms/src/api/chapter/content-types/chapter/schema.json`: +```json +{ + "kind": "collectionType", + "collectionName": "chapters", + "info": { + "singularName": "chapter", + "pluralName": "chapters", + "displayName": "Chapter", + "description": "A chapter of a documentary" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "attributes": { + "title": { + "type": "string", + "required": true, + "pluginOptions": { + "i18n": { "localized": true } + } + }, + "content": { + "type": "richtext", + "required": true, + "pluginOptions": { + "i18n": { "localized": true } + } + }, + "audioFile": { + "type": "media", + "multiple": false, + "allowedTypes": ["audios"] + }, + "audioDuration": { + "type": "integer" + }, + "order": { + "type": "integer", + "required": true, + "default": 0 + }, + "coverImage": { + "type": "media", + "multiple": false, + "allowedTypes": ["images"] + }, + "documentary": { + "type": "relation", + "relation": "manyToOne", + "target": "api::documentary.documentary", + "inversedBy": "chapters" + } + } +} +``` + +**Step 4: Create route, controller, and service stubs for each API** + +For each of `game`, `documentary`, `chapter`, create: + +`apps/cms/src/api/{name}/routes/{name}.ts`: +```typescript +import { factories } from "@strapi/strapi"; +export default factories.createCoreRouter("api::{name}.{name}"); +``` + +`apps/cms/src/api/{name}/controllers/{name}.ts`: +```typescript +import { factories } from "@strapi/strapi"; +export default factories.createCoreController("api::{name}.{name}"); +``` + +`apps/cms/src/api/{name}/services/{name}.ts`: +```typescript +import { factories } from "@strapi/strapi"; +export default factories.createCoreService("api::{name}.{name}"); +``` + +**Step 5: Commit** + +```bash +git add apps/cms/src/api/ +git commit -m "feat: define Game, Documentary, Chapter content types in Strapi" +``` + +--- + +## Phase 3: Next.js Frontend Setup + +### Task 5: Scaffold Next.js app + +**Files:** +- Create: `apps/web/` (Next.js scaffold) + +**Step 1: Create Next.js project** + +Run: `npx create-next-app@latest apps/web --typescript --tailwind --app --src-dir --eslint --no-import-alias` + +**Step 2: Update apps/web/package.json** — set name to `@afterlife/web` + +**Step 3: Add shared package as dependency** + +Add to `apps/web/package.json` dependencies: +```json +"@afterlife/shared": "*" +``` + +**Step 4: Install dependencies from root** + +Run (from project root): `npm install` + +**Step 5: Verify it builds** + +Run: `cd apps/web && npm run build` +Expected: Build succeeds + +**Step 6: Commit** + +```bash +git add apps/web/ +git commit -m "feat: scaffold Next.js 15 frontend app" +``` + +--- + +### Task 6: Configure i18n with next-intl + +**Files:** +- Modify: `apps/web/package.json` (add next-intl) +- Create: `apps/web/src/i18n/request.ts` +- Create: `apps/web/src/i18n/routing.ts` +- Create: `apps/web/src/messages/es.json` +- Create: `apps/web/src/messages/en.json` +- Create: `apps/web/src/middleware.ts` +- Modify: `apps/web/src/app/layout.tsx` → move to `apps/web/src/app/[locale]/layout.tsx` +- Modify: `apps/web/src/app/page.tsx` → move to `apps/web/src/app/[locale]/page.tsx` + +**Step 1: Install next-intl** + +Run: `cd apps/web && npm install next-intl` + +**Step 2: Create i18n routing config** + +`apps/web/src/i18n/routing.ts`: +```typescript +import { defineRouting } from "next-intl/routing"; + +export const routing = defineRouting({ + locales: ["es", "en"], + defaultLocale: "es", +}); +``` + +**Step 3: Create i18n request config** + +`apps/web/src/i18n/request.ts`: +```typescript +import { getRequestConfig } from "next-intl/server"; +import { routing } from "./routing"; + +export default getRequestConfig(async ({ requestLocale }) => { + let locale = await requestLocale; + if (!locale || !routing.locales.includes(locale as any)) { + locale = routing.defaultLocale; + } + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + }; +}); +``` + +**Step 4: Create message files** + +`apps/web/src/messages/es.json`: +```json +{ + "nav": { + "home": "Inicio", + "catalog": "Catálogo", + "about": "Sobre Nosotros", + "donate": "Donaciones" + }, + "home": { + "hero_title": "Project Afterlife", + "hero_subtitle": "Preservando juegos online que merecen una segunda vida", + "latest_games": "Últimos juegos restaurados", + "view_all": "Ver todos", + "donate_cta": "Apoya la preservación" + }, + "catalog": { + "title": "Catálogo de Juegos", + "filter_genre": "Género", + "filter_status": "Estado del servidor", + "all": "Todos", + "no_results": "No se encontraron juegos" + }, + "game": { + "released": "Lanzado", + "shutdown": "Cerrado", + "developer": "Desarrolladora", + "publisher": "Distribuidora", + "server_status": "Estado del servidor", + "play_now": "Jugar ahora", + "view_documentary": "Ver documental", + "status_online": "En línea", + "status_maintenance": "Mantenimiento", + "status_coming_soon": "Próximamente" + }, + "documentary": { + "chapters": "Capítulos", + "listen": "Escuchar", + "reading_progress": "Progreso de lectura" + }, + "audio": { + "play": "Reproducir", + "pause": "Pausar", + "speed": "Velocidad", + "chapter_mode": "Por capítulo", + "continuous_mode": "Continuo" + }, + "about": { + "title": "Sobre Nosotros", + "mission": "Nuestra Misión", + "team": "El Equipo", + "contribute": "Cómo Contribuir" + }, + "donate": { + "title": "Donaciones", + "description": "Project Afterlife se financia exclusivamente con donaciones. Tu apoyo mantiene vivos estos juegos.", + "patreon": "Donar en Patreon", + "kofi": "Donar en Ko-fi", + "transparency": "Transparencia de fondos" + }, + "footer": { + "rights": "Project Afterlife. Preservando la historia del gaming.", + "language": "Idioma" + } +} +``` + +`apps/web/src/messages/en.json`: +```json +{ + "nav": { + "home": "Home", + "catalog": "Catalog", + "about": "About Us", + "donate": "Donations" + }, + "home": { + "hero_title": "Project Afterlife", + "hero_subtitle": "Preserving online games that deserve a second life", + "latest_games": "Latest restored games", + "view_all": "View all", + "donate_cta": "Support preservation" + }, + "catalog": { + "title": "Game Catalog", + "filter_genre": "Genre", + "filter_status": "Server status", + "all": "All", + "no_results": "No games found" + }, + "game": { + "released": "Released", + "shutdown": "Shutdown", + "developer": "Developer", + "publisher": "Publisher", + "server_status": "Server status", + "play_now": "Play now", + "view_documentary": "View documentary", + "status_online": "Online", + "status_maintenance": "Maintenance", + "status_coming_soon": "Coming soon" + }, + "documentary": { + "chapters": "Chapters", + "listen": "Listen", + "reading_progress": "Reading progress" + }, + "audio": { + "play": "Play", + "pause": "Pause", + "speed": "Speed", + "chapter_mode": "By chapter", + "continuous_mode": "Continuous" + }, + "about": { + "title": "About Us", + "mission": "Our Mission", + "team": "The Team", + "contribute": "How to Contribute" + }, + "donate": { + "title": "Donations", + "description": "Project Afterlife is funded exclusively by donations. Your support keeps these games alive.", + "patreon": "Donate on Patreon", + "kofi": "Donate on Ko-fi", + "transparency": "Fund transparency" + }, + "footer": { + "rights": "Project Afterlife. Preserving gaming history.", + "language": "Language" + } +} +``` + +**Step 5: Create middleware for locale routing** + +`apps/web/src/middleware.ts`: +```typescript +import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; + +export default createMiddleware(routing); + +export const config = { + matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], +}; +``` + +**Step 6: Move layout and page into [locale] directory** + +Move `apps/web/src/app/layout.tsx` → `apps/web/src/app/[locale]/layout.tsx` +Move `apps/web/src/app/page.tsx` → `apps/web/src/app/[locale]/page.tsx` + +Update `apps/web/src/app/[locale]/layout.tsx`: +```typescript +import type { Metadata } from "next"; +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { routing } from "@/i18n/routing"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Project Afterlife", + description: "Preserving online games that deserve a second life", +}; + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + if (!routing.locales.includes(locale as any)) { + notFound(); + } + const messages = await getMessages(); + + return ( + + + + {children} + + + + ); +} +``` + +Also move `globals.css` to `apps/web/src/app/[locale]/globals.css`. + +**Step 7: Update next.config** + +Modify `apps/web/next.config.ts`: +```typescript +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + +const nextConfig = {}; + +export default withNextIntl(nextConfig); +``` + +**Step 8: Verify it builds** + +Run: `cd apps/web && npm run build` +Expected: Build succeeds with locale routing + +**Step 9: Commit** + +```bash +git add apps/web/ +git commit -m "feat: configure next-intl i18n with ES/EN locales" +``` + +--- + +### Task 7: Create Strapi API client + +**Files:** +- Create: `apps/web/src/lib/strapi.ts` +- Create: `apps/web/src/lib/api.ts` +- Create: `apps/web/.env.local` + +**Step 1: Create .env.local** + +`apps/web/.env.local`: +``` +STRAPI_URL=http://localhost:1337 +STRAPI_API_TOKEN=your-api-token-here +NEXT_PUBLIC_STRAPI_URL=http://localhost:1337 +``` + +**Step 2: Create base Strapi client** + +`apps/web/src/lib/strapi.ts`: +```typescript +const STRAPI_URL = process.env.STRAPI_URL || "http://localhost:1337"; +const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN; + +interface FetchOptions { + path: string; + params?: Record; + locale?: string; +} + +export async function strapiGet({ path, params, locale }: FetchOptions): Promise { + const url = new URL(`/api${path}`, STRAPI_URL); + + if (locale) url.searchParams.set("locale", locale); + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + const headers: HeadersInit = { "Content-Type": "application/json" }; + if (STRAPI_TOKEN) { + headers.Authorization = `Bearer ${STRAPI_TOKEN}`; + } + + const res = await fetch(url.toString(), { + headers, + next: { revalidate: 60 }, + }); + + if (!res.ok) { + throw new Error(`Strapi error: ${res.status} ${res.statusText}`); + } + + return res.json(); +} +``` + +**Step 3: Create API functions** + +`apps/web/src/lib/api.ts`: +```typescript +import type { + Game, + Documentary, + Chapter, + StrapiListResponse, + StrapiResponse, +} from "@afterlife/shared"; +import { strapiGet } from "./strapi"; + +export async function getGames(locale: string): Promise> { + return strapiGet({ + path: "/games", + locale, + params: { + "populate[coverImage]": "*", + "populate[documentary]": "*", + "sort": "createdAt:desc", + }, + }); +} + +export async function getGameBySlug(slug: string, locale: string): Promise> { + return strapiGet({ + path: `/games`, + locale, + params: { + "filters[slug][$eq]": slug, + "populate[coverImage]": "*", + "populate[screenshots]": "*", + "populate[documentary][populate][chapters][populate]": "*", + }, + }); +} + +export async function getDocumentaryByGameSlug( + slug: string, + locale: string +): Promise { + const gameRes = await getGameBySlug(slug, locale); + const game = Array.isArray(gameRes.data) ? gameRes.data[0] : gameRes.data; + return game?.documentary ?? null; +} + +export async function getChapter( + chapterId: number, + locale: string +): Promise> { + return strapiGet({ + path: `/chapters/${chapterId}`, + locale, + params: { + "populate[audioFile]": "*", + "populate[coverImage]": "*", + }, + }); +} +``` + +**Step 4: Commit** + +```bash +git add apps/web/src/lib/ apps/web/.env.local +git commit -m "feat: add Strapi API client and data fetching functions" +``` + +--- + +## Phase 4: Frontend Pages + +### Task 8: Create shared layout components (Navbar + Footer) + +**Files:** +- Create: `apps/web/src/components/layout/Navbar.tsx` +- Create: `apps/web/src/components/layout/Footer.tsx` +- Create: `apps/web/src/components/layout/LanguageSwitcher.tsx` +- Modify: `apps/web/src/app/[locale]/layout.tsx` + +**Step 1: Install framer-motion** + +Run: `cd apps/web && npm install framer-motion` + +**Step 2: Create LanguageSwitcher component** + +`apps/web/src/components/layout/LanguageSwitcher.tsx`: +```tsx +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { useRouter, usePathname } from "next/navigation"; + +export function LanguageSwitcher() { + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + const t = useTranslations("footer"); + + function switchLocale(newLocale: string) { + const segments = pathname.split("/"); + segments[1] = newLocale; + router.push(segments.join("/")); + } + + return ( +
+ {t("language")}: + + | + +
+ ); +} +``` + +**Step 3: Create Navbar** + +`apps/web/src/components/layout/Navbar.tsx`: +```tsx +"use client"; + +import Link from "next/link"; +import { useLocale, useTranslations } from "next-intl"; +import { LanguageSwitcher } from "./LanguageSwitcher"; + +export function Navbar() { + const t = useTranslations("nav"); + const locale = useLocale(); + + const links = [ + { href: `/${locale}`, label: t("home") }, + { href: `/${locale}/catalog`, label: t("catalog") }, + { href: `/${locale}/about`, label: t("about") }, + { href: `/${locale}/donate`, label: t("donate") }, + ]; + + return ( + + ); +} +``` + +**Step 4: Create Footer** + +`apps/web/src/components/layout/Footer.tsx`: +```tsx +import { useTranslations } from "next-intl"; + +export function Footer() { + const t = useTranslations("footer"); + + return ( +
+
+

{t("rights")}

+
+
+ ); +} +``` + +**Step 5: Update layout to include Navbar and Footer** + +Update `apps/web/src/app/[locale]/layout.tsx` body to include: +```tsx + + + +
{children}
+