diff --git a/README.md b/README.md index c1fd3e5..f36e4cb 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A modern sports-fashion e-commerce platform. -**Status: milestone 0 — architecture skeleton.** The structure, boundaries, data model and -tooling are in place and verified. Business features are not implemented yet; see -[Roadmap](#roadmap). +**Status: milestone 1 — catalog read API, live end to end, in Vietnamese and English.** +The storefront renders real products from the database through the REST API. Cart, checkout, +orders and auth are still ahead; see [Roadmap](#roadmap). ``` Storefront (Next.js) ─┐ @@ -116,10 +116,12 @@ sport-store/ ├── apps/ │ ├── storefront/ Next.js customer site (:3000) │ │ └── src/ -│ │ ├── app/ App Router — (shop) (checkout) (account) route groups +│ │ ├── app/[locale]/ (shop) (checkout) (account) route groups │ │ ├── components/ Cross-feature UI (layout, chrome) │ │ ├── features/ auth · product · category · collection · search │ │ │ cart · checkout · order · wishlist · account +│ │ ├── i18n/ next-intl routing, request config, navigation +│ │ ├── messages/ vi.json · en.json (UI strings) │ │ ├── hooks/ lib/ services/ stores/ styles/ types/ │ │ │ ├── admin/ Next.js back office (:3001) @@ -151,7 +153,7 @@ sport-store/ │ ├── docs/ │ ├── architecture.md Boundaries, conventions, risks — read this first -│ └── adr/ 12 decision records +│ └── adr/ 14 decision records │ ├── docker-compose.yml Backing services; `--profile full` runs everything ├── turbo.json pnpm-workspace.yaml package.json @@ -187,6 +189,28 @@ Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that or a domain event — enforced by ESLint, which is what keeps a future service extraction possible. ([ADR-0002](./docs/adr/0002-modular-monolith-not-microservices.md)) +8. **Two languages, two mechanisms.** UI strings live in message catalogs; product content lives + in database translation tables with per-locale slugs and field-level fallback. Conflating them + is why most "add a language" projects end up half-translated. + ([ADR-0013](./docs/adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md)) + +### Languages + +Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`. + +| | Vietnamese | English | +| ------- | --------------------------- | --------------------------- | +| Listing | `/men` | `/en/men` | +| Product | `/products/ao-chay-bo-aero` | `/en/products/aero-run-tee` | + +Product slugs are translated too, so each language has its own indexable URL. Every product +page emits `hreflang` alternates and a canonical link, and requesting a product by the _other_ +locale's slug redirects to the canonical one — which is what keeps the language switcher on a +product page from 404ing. + +The admin switches language by cookie with no URL segment: it is `noindex` everywhere, so +locale-in-path would buy nothing. + ### Storefront routes `/` · `/men` · `/women` · `/sports/[running|football|training|gym|badminton|lifestyle]` · @@ -203,24 +227,23 @@ Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that ## Roadmap -| Milestone | Scope | -| --------- | ------------------------------------------------------------------------------------- | -| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | -| **M1** | Catalog read API: products, variants, categories, collections, brands + Redis caching | -| **M2** | Auth: login, refresh rotation, RBAC admin, user/role management | -| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory | -| **M4** | Storefront catalog: listings, PDP with variant selector, filters | -| **M5** | Cart, checkout, orders | -| **M6** | Search + faceting | -| **M7** | Promotions, coupons, reviews, CMS | -| **M8** | Customer account | -| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | +| Milestone | Scope | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | +| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data | +| **M2** | Auth: login, refresh rotation, RBAC admin, user/role management | +| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory | +| **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain | +| **M5** | Cart, checkout, orders | +| **M6** | Search + faceting | +| **M7** | Promotions, coupons, reviews, CMS | +| **M8** | Customer account | +| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | -**Recommended next step: M1.** It exercises every layer end to end — Prisma repository → service -→ controller → envelope → `@sport/api-client` → a rendered page — on read-only endpoints where -mistakes are cheap. It also proves the variant model against real data before anything writes to -it. Auth (M2) comes second because the enforcement half already exists; only issuance is -missing. +**Recommended next step: M2 (auth).** The enforcement half already exists — global access-token +guard, RBAC permissions guard, audience separation — so only issuance is missing: login, refresh +rotation, and the admin user/role screens. Everything after it (cart ownership, orders, the admin +write path) depends on knowing who is asking. --- @@ -228,24 +251,24 @@ missing. Everything below was run, not assumed: -- `pnpm install` — 10 workspace projects resolved -- `pnpm lint` · `pnpm typecheck` · `pnpm build` — 24/24 Turborepo tasks pass -- `pnpm format:check` — clean -- `prisma migrate dev` — 25 tables created -- `pnpm db:seed` — 36 permissions, 6 roles -- API boots; `GET /api/v1/health` returns `status: ok` with PostgreSQL and Redis both `up` -- Error envelope confirmed on a 404; `x-request-id` echoed; Helmet, CORS and rate-limit headers - present; Swagger served at `/docs` -- `pnpm test` — 5 passing RBAC guard tests -- Storefront renders 20 routes (`/sports/curling` correctly 404s); admin renders 17 +- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 25/25 Turborepo tasks pass; + `pnpm format:check` clean +- 5 migrations applied; 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, + 3 collections, 12 products, **155 variants** and 64 generated images uploaded to MinIO +- `pnpm test` — 17 passing (RBAC guards, translation fallback, `Accept-Language` negotiation) +- API: listings with filters/facets/cursor paging, PDP, navigation, brands and collections all + return correctly localised payloads in both `vi` and `en` +- Storefront: every route returns 200 in both locales; PDP renders translated options, spec + table and variant titles; `/en/products/` → 307 → `/en/products/`; + `hreflang` + canonical emitted per locale +- Money formats per locale from one integer: `690.000 ₫` (vi) / `₫690,000` (en) +- Admin: renders Vietnamese by default and English with `sport_admin_locale=en` Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are auto-converted correctly, and routing is verified working. Nothing in this repository registers that path. ---- - ## Contributing - Branches: `feat/…`, `fix/…`, `chore/…`, `docs/…` diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts index 6ff3d9a..6bf42d3 100644 --- a/apps/admin/next.config.ts +++ b/apps/admin/next.config.ts @@ -1,5 +1,9 @@ +import createNextIntlPlugin from 'next-intl/plugin'; import type { NextConfig } from 'next'; +/** Registers the cookie-based locale resolver in src/i18n/request.ts. */ +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); + const nextConfig: NextConfig = { reactStrictMode: true, transpilePackages: ['@sport/ui'], @@ -28,4 +32,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/apps/admin/package.json b/apps/admin/package.json index fa1836d..006ea45 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -18,6 +18,7 @@ "@sport/validation": "workspace:*", "@tanstack/react-query": "^5.101.4", "next": "catalog:", + "next-intl": "^4.13.6", "react": "catalog:", "react-dom": "catalog:", "zod": "catalog:", diff --git a/apps/admin/src/app/(auth)/login/page.tsx b/apps/admin/src/app/(auth)/login/page.tsx index 70e1eae..7d2c12d 100644 --- a/apps/admin/src/app/(auth)/login/page.tsx +++ b/apps/admin/src/app/(auth)/login/page.tsx @@ -1,23 +1,30 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; -export const metadata: Metadata = { title: 'Sign in' }; +import { LanguageSwitcher } from '@/components/language-switcher'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('common.login'); + return { title: t('title') }; +} /** * Sits outside the dashboard route group so it renders without the sidebar and * without the auth requirement. */ -export default function LoginPage() { +export default async function LoginPage() { + const t = await getTranslations('common'); + return (
-

- Sport. Admin -

-

- Sign-in lands with the auth milestone (M2). Credentials will be exchanged for a - short-lived access token plus a rotating, httpOnly refresh cookie scoped to the admin - audience. -

+
+

+ Sport. {t('appName')} +

+ +
+

{t('login.body')}

); diff --git a/apps/admin/src/app/(dashboard)/brands/page.tsx b/apps/admin/src/app/(dashboard)/brands/page.tsx index 4d63de0..d07119d 100644 --- a/apps/admin/src/app/(dashboard)/brands/page.tsx +++ b/apps/admin/src/app/(dashboard)/brands/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Brands' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.brands'); + return { title: t('title') }; +} + +export default async function BrandsPage() { + const t = await getTranslations('pages.brands'); -export default function BrandsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/categories/page.tsx b/apps/admin/src/app/(dashboard)/categories/page.tsx index 3e6cd4b..977e870 100644 --- a/apps/admin/src/app/(dashboard)/categories/page.tsx +++ b/apps/admin/src/app/(dashboard)/categories/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Categories' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.categories'); + return { title: t('title') }; +} + +export default async function CategoriesPage() { + const t = await getTranslations('pages.categories'); -export default function CategoriesPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/cms/page.tsx b/apps/admin/src/app/(dashboard)/cms/page.tsx index 39b7210..6a3f9d4 100644 --- a/apps/admin/src/app/(dashboard)/cms/page.tsx +++ b/apps/admin/src/app/(dashboard)/cms/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Content' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.cms'); + return { title: t('title') }; +} + +export default async function CmsPage() { + const t = await getTranslations('pages.cms'); -export default function CmsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/collections/page.tsx b/apps/admin/src/app/(dashboard)/collections/page.tsx index 31b8be2..bf1e359 100644 --- a/apps/admin/src/app/(dashboard)/collections/page.tsx +++ b/apps/admin/src/app/(dashboard)/collections/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Collections' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.collections'); + return { title: t('title') }; +} + +export default async function CollectionsPage() { + const t = await getTranslations('pages.collections'); -export default function CollectionsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/coupons/page.tsx b/apps/admin/src/app/(dashboard)/coupons/page.tsx index c4db0cf..8c290f1 100644 --- a/apps/admin/src/app/(dashboard)/coupons/page.tsx +++ b/apps/admin/src/app/(dashboard)/coupons/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Coupons' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.coupons'); + return { title: t('title') }; +} + +export default async function CouponsPage() { + const t = await getTranslations('pages.coupons'); -export default function CouponsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/customers/page.tsx b/apps/admin/src/app/(dashboard)/customers/page.tsx index b20bde6..701f752 100644 --- a/apps/admin/src/app/(dashboard)/customers/page.tsx +++ b/apps/admin/src/app/(dashboard)/customers/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Customers' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.customers'); + return { title: t('title') }; +} + +export default async function CustomersPage() { + const t = await getTranslations('pages.customers'); -export default function CustomersPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/inventory/page.tsx b/apps/admin/src/app/(dashboard)/inventory/page.tsx index 25bdb9c..41d08ee 100644 --- a/apps/admin/src/app/(dashboard)/inventory/page.tsx +++ b/apps/admin/src/app/(dashboard)/inventory/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Inventory' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.inventory'); + return { title: t('title') }; +} + +export default async function InventoryPage() { + const t = await getTranslations('pages.inventory'); -export default function InventoryPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index fcaffe0..4d4cb4c 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -1,18 +1,24 @@ +import { getTranslations } from 'next-intl/server'; + +import { LanguageSwitcher } from '@/components/language-switcher'; import { AdminSidebar } from '@/components/layout/admin-sidebar'; /** * Every route in this group requires an authenticated back-office actor. - * Enforcement is layered: middleware checks for a session cookie, this layout + * Enforcement is layered: the proxy checks for a session cookie, this layout * verifies the token server-side, and the API re-checks permissions on every * request. Only the last one is real security; the first two are UX. */ -export default function DashboardLayout({ children }: { children: React.ReactNode }) { +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const t = await getTranslations('common'); + return (
-
- Signed out +
+ + {t('signedOut')}
{children}
diff --git a/apps/admin/src/app/(dashboard)/media/page.tsx b/apps/admin/src/app/(dashboard)/media/page.tsx index 3f68501..fcbc883 100644 --- a/apps/admin/src/app/(dashboard)/media/page.tsx +++ b/apps/admin/src/app/(dashboard)/media/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Media' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.media'); + return { title: t('title') }; +} + +export default async function MediaPage() { + const t = await getTranslations('pages.media'); -export default function MediaPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/orders/page.tsx b/apps/admin/src/app/(dashboard)/orders/page.tsx index b9797e6..9514dc6 100644 --- a/apps/admin/src/app/(dashboard)/orders/page.tsx +++ b/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Orders' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.orders'); + return { title: t('title') }; +} + +export default async function OrdersPage() { + const t = await getTranslations('pages.orders'); -export default function OrdersPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/page.tsx b/apps/admin/src/app/(dashboard)/page.tsx index 9c06b40..dbd0421 100644 --- a/apps/admin/src/app/(dashboard)/page.tsx +++ b/apps/admin/src/app/(dashboard)/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Dashboard' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.dashboard'); + return { title: t('title') }; +} + +export default async function DashboardPage() { + const t = await getTranslations('pages.dashboard'); -export default function DashboardPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/products/page.tsx b/apps/admin/src/app/(dashboard)/products/page.tsx index 939647d..c319caa 100644 --- a/apps/admin/src/app/(dashboard)/products/page.tsx +++ b/apps/admin/src/app/(dashboard)/products/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Products' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.products'); + return { title: t('title') }; +} + +export default async function ProductsPage() { + const t = await getTranslations('pages.products'); -export default function ProductsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/promotions/page.tsx b/apps/admin/src/app/(dashboard)/promotions/page.tsx index babcf0d..cd985b0 100644 --- a/apps/admin/src/app/(dashboard)/promotions/page.tsx +++ b/apps/admin/src/app/(dashboard)/promotions/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Promotions' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.promotions'); + return { title: t('title') }; +} + +export default async function PromotionsPage() { + const t = await getTranslations('pages.promotions'); -export default function PromotionsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/reviews/page.tsx b/apps/admin/src/app/(dashboard)/reviews/page.tsx index 2a7e1de..d5b606f 100644 --- a/apps/admin/src/app/(dashboard)/reviews/page.tsx +++ b/apps/admin/src/app/(dashboard)/reviews/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Reviews' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.reviews'); + return { title: t('title') }; +} + +export default async function ReviewsPage() { + const t = await getTranslations('pages.reviews'); -export default function ReviewsPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/settings/roles/page.tsx b/apps/admin/src/app/(dashboard)/settings/roles/page.tsx index 43b7149..fa1b383 100644 --- a/apps/admin/src/app/(dashboard)/settings/roles/page.tsx +++ b/apps/admin/src/app/(dashboard)/settings/roles/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Roles' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.roles'); + return { title: t('title') }; +} + +export default async function RolesPage() { + const t = await getTranslations('pages.roles'); -export default function RolesPage() { return ( diff --git a/apps/admin/src/app/(dashboard)/settings/users/page.tsx b/apps/admin/src/app/(dashboard)/settings/users/page.tsx index 7709efd..88e7c2d 100644 --- a/apps/admin/src/app/(dashboard)/settings/users/page.tsx +++ b/apps/admin/src/app/(dashboard)/settings/users/page.tsx @@ -1,14 +1,20 @@ import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { PageScaffold } from '@/components/layout/page-scaffold'; -export const metadata: Metadata = { title: 'Users' }; +export async function generateMetadata(): Promise { + const t = await getTranslations('pages.users'); + return { title: t('title') }; +} + +export default async function UsersPage() { + const t = await getTranslations('pages.users'); -export default function UsersPage() { return ( diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 172ba35..e3623c4 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,17 +1,34 @@ import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getTranslations } from 'next-intl/server'; + +import { LOCALE_TAGS } from '@sport/types'; import '@/styles/globals.css'; -export const metadata: Metadata = { - title: { default: 'Sport Store Admin', template: '%s | Sport Admin' }, - description: 'Back-office dashboard.', - robots: { index: false, follow: false }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations('common'); + + return { + title: { default: t('appName'), template: `%s | ${t('appName')}` }, + // The admin must never be indexed: it is an internal tool, and its URLs + // leak the shape of the back office even when they 401. + robots: { index: false, follow: false }, + }; +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + // Locale comes from a cookie rather than the URL — see src/i18n/request.ts. + const locale = await getLocale(); -export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} + + + {children} + ); } diff --git a/apps/admin/src/components/language-switcher.tsx b/apps/admin/src/components/language-switcher.tsx new file mode 100644 index 0000000..0904753 --- /dev/null +++ b/apps/admin/src/components/language-switcher.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useTransition } from 'react'; + +import { LOCALE_LABELS, LOCALES, type Locale } from '@sport/types'; +import { cn } from '@sport/ui'; + +import { setLocale } from '@/i18n/actions'; + +export function LanguageSwitcher({ className }: { className?: string }) { + const t = useTranslations('common'); + const activeLocale = useLocale() as Locale; + const [isPending, startTransition] = useTransition(); + + return ( +
+ {LOCALES.map((locale) => ( + + ))} +
+ ); +} diff --git a/apps/admin/src/components/layout/admin-sidebar.tsx b/apps/admin/src/components/layout/admin-sidebar.tsx index 2145d1d..5222752 100644 --- a/apps/admin/src/components/layout/admin-sidebar.tsx +++ b/apps/admin/src/components/layout/admin-sidebar.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; import { NAVIGATION } from '@/lib/navigation'; @@ -7,20 +8,23 @@ import { NAVIGATION } from '@/lib/navigation'; * item is filtered with `hasPermission(actor.permissions, item.permission)` — * the same catalog the API guards read, so menu and enforcement cannot drift. */ -export function AdminSidebar() { +export async function AdminSidebar() { + const t = await getTranslations('common'); + const tPages = await getTranslations('pages'); + return (