Stage M1
This commit is contained in:
@@ -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]` ·
|
||||
@@ -204,23 +228,22 @@ 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 |
|
||||
| **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 with variant selector, filters |
|
||||
| **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/<vi-slug>` → 307 → `/en/products/<en-slug>`;
|
||||
`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/…`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<div className="bg-ink-50 flex min-h-screen items-center justify-center px-6">
|
||||
<div className="border-ink-200 w-full max-w-sm border bg-white p-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<h1 className="text-lg font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> Admin
|
||||
Sport<span className="text-volt-600">.</span> {t('appName')}
|
||||
</h1>
|
||||
<p className="text-ink-500 mt-4 text-sm">
|
||||
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.
|
||||
</p>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<p className="text-ink-500 mt-4 text-sm">{t('login.body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Brands"
|
||||
description="Brand records, logos and SEO fields."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="brand.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Categories"
|
||||
description="Drag-to-reorder category tree with materialised-path maintenance."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="category.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Content"
|
||||
description="Homepage blocks, banners, blog posts and static pages."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="cms.read"
|
||||
milestone="M7 — content"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Collections"
|
||||
description="Manual and rule-based collections, with campaign scheduling."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="collection.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Coupons"
|
||||
description="Coupon codes, usage limits and redemption reporting."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="coupon.manage"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Customers"
|
||||
description="Customer records, order history and addresses."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="customer.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Inventory"
|
||||
description="Stock by variant and location, adjustments and the movement ledger."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="inventory.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen">
|
||||
<AdminSidebar />
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="border-ink-200 flex h-14 items-center justify-end border-b bg-white px-6">
|
||||
<span className="text-ink-500 text-xs font-medium">Signed out</span>
|
||||
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6">
|
||||
<LanguageSwitcher />
|
||||
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Media"
|
||||
description="Asset library. Uploads go browser → presigned URL → R2; the API only records metadata."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="media.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Orders"
|
||||
description="Order list and detail: fulfilment, status transitions, refunds and the audit trail."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="order.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Dashboard"
|
||||
description="Revenue, orders, conversion and low-stock alerts."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="order.read"
|
||||
milestone="M9 — admin analytics"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Products"
|
||||
description="Product list with status, brand and variant count. The editor manages options, generates the variant matrix and edits per-variant SKU, price and stock."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="product.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Promotions"
|
||||
description="Automatic cart-level discount rules."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="promotion.manage"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Reviews"
|
||||
description="Review moderation queue."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="review.moderate"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Roles"
|
||||
description="Role editor: a role is a named set of permissions, editable at runtime with no deploy."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="role.read"
|
||||
milestone="M2 — auth & RBAC"
|
||||
/>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 (
|
||||
<PageScaffold
|
||||
title="Users"
|
||||
description="Back-office accounts and role assignment."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="user.read"
|
||||
milestone="M2 — auth & RBAC"
|
||||
/>
|
||||
|
||||
@@ -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.',
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
<html
|
||||
lang={LOCALE_TAGS[locale as keyof typeof LOCALE_TAGS] ?? 'vi-VN'}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-screen antialiased">
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn('flex items-center gap-1', isPending && 'opacity-60', className)}
|
||||
role="group"
|
||||
aria-label={t('changeLanguage')}
|
||||
>
|
||||
{LOCALES.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={locale === activeLocale}
|
||||
onClick={() => startTransition(() => void setLocale(locale))}
|
||||
aria-current={locale === activeLocale ? 'true' : undefined}
|
||||
lang={locale}
|
||||
title={LOCALE_LABELS[locale]}
|
||||
className={cn(
|
||||
'rounded-card px-1.5 py-1 text-xs font-semibold uppercase tracking-widest transition-colors',
|
||||
locale === activeLocale ? 'bg-ink-100 text-ink-950' : 'text-ink-400 hover:text-ink-950',
|
||||
)}
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block">
|
||||
<div className="border-ink-200 flex h-14 items-center border-b px-5">
|
||||
<Link href="/" className="text-sm font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> Admin
|
||||
Sport<span className="text-volt-600">.</span> {t('appName')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-6 p-5">
|
||||
{NAVIGATION.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div key={section.titleKey}>
|
||||
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{section.title}
|
||||
{t(`sections.${section.titleKey}`)}
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
@@ -29,7 +33,7 @@ export function AdminSidebar() {
|
||||
href={item.href}
|
||||
className="rounded-card text-ink-600 hover:bg-ink-100 hover:text-ink-950 block px-2 py-1.5 text-sm"
|
||||
>
|
||||
{item.label}
|
||||
{tPages(`${item.labelKey}.title`)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@sport/ui';
|
||||
|
||||
/** Placeholder for admin screens that are not built yet. */
|
||||
@@ -12,13 +14,15 @@ export function PageScaffold({
|
||||
permission: string;
|
||||
milestone: string;
|
||||
}) {
|
||||
const t = useTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{description}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Badge variant="outline">requires: {permission}</Badge>
|
||||
<Badge variant="neutral">Planned: {milestone}</Badge>
|
||||
<Badge variant="outline">{t('requires', { permission })}</Badge>
|
||||
<Badge variant="neutral">{t('planned', { milestone })}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { isLocale, type Locale } from '@sport/types';
|
||||
|
||||
import { LOCALE_COOKIE } from './request';
|
||||
|
||||
/**
|
||||
* Persists the operator's language choice.
|
||||
*
|
||||
* A Server Action rather than a client-side cookie write, so the very next
|
||||
* render already has the new locale — no flash of the previous language, and no
|
||||
* need to duplicate the cookie name in client code.
|
||||
*/
|
||||
export async function setLocale(locale: Locale): Promise<void> {
|
||||
if (!isLocale(locale)) return;
|
||||
|
||||
const store = await cookies();
|
||||
store.set(LOCALE_COOKIE, locale, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
// Not httpOnly: this is a display preference, not a credential, and reading
|
||||
// it client-side is occasionally useful.
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
// Every admin screen renders translated text, so the whole tree is stale.
|
||||
revalidatePath('/', 'layout');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from '@sport/types';
|
||||
|
||||
/**
|
||||
* The admin resolves its locale from a cookie, not from the URL.
|
||||
*
|
||||
* Deliberately different from the storefront. The storefront needs locale in
|
||||
* the path for SEO, shareable links and hreflang; the admin is an internal tool
|
||||
* behind a login with `noindex` on every page — none of that applies. Putting
|
||||
* `/vi/` in front of every admin route would add a proxy hop and double the
|
||||
* route tree to buy nothing.
|
||||
*/
|
||||
export const LOCALE_COOKIE = 'sport_admin_locale';
|
||||
|
||||
export async function getPreferredLocale(): Promise<Locale> {
|
||||
const store = await cookies();
|
||||
const value = store.get(LOCALE_COOKIE)?.value;
|
||||
|
||||
return value && isLocale(value) ? value : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locale = await getPreferredLocale();
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -3,55 +3,61 @@ import { PERMISSIONS, type Permission } from '@sport/types';
|
||||
/**
|
||||
* The sidebar is derived from the same permission catalog the API guards use.
|
||||
*
|
||||
* A user who cannot read orders never sees an Orders link, so the UI has no
|
||||
* dead ends. This is presentation only — hiding a link is not authorization.
|
||||
* An operator who cannot read orders never sees an Orders link, so the UI has
|
||||
* no dead ends. This is presentation only — hiding a link is not authorization.
|
||||
* The API re-checks every request, because a hidden link is one devtools
|
||||
* inspection away from being visible.
|
||||
*
|
||||
* Labels are message keys rather than strings: the sidebar is the most visible
|
||||
* surface in the admin, and hard-coding English here would have made the
|
||||
* language switcher look broken.
|
||||
*/
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
/** Key into the `pages` message namespace. */
|
||||
labelKey: string;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
title: string;
|
||||
/** Key into the `common.sections` message namespace. */
|
||||
titleKey: 'catalog' | 'sales' | 'marketing' | 'settings';
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const NAVIGATION: NavSection[] = [
|
||||
{
|
||||
title: 'Catalog',
|
||||
titleKey: 'catalog',
|
||||
items: [
|
||||
{ href: '/products', label: 'Products', permission: PERMISSIONS.PRODUCT_READ },
|
||||
{ href: '/categories', label: 'Categories', permission: PERMISSIONS.CATEGORY_READ },
|
||||
{ href: '/collections', label: 'Collections', permission: PERMISSIONS.COLLECTION_READ },
|
||||
{ href: '/brands', label: 'Brands', permission: PERMISSIONS.BRAND_READ },
|
||||
{ href: '/inventory', label: 'Inventory', permission: PERMISSIONS.INVENTORY_READ },
|
||||
{ href: '/media', label: 'Media', permission: PERMISSIONS.MEDIA_READ },
|
||||
{ href: '/products', labelKey: 'products', permission: PERMISSIONS.PRODUCT_READ },
|
||||
{ href: '/categories', labelKey: 'categories', permission: PERMISSIONS.CATEGORY_READ },
|
||||
{ href: '/collections', labelKey: 'collections', permission: PERMISSIONS.COLLECTION_READ },
|
||||
{ href: '/brands', labelKey: 'brands', permission: PERMISSIONS.BRAND_READ },
|
||||
{ href: '/inventory', labelKey: 'inventory', permission: PERMISSIONS.INVENTORY_READ },
|
||||
{ href: '/media', labelKey: 'media', permission: PERMISSIONS.MEDIA_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sales',
|
||||
titleKey: 'sales',
|
||||
items: [
|
||||
{ href: '/orders', label: 'Orders', permission: PERMISSIONS.ORDER_READ },
|
||||
{ href: '/customers', label: 'Customers', permission: PERMISSIONS.CUSTOMER_READ },
|
||||
{ href: '/orders', labelKey: 'orders', permission: PERMISSIONS.ORDER_READ },
|
||||
{ href: '/customers', labelKey: 'customers', permission: PERMISSIONS.CUSTOMER_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Marketing',
|
||||
titleKey: 'marketing',
|
||||
items: [
|
||||
{ href: '/promotions', label: 'Promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
|
||||
{ href: '/coupons', label: 'Coupons', permission: PERMISSIONS.COUPON_MANAGE },
|
||||
{ href: '/reviews', label: 'Reviews', permission: PERMISSIONS.REVIEW_MODERATE },
|
||||
{ href: '/cms', label: 'Content', permission: PERMISSIONS.CMS_READ },
|
||||
{ href: '/promotions', labelKey: 'promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
|
||||
{ href: '/coupons', labelKey: 'coupons', permission: PERMISSIONS.COUPON_MANAGE },
|
||||
{ href: '/reviews', labelKey: 'reviews', permission: PERMISSIONS.REVIEW_MODERATE },
|
||||
{ href: '/cms', labelKey: 'cms', permission: PERMISSIONS.CMS_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
titleKey: 'settings',
|
||||
items: [
|
||||
{ href: '/settings/users', label: 'Users', permission: PERMISSIONS.USER_READ },
|
||||
{ href: '/settings/roles', label: 'Roles', permission: PERMISSIONS.ROLE_READ },
|
||||
{ href: '/settings/users', labelKey: 'users', permission: PERMISSIONS.USER_READ },
|
||||
{ href: '/settings/roles', labelKey: 'roles', permission: PERMISSIONS.ROLE_READ },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Sport Admin",
|
||||
"changeLanguage": "Change language",
|
||||
"signedOut": "Signed out",
|
||||
"requires": "requires: {permission}",
|
||||
"planned": "Planned: {milestone}",
|
||||
"sections": {
|
||||
"catalog": "Catalog",
|
||||
"sales": "Sales",
|
||||
"marketing": "Marketing",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"body": "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."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"body": "Revenue, orders, conversion and low-stock alerts."
|
||||
},
|
||||
"products": {
|
||||
"title": "Products",
|
||||
"body": "Product list with status, brand and variant count. The editor manages options, generates the variant matrix and edits per-variant SKU, price and stock."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"body": "Drag-to-reorder category tree with materialised-path maintenance."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"body": "Manual and rule-based collections, with campaign scheduling."
|
||||
},
|
||||
"brands": {
|
||||
"title": "Brands",
|
||||
"body": "Brand records, logos and SEO fields."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Inventory",
|
||||
"body": "Stock by variant and location, adjustments and the movement ledger."
|
||||
},
|
||||
"media": {
|
||||
"title": "Media",
|
||||
"body": "Asset library. Uploads go browser → presigned URL → R2; the API only records metadata."
|
||||
},
|
||||
"orders": {
|
||||
"title": "Orders",
|
||||
"body": "Order list and detail: fulfilment, status transitions, refunds and the audit trail."
|
||||
},
|
||||
"customers": {
|
||||
"title": "Customers",
|
||||
"body": "Customer records, order history and addresses."
|
||||
},
|
||||
"promotions": {
|
||||
"title": "Promotions",
|
||||
"body": "Automatic cart-level discount rules."
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Coupons",
|
||||
"body": "Coupon codes, usage limits and redemption reporting."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Reviews",
|
||||
"body": "Review moderation queue."
|
||||
},
|
||||
"cms": {
|
||||
"title": "Content",
|
||||
"body": "Homepage blocks, banners, blog posts and static pages."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"body": "Back-office accounts and role assignment."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Roles",
|
||||
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Sport Admin",
|
||||
"changeLanguage": "Đổi ngôn ngữ",
|
||||
"signedOut": "Chưa đăng nhập",
|
||||
"requires": "cần quyền: {permission}",
|
||||
"planned": "Dự kiến: {milestone}",
|
||||
"sections": {
|
||||
"catalog": "Danh mục sản phẩm",
|
||||
"sales": "Bán hàng",
|
||||
"marketing": "Tiếp thị",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"login": {
|
||||
"title": "Đăng nhập",
|
||||
"body": "Chức năng đăng nhập sẽ có ở giai đoạn xác thực (M2). Thông tin đăng nhập sẽ được đổi lấy access token ngắn hạn cùng refresh cookie httpOnly xoay vòng, giới hạn cho phạm vi quản trị."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Tổng quan",
|
||||
"body": "Doanh thu, đơn hàng, tỷ lệ chuyển đổi và cảnh báo sắp hết hàng."
|
||||
},
|
||||
"products": {
|
||||
"title": "Sản phẩm",
|
||||
"body": "Danh sách sản phẩm kèm trạng thái, thương hiệu và số phiên bản. Trình soạn thảo quản lý tuỳ chọn, sinh ma trận phiên bản và chỉnh SKU, giá, tồn kho cho từng phiên bản."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Danh mục",
|
||||
"body": "Cây danh mục kéo-thả kèm cập nhật đường dẫn phân cấp."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Bộ sưu tập",
|
||||
"body": "Bộ sưu tập thủ công và theo quy tắc, kèm lịch chạy chiến dịch."
|
||||
},
|
||||
"brands": {
|
||||
"title": "Thương hiệu",
|
||||
"body": "Thông tin thương hiệu, logo và trường SEO."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Kho hàng",
|
||||
"body": "Tồn kho theo phiên bản và kho, điều chỉnh và sổ nhật ký xuất nhập."
|
||||
},
|
||||
"media": {
|
||||
"title": "Thư viện media",
|
||||
"body": "Kho tài nguyên. Tệp tải lên đi thẳng từ trình duyệt qua URL ký sẵn tới R2; API chỉ lưu thông tin mô tả."
|
||||
},
|
||||
"orders": {
|
||||
"title": "Đơn hàng",
|
||||
"body": "Danh sách và chi tiết đơn: giao hàng, chuyển trạng thái, hoàn tiền và nhật ký thay đổi."
|
||||
},
|
||||
"customers": {
|
||||
"title": "Khách hàng",
|
||||
"body": "Hồ sơ khách hàng, lịch sử đơn và địa chỉ."
|
||||
},
|
||||
"promotions": {
|
||||
"title": "Khuyến mãi",
|
||||
"body": "Quy tắc giảm giá tự động ở cấp giỏ hàng."
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Mã giảm giá",
|
||||
"body": "Mã giảm giá, giới hạn sử dụng và báo cáo quy đổi."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Đánh giá",
|
||||
"body": "Hàng đợi kiểm duyệt đánh giá."
|
||||
},
|
||||
"cms": {
|
||||
"title": "Nội dung",
|
||||
"body": "Khối trang chủ, banner, bài viết và trang tĩnh."
|
||||
},
|
||||
"users": {
|
||||
"title": "Người dùng",
|
||||
"body": "Tài khoản nội bộ và phân vai trò."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Vai trò",
|
||||
"body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Locale" AS ENUM ('VI', 'EN');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "brand_translations" (
|
||||
"brand_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "brand_translations_pkey" PRIMARY KEY ("brand_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "category_translations" (
|
||||
"category_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "category_translations_pkey" PRIMARY KEY ("category_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collection_translations" (
|
||||
"collection_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "collection_translations_pkey" PRIMARY KEY ("collection_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_translations" (
|
||||
"product_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"slug" VARCHAR(280) NOT NULL,
|
||||
"short_description" VARCHAR(500),
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "product_translations_pkey" PRIMARY KEY ("product_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_option_translations" (
|
||||
"option_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(60) NOT NULL,
|
||||
|
||||
CONSTRAINT "product_option_translations_pkey" PRIMARY KEY ("option_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_option_value_translations" (
|
||||
"option_value_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"label" VARCHAR(80) NOT NULL,
|
||||
|
||||
CONSTRAINT "product_option_value_translations_pkey" PRIMARY KEY ("option_value_id","locale")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "brand_translations_locale_slug_key" ON "brand_translations"("locale", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "category_translations_locale_slug_key" ON "category_translations"("locale", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collection_translations_locale_slug_key" ON "collection_translations"("locale", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_translations_locale_name_idx" ON "product_translations"("locale", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_translations_locale_slug_key" ON "product_translations"("locale", "slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "brand_translations" ADD CONSTRAINT "brand_translations_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "brands"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "category_translations" ADD CONSTRAINT "category_translations_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collection_translations" ADD CONSTRAINT "collection_translations_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_translations" ADD CONSTRAINT "product_translations_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_option_translations" ADD CONSTRAINT "product_option_translations_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "product_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_option_value_translations" ADD CONSTRAINT "product_option_value_translations_option_value_id_fkey" FOREIGN KEY ("option_value_id") REFERENCES "product_option_values"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "products" ADD COLUMN "in_stock" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "is_on_sale" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "max_price_amount" INTEGER,
|
||||
ADD COLUMN "min_price_amount" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_status_min_price_amount_idx" ON "products"("status", "min_price_amount");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_status_in_stock_idx" ON "products"("status", "in_stock");
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_attribute_translations" (
|
||||
"attribute_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"label" VARCHAR(120) NOT NULL,
|
||||
"value" VARCHAR(500) NOT NULL,
|
||||
|
||||
CONSTRAINT "product_attribute_translations_pkey" PRIMARY KEY ("attribute_id","locale")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_attribute_translations" ADD CONSTRAINT "product_attribute_translations_attribute_id_fkey" FOREIGN KEY ("attribute_id") REFERENCES "product_attributes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,13 +1,13 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sport Store — Prisma schema
|
||||
//
|
||||
// SCOPE OF THIS FILE (milestone 0)
|
||||
// SCOPE OF THIS FILE (milestones 0-1)
|
||||
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
|
||||
// attributes), taxonomy (brand / category / collection), media metadata and
|
||||
// the inventory ledger.
|
||||
// attributes), taxonomy (brand / category / collection), per-locale content
|
||||
// translations, media metadata and the inventory ledger.
|
||||
//
|
||||
// Cart, checkout, order, payment, promotion and review tables are milestone 1.
|
||||
// They are intentionally absent so the first migration stays reviewable.
|
||||
// Cart, checkout, order, payment, promotion and review tables are milestone 5.
|
||||
// They are intentionally absent so the migration history stays reviewable.
|
||||
//
|
||||
// CONVENTIONS
|
||||
// - Table names: snake_case plural (@@map). Prisma models: PascalCase singular.
|
||||
@@ -285,6 +285,26 @@ model MediaAsset {
|
||||
@@map("media_assets")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Localisation
|
||||
// ===========================================================================
|
||||
|
||||
enum Locale {
|
||||
VI
|
||||
EN
|
||||
}
|
||||
|
||||
// Translation tables follow one shape throughout: a composite (entityId, locale)
|
||||
// primary key, plus a per-locale unique slug where the entity has a URL.
|
||||
//
|
||||
// Typed tables rather than a generic (entityType, field, value) table: a generic
|
||||
// table cannot enforce "slug is unique within a locale", cannot be indexed
|
||||
// usefully, and returns `string | undefined` for everything. Six small tables
|
||||
// with real constraints beat one clever one.
|
||||
//
|
||||
// The base row keeps the canonical values. A missing translation falls back to
|
||||
// them field by field, so an untranslated description never renders as blank.
|
||||
|
||||
// ===========================================================================
|
||||
// Taxonomy
|
||||
// ===========================================================================
|
||||
@@ -305,6 +325,7 @@ model Brand {
|
||||
|
||||
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
translations BrandTranslation[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
@@ -337,6 +358,7 @@ model Category {
|
||||
children Category[] @relation("CategoryTree")
|
||||
image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
translations CategoryTranslation[]
|
||||
|
||||
@@unique([parentId, slug])
|
||||
@@index([path])
|
||||
@@ -373,6 +395,7 @@ model Collection {
|
||||
|
||||
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
|
||||
products ProductCollection[]
|
||||
translations CollectionTranslation[]
|
||||
|
||||
@@index([isActive, startsAt, endsAt])
|
||||
@@map("collections")
|
||||
@@ -453,6 +476,22 @@ model Product {
|
||||
metaDescription String? @map("meta_description")
|
||||
metadata Json?
|
||||
|
||||
/// ---- Read-model projection, derived from this product's variants --------
|
||||
///
|
||||
/// Denormalised deliberately. Sorting a listing by price and rendering a
|
||||
/// price facet both need MIN/MAX across variants, and no ORM can express
|
||||
/// "order by the minimum price of a related collection" — the alternatives
|
||||
/// were raw SQL for every listing query or a search index we do not need yet
|
||||
/// (ADR-0012).
|
||||
///
|
||||
/// These columns are ALWAYS derived, never authored. `ProductsService
|
||||
/// .recomputePricing()` is the single writer; every variant mutation in M3
|
||||
/// must call it. Treat a manual UPDATE of these columns as a bug.
|
||||
minPriceAmount Int? @map("min_price_amount")
|
||||
maxPriceAmount Int? @map("max_price_amount")
|
||||
isOnSale Boolean @default(false) @map("is_on_sale")
|
||||
inStock Boolean @default(false) @map("in_stock")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
@@ -464,8 +503,11 @@ model Product {
|
||||
images ProductImage[]
|
||||
attributes ProductAttribute[]
|
||||
collections ProductCollection[]
|
||||
translations ProductTranslation[]
|
||||
|
||||
@@index([status, publishedAt])
|
||||
@@index([status, minPriceAmount])
|
||||
@@index([status, inStock])
|
||||
@@index([brandId])
|
||||
@@index([primaryCategoryId])
|
||||
@@index([genderTargets], type: Gin)
|
||||
@@ -486,6 +528,7 @@ model ProductOption {
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
values ProductOptionValue[]
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
translations ProductOptionTranslation[]
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([productId, position])
|
||||
@@ -508,6 +551,7 @@ model ProductOptionValue {
|
||||
swatchImage MediaAsset? @relation("OptionValueSwatch", fields: [swatchImageId], references: [id], onDelete: SetNull)
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
images ProductImage[]
|
||||
translations ProductOptionValueTranslation[]
|
||||
|
||||
@@unique([optionId, value])
|
||||
@@index([optionId, position])
|
||||
@@ -607,6 +651,7 @@ model ProductAttribute {
|
||||
isFilterable Boolean @default(false) @map("is_filterable")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
translations ProductAttributeTranslation[]
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([key, value])
|
||||
@@ -692,3 +737,122 @@ model StockMovement {
|
||||
@@index([referenceId])
|
||||
@@map("stock_movements")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Translations
|
||||
// ===========================================================================
|
||||
|
||||
model BrandTranslation {
|
||||
brandId String @map("brand_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([brandId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@map("brand_translations")
|
||||
}
|
||||
|
||||
model CategoryTranslation {
|
||||
categoryId String @map("category_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([categoryId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@map("category_translations")
|
||||
}
|
||||
|
||||
model CollectionTranslation {
|
||||
collectionId String @map("collection_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([collectionId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@map("collection_translations")
|
||||
}
|
||||
|
||||
/// Per-locale slugs matter here more than anywhere else: `/en/products/
|
||||
/// mens-running-tee` and `/vi/products/ao-chay-bo-nam` are the actual SEO
|
||||
/// surface of the store.
|
||||
model ProductTranslation {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(255)
|
||||
slug String @db.VarChar(280)
|
||||
shortDescription String? @map("short_description") @db.VarChar(500)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@index([locale, name])
|
||||
@@map("product_translations")
|
||||
}
|
||||
|
||||
model ProductOptionTranslation {
|
||||
optionId String @map("option_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(60)
|
||||
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([optionId, locale])
|
||||
@@map("product_option_translations")
|
||||
}
|
||||
|
||||
/// Colour names are the most visible translated strings on a listing page —
|
||||
/// a grid of swatches labelled "Black" in a Vietnamese store reads as broken.
|
||||
model ProductOptionValueTranslation {
|
||||
optionValueId String @map("option_value_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
label String @db.VarChar(80)
|
||||
|
||||
optionValue ProductOptionValue @relation(fields: [optionValueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([optionValueId, locale])
|
||||
@@map("product_option_value_translations")
|
||||
}
|
||||
|
||||
/// Spec-table rows are user-visible content ("Chất liệu: 92% Polyester"), so
|
||||
/// both the label and the value need translating — a Vietnamese PDP showing an
|
||||
/// English spec table is a half-finished translation.
|
||||
model ProductAttributeTranslation {
|
||||
attributeId String @map("attribute_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
label String @db.VarChar(120)
|
||||
value String @db.VarChar(500)
|
||||
|
||||
attribute ProductAttribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([attributeId, locale])
|
||||
@@map("product_attribute_translations")
|
||||
}
|
||||
|
||||
+16
-1
@@ -6,8 +6,10 @@
|
||||
* users: account provisioning belongs to the auth milestone, and baking a
|
||||
* default admin password into a repository is how stores get compromised.
|
||||
*/
|
||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||
|
||||
import { seedCatalog } from './seed/catalog';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -129,6 +131,19 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Seed complete: ${permissionIds.size} permissions, ${Object.keys(ROLE_DEFINITIONS).length} roles.`,
|
||||
);
|
||||
|
||||
// Demo catalog: development and staging only. Guarded twice — by NODE_ENV and
|
||||
// by an explicit opt-out — because sample products appearing in a production
|
||||
// storefront is the kind of mistake that reaches customers.
|
||||
const isProduction = process.env['NODE_ENV'] === 'production';
|
||||
const demoDisabled = process.env['SEED_DEMO'] === 'false';
|
||||
|
||||
if (isProduction || demoDisabled) {
|
||||
console.log('Skipping demo catalog seed.');
|
||||
return;
|
||||
}
|
||||
|
||||
await seedCatalog(prisma);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
BRANDS,
|
||||
CATEGORIES,
|
||||
COLLECTIONS,
|
||||
COLOURS,
|
||||
PRODUCTS,
|
||||
type Localised,
|
||||
type ProductSeed,
|
||||
} from './data';
|
||||
import { blurDataUrl, hexToRgb, solidPng } from './png';
|
||||
|
||||
/**
|
||||
* Demo catalog seed. Development and staging only — never production.
|
||||
*
|
||||
* Idempotent by natural key (slug / SKU / path), so re-running updates rather
|
||||
* than duplicating. Deterministic throughout: same input, same stock numbers,
|
||||
* same images. A seed that produces different data on each run makes "is this
|
||||
* a bug or just the seed?" unanswerable.
|
||||
*/
|
||||
|
||||
const IMAGE_WIDTH = 1200;
|
||||
const IMAGE_HEIGHT = 1500;
|
||||
|
||||
type Locale = 'VI' | 'EN';
|
||||
const LOCALES: Locale[] = ['VI', 'EN'];
|
||||
|
||||
function localeValue(value: Localised, locale: Locale): string {
|
||||
return locale === 'VI' ? value.vi : value.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) seeded from a string. Stock levels vary
|
||||
* across variants — as they do in reality — but never between runs.
|
||||
*/
|
||||
function seededRandom(seed: string): () => number {
|
||||
let h = 1779033703 ^ seed.length;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
|
||||
let state = h >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedCatalog(prisma: PrismaClient): Promise<void> {
|
||||
const storage = createStorageClient();
|
||||
|
||||
const brandIds = await seedBrands(prisma);
|
||||
const categoryIds = await seedCategories(prisma);
|
||||
const collectionIds = await seedCollections(prisma);
|
||||
const location = await prisma.inventoryLocation.findUniqueOrThrow({ where: { code: 'MAIN' } });
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
await seedProduct(prisma, storage, product, {
|
||||
brandId: brandIds.get(product.brand),
|
||||
categoryId: categoryIds.get(product.categoryPath),
|
||||
collectionIds,
|
||||
locationId: location.id,
|
||||
});
|
||||
}
|
||||
|
||||
const dropped = await invalidateCatalogCache();
|
||||
|
||||
console.log(
|
||||
`Catalog: ${BRANDS.length} brands, ${CATEGORIES.length} categories, ` +
|
||||
`${COLLECTIONS.length} collections, ${PRODUCTS.length} products` +
|
||||
`${dropped === null ? '' : `, ${dropped} cache keys dropped`}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the catalog read cache.
|
||||
*
|
||||
* Without this, re-seeding leaves Redis serving the previous catalog for up to
|
||||
* 15 minutes — which reads as "my changes did nothing" and burns an afternoon.
|
||||
* Failure is non-fatal: an unreachable cache must never fail a seed.
|
||||
*/
|
||||
async function invalidateCatalogCache(): Promise<number | null> {
|
||||
const url = process.env['REDIS_URL'];
|
||||
if (!url) return null;
|
||||
|
||||
const prefix = process.env['REDIS_KEY_PREFIX'] ?? 'sport:';
|
||||
const redis = new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
|
||||
// SCAN, never KEYS: KEYS blocks the whole server, and this same helper will
|
||||
// eventually run against production during a catalog import.
|
||||
let cursor = '0';
|
||||
let removed = 0;
|
||||
|
||||
do {
|
||||
const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}catalog:*`, 'COUNT', 500);
|
||||
cursor = next;
|
||||
if (keys.length > 0) {
|
||||
// Keys from SCAN already carry the prefix; `del` must not re-add it.
|
||||
removed += await redis.del(...keys);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return removed;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStorageClient(): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: process.env['STORAGE_ENDPOINT'] ?? 'http://localhost:9000',
|
||||
region: process.env['STORAGE_REGION'] ?? 'auto',
|
||||
forcePathStyle: (process.env['STORAGE_FORCE_PATH_STYLE'] ?? 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: process.env['STORAGE_ACCESS_KEY_ID'] ?? 'sportminio',
|
||||
secretAccessKey: process.env['STORAGE_SECRET_ACCESS_KEY'] ?? 'sportminio',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a generated placeholder and records the metadata row.
|
||||
*
|
||||
* Bytes to object storage, key to PostgreSQL — the same split real uploads use
|
||||
* (ADR-0009). The key is derived from the colourway so re-running overwrites
|
||||
* the same object instead of littering the bucket.
|
||||
*/
|
||||
async function upsertMedia(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
storageKey: string,
|
||||
hex: string,
|
||||
altText: string,
|
||||
): Promise<string> {
|
||||
const rgb = hexToRgb(hex);
|
||||
const png = solidPng(IMAGE_WIDTH, IMAGE_HEIGHT, rgb);
|
||||
|
||||
await storage.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env['STORAGE_BUCKET'] ?? 'sport-media',
|
||||
Key: storageKey,
|
||||
Body: png,
|
||||
ContentType: 'image/png',
|
||||
CacheControl: 'public, max-age=31536000, immutable',
|
||||
}),
|
||||
);
|
||||
|
||||
const media = await prisma.mediaAsset.upsert({
|
||||
where: { storageKey },
|
||||
update: { altText, sizeBytes: png.length },
|
||||
create: {
|
||||
kind: 'IMAGE',
|
||||
storageKey,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: png.length,
|
||||
width: IMAGE_WIDTH,
|
||||
height: IMAGE_HEIGHT,
|
||||
blurDataUrl: blurDataUrl(rgb),
|
||||
altText,
|
||||
},
|
||||
});
|
||||
|
||||
return media.id;
|
||||
}
|
||||
|
||||
async function seedBrands(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
for (const brand of BRANDS) {
|
||||
const row = await prisma.brand.upsert({
|
||||
where: { slug: brand.key },
|
||||
update: { name: brand.name.en, description: brand.description.en },
|
||||
create: { slug: brand.key, name: brand.name.en, description: brand.description.en },
|
||||
});
|
||||
ids.set(brand.key, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.brandTranslation.upsert({
|
||||
where: { brandId_locale: { brandId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(brand.name, locale),
|
||||
slug: localeValue(brand.slug, locale),
|
||||
description: localeValue(brand.description, locale),
|
||||
},
|
||||
create: {
|
||||
brandId: row.id,
|
||||
locale,
|
||||
name: localeValue(brand.name, locale),
|
||||
slug: localeValue(brand.slug, locale),
|
||||
description: localeValue(brand.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedCategories(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
// CATEGORIES is ordered parents-first, so a parent id is always known by the
|
||||
// time its children are processed.
|
||||
for (const category of CATEGORIES) {
|
||||
const depth = category.path.split('/').length - 1;
|
||||
const parentId = category.parentPath ? ids.get(category.parentPath) : null;
|
||||
|
||||
const row = await prisma.category.upsert({
|
||||
where: { path: category.path },
|
||||
update: { name: category.name.en, position: category.position, parentId, depth },
|
||||
create: {
|
||||
path: category.path,
|
||||
slug: category.slug.en,
|
||||
name: category.name.en,
|
||||
position: category.position,
|
||||
parentId,
|
||||
depth,
|
||||
},
|
||||
});
|
||||
ids.set(category.path, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.categoryTranslation.upsert({
|
||||
where: { categoryId_locale: { categoryId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(category.name, locale),
|
||||
slug: localeValue(category.slug, locale),
|
||||
},
|
||||
create: {
|
||||
categoryId: row.id,
|
||||
locale,
|
||||
name: localeValue(category.name, locale),
|
||||
slug: localeValue(category.slug, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedCollections(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
for (const collection of COLLECTIONS) {
|
||||
const row = await prisma.collection.upsert({
|
||||
where: { slug: collection.key },
|
||||
update: { name: collection.name.en, position: collection.position },
|
||||
create: {
|
||||
slug: collection.key,
|
||||
name: collection.name.en,
|
||||
description: collection.description.en,
|
||||
position: collection.position,
|
||||
type: 'MANUAL',
|
||||
},
|
||||
});
|
||||
ids.set(collection.key, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.collectionTranslation.upsert({
|
||||
where: { collectionId_locale: { collectionId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(collection.name, locale),
|
||||
slug: localeValue(collection.slug, locale),
|
||||
description: localeValue(collection.description, locale),
|
||||
},
|
||||
create: {
|
||||
collectionId: row.id,
|
||||
locale,
|
||||
name: localeValue(collection.name, locale),
|
||||
slug: localeValue(collection.slug, locale),
|
||||
description: localeValue(collection.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedProduct(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
seed: ProductSeed,
|
||||
refs: {
|
||||
brandId: string | undefined;
|
||||
categoryId: string | undefined;
|
||||
collectionIds: Map<string, string>;
|
||||
locationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const product = await prisma.product.upsert({
|
||||
where: { slug: seed.key },
|
||||
update: {
|
||||
name: seed.name.en,
|
||||
status: 'ACTIVE',
|
||||
publishedAt: new Date('2026-01-15T00:00:00Z'),
|
||||
brandId: refs.brandId ?? null,
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
},
|
||||
create: {
|
||||
slug: seed.key,
|
||||
name: seed.name.en,
|
||||
description: seed.description.en,
|
||||
shortDescription: seed.shortDescription.en,
|
||||
status: 'ACTIVE',
|
||||
publishedAt: new Date('2026-01-15T00:00:00Z'),
|
||||
brandId: refs.brandId ?? null,
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
},
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
// An empty string in the seed means "not translated" — store null so the
|
||||
// API's field-level fallback kicks in rather than rendering a blank.
|
||||
const description = localeValue(seed.description, locale) || null;
|
||||
|
||||
await prisma.productTranslation.upsert({
|
||||
where: { productId_locale: { productId: product.id, locale } },
|
||||
update: {
|
||||
name: localeValue(seed.name, locale),
|
||||
slug: localeValue(seed.slug, locale),
|
||||
shortDescription: localeValue(seed.shortDescription, locale),
|
||||
description,
|
||||
},
|
||||
create: {
|
||||
productId: product.id,
|
||||
locale,
|
||||
name: localeValue(seed.name, locale),
|
||||
slug: localeValue(seed.slug, locale),
|
||||
shortDescription: localeValue(seed.shortDescription, locale),
|
||||
description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await seedAttributes(prisma, product.id, seed);
|
||||
await prisma.productCollection.deleteMany({ where: { productId: product.id } });
|
||||
for (const key of seed.collections) {
|
||||
const collectionId = refs.collectionIds.get(key);
|
||||
if (collectionId) {
|
||||
await prisma.productCollection.create({ data: { productId: product.id, collectionId } });
|
||||
}
|
||||
}
|
||||
|
||||
const { colourValueIds, sizeValueIds, colourOptionId, sizeOptionId } = await seedOptions(
|
||||
prisma,
|
||||
product.id,
|
||||
seed,
|
||||
);
|
||||
|
||||
await seedImages(prisma, storage, product.id, seed, colourValueIds);
|
||||
await seedVariants(prisma, product.id, seed, {
|
||||
colourOptionId,
|
||||
sizeOptionId,
|
||||
colourValueIds,
|
||||
sizeValueIds,
|
||||
locationId: refs.locationId,
|
||||
});
|
||||
|
||||
await recomputePricing(prisma, product.id);
|
||||
}
|
||||
|
||||
async function seedAttributes(
|
||||
prisma: PrismaClient,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
): Promise<void> {
|
||||
for (const [index, attribute] of seed.attributes.entries()) {
|
||||
const row = await prisma.productAttribute.upsert({
|
||||
where: { productId_key: { productId, key: attribute.key } },
|
||||
update: { label: attribute.label.en, value: attribute.value.en, position: index },
|
||||
create: {
|
||||
productId,
|
||||
key: attribute.key,
|
||||
label: attribute.label.en,
|
||||
value: attribute.value.en,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productAttributeTranslation.upsert({
|
||||
where: { attributeId_locale: { attributeId: row.id, locale } },
|
||||
update: {
|
||||
label: localeValue(attribute.label, locale),
|
||||
value: localeValue(attribute.value, locale),
|
||||
},
|
||||
create: {
|
||||
attributeId: row.id,
|
||||
locale,
|
||||
label: localeValue(attribute.label, locale),
|
||||
value: localeValue(attribute.value, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Colour and Size axes and their values.
|
||||
*
|
||||
* This is the shape the whole catalog model rests on: options define the axes,
|
||||
* values define the allowed points, and the variant table below resolves every
|
||||
* combination into a sellable unit.
|
||||
*/
|
||||
async function seedOptions(prisma: PrismaClient, productId: string, seed: ProductSeed) {
|
||||
const colourOption = await prisma.productOption.upsert({
|
||||
where: { productId_key: { productId, key: 'colour' } },
|
||||
update: { name: 'Colour', position: 0 },
|
||||
create: { productId, key: 'colour', name: 'Colour', position: 0 },
|
||||
});
|
||||
|
||||
const sizeOption = await prisma.productOption.upsert({
|
||||
where: { productId_key: { productId, key: 'size' } },
|
||||
update: { name: 'Size', position: 1 },
|
||||
create: { productId, key: 'size', name: 'Size', position: 1 },
|
||||
});
|
||||
|
||||
for (const [optionId, names] of [
|
||||
[colourOption.id, { vi: 'Màu sắc', en: 'Colour' }],
|
||||
[sizeOption.id, { vi: 'Kích cỡ', en: 'Size' }],
|
||||
] as const) {
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productOptionTranslation.upsert({
|
||||
where: { optionId_locale: { optionId, locale } },
|
||||
update: { name: localeValue(names, locale) },
|
||||
create: { optionId, locale, name: localeValue(names, locale) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const colourValueIds = new Map<string, string>();
|
||||
for (const [index, colourKey] of seed.colours.entries()) {
|
||||
const colour = COLOURS[colourKey];
|
||||
if (!colour) continue;
|
||||
|
||||
const value = await prisma.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: colourOption.id, value: colour.value } },
|
||||
update: { label: colour.label.en, position: index, swatchHex: colour.hex },
|
||||
create: {
|
||||
optionId: colourOption.id,
|
||||
value: colour.value,
|
||||
label: colour.label.en,
|
||||
position: index,
|
||||
swatchHex: colour.hex,
|
||||
},
|
||||
});
|
||||
colourValueIds.set(colour.value, value.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productOptionValueTranslation.upsert({
|
||||
where: { optionValueId_locale: { optionValueId: value.id, locale } },
|
||||
update: { label: localeValue(colour.label, locale) },
|
||||
create: { optionValueId: value.id, locale, label: localeValue(colour.label, locale) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes get NO translation rows on purpose: "M" is "M" in both languages.
|
||||
// The API falls back to the base label, which is exactly the intended
|
||||
// behaviour and keeps this from becoming busywork for every new product.
|
||||
const sizeValueIds = new Map<string, string>();
|
||||
for (const [index, size] of seed.sizes.entries()) {
|
||||
const value = await prisma.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: sizeOption.id, value: size.toLowerCase() } },
|
||||
update: { label: size, position: index },
|
||||
create: { optionId: sizeOption.id, value: size.toLowerCase(), label: size, position: index },
|
||||
});
|
||||
sizeValueIds.set(size, value.id);
|
||||
}
|
||||
|
||||
return {
|
||||
colourOptionId: colourOption.id,
|
||||
sizeOptionId: sizeOption.id,
|
||||
colourValueIds,
|
||||
sizeValueIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedImages(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
colourValueIds: Map<string, string>,
|
||||
): Promise<void> {
|
||||
for (const [index, colourKey] of seed.colours.entries()) {
|
||||
const colour = COLOURS[colourKey];
|
||||
const optionValueId = colour ? colourValueIds.get(colour.value) : undefined;
|
||||
if (!colour || !optionValueId) continue;
|
||||
|
||||
// Two images per colourway: the card's default and its hover state.
|
||||
for (const [variantIndex, suffix] of ['a', 'b'].entries()) {
|
||||
const storageKey = `products/seed/${seed.key}-${colour.value}-${suffix}.png`;
|
||||
const mediaId = await upsertMedia(
|
||||
prisma,
|
||||
storage,
|
||||
storageKey,
|
||||
variantIndex === 0 ? colour.hex : shade(colour.hex),
|
||||
`${seed.name.en} — ${colour.label.en}`,
|
||||
);
|
||||
|
||||
await prisma.productImage.upsert({
|
||||
where: { productId_mediaId_optionValueId: { productId, mediaId, optionValueId } },
|
||||
update: { position: index * 2 + variantIndex },
|
||||
create: { productId, mediaId, optionValueId, position: index * 2 + variantIndex },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Slightly darker sibling colour, so the hover image is visibly different. */
|
||||
function shade(hex: string): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const darken = (value: number) => Math.max(0, Math.round(value * 0.78));
|
||||
return `#${[darken(r), darken(g), darken(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heart of the model: one variant per colour × size, each with its own SKU,
|
||||
* price and stock row.
|
||||
*/
|
||||
async function seedVariants(
|
||||
prisma: PrismaClient,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
refs: {
|
||||
colourOptionId: string;
|
||||
sizeOptionId: string;
|
||||
colourValueIds: Map<string, string>;
|
||||
sizeValueIds: Map<string, string>;
|
||||
locationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const random = seededRandom(seed.key);
|
||||
let position = 0;
|
||||
|
||||
for (const colourKey of seed.colours) {
|
||||
const colour = COLOURS[colourKey];
|
||||
const colourValueId = colour ? refs.colourValueIds.get(colour.value) : undefined;
|
||||
if (!colour || !colourValueId) continue;
|
||||
|
||||
for (const size of seed.sizes) {
|
||||
const sizeValueId = refs.sizeValueIds.get(size);
|
||||
if (!sizeValueId) continue;
|
||||
|
||||
const sku = `${seed.skuPrefix}-${colour.value.toUpperCase()}-${size.toUpperCase()}`;
|
||||
|
||||
const variant = await prisma.productVariant.upsert({
|
||||
where: { sku },
|
||||
update: {
|
||||
title: `${colour.label.en} / ${size}`,
|
||||
priceAmount: seed.price,
|
||||
salePriceAmount: seed.salePrice ?? null,
|
||||
compareAtAmount: seed.compareAt ?? null,
|
||||
position,
|
||||
},
|
||||
create: {
|
||||
productId,
|
||||
sku,
|
||||
title: `${colour.label.en} / ${size}`,
|
||||
currency: 'VND',
|
||||
priceAmount: seed.price,
|
||||
salePriceAmount: seed.salePrice ?? null,
|
||||
compareAtAmount: seed.compareAt ?? null,
|
||||
weightGrams: seed.weightGrams,
|
||||
position,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
// Composite PK (variantId, optionId) makes "two colours on one variant"
|
||||
// impossible at the database level.
|
||||
for (const [optionId, optionValueId] of [
|
||||
[refs.colourOptionId, colourValueId],
|
||||
[refs.sizeOptionId, sizeValueId],
|
||||
] as const) {
|
||||
await prisma.productVariantOptionValue.upsert({
|
||||
where: { variantId_optionId: { variantId: variant.id, optionId } },
|
||||
update: { optionValueId },
|
||||
create: { variantId: variant.id, optionId, optionValueId },
|
||||
});
|
||||
}
|
||||
|
||||
// A realistic spread: mostly healthy, some low, a few genuinely sold out
|
||||
// so the out-of-stock UI is exercised without being contrived.
|
||||
const roll = random();
|
||||
const onHand =
|
||||
roll < 0.08 ? 0 : roll < 0.2 ? 1 + Math.floor(random() * 4) : 8 + Math.floor(random() * 40);
|
||||
|
||||
await prisma.stockLevel.upsert({
|
||||
where: { variantId_locationId: { variantId: variant.id, locationId: refs.locationId } },
|
||||
update: { onHand },
|
||||
create: { variantId: variant.id, locationId: refs.locationId, onHand, reserved: 0 },
|
||||
});
|
||||
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors ProductsRepository.recomputePricing.
|
||||
*
|
||||
* Duplicated here rather than imported because the seed runs through `tsx`
|
||||
* outside the Nest container — instantiating the DI graph to compute four
|
||||
* columns would be the more fragile choice.
|
||||
*/
|
||||
async function recomputePricing(prisma: PrismaClient, productId: string): Promise<void> {
|
||||
const variants = await prisma.productVariant.findMany({
|
||||
where: { productId, status: 'ACTIVE', deletedAt: null },
|
||||
select: {
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (variants.length === 0) return;
|
||||
|
||||
const effective = variants.map((v) => v.salePriceAmount ?? v.priceAmount);
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
minPriceAmount: Math.min(...effective),
|
||||
maxPriceAmount: Math.max(...effective),
|
||||
isOnSale: variants.some((v) => v.salePriceAmount !== null),
|
||||
inStock: variants.some((v) => v.stockLevels.some((l) => l.onHand - l.reserved > 0)),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Demo catalog content.
|
||||
*
|
||||
* Brands are fictional on purpose — seeding a repository with real trademarks
|
||||
* is a liability nobody needs, and it makes it obvious this is sample data.
|
||||
*
|
||||
* Every entity carries both locales so the language switcher has something real
|
||||
* to switch between. A few fields are deliberately left untranslated (see
|
||||
* `sizes`, and the description on one product) to exercise the field-level
|
||||
* fallback in `coalesce`.
|
||||
*/
|
||||
|
||||
export interface Localised {
|
||||
vi: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export interface BrandSeed {
|
||||
key: string;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
description: Localised;
|
||||
}
|
||||
|
||||
export const BRANDS: BrandSeed[] = [
|
||||
{
|
||||
key: 'velocity',
|
||||
name: { vi: 'Velocity', en: 'Velocity' },
|
||||
slug: { vi: 'velocity', en: 'velocity' },
|
||||
description: {
|
||||
vi: 'Đồ chạy bộ hiệu suất cao, thiết kế cho tốc độ và quãng đường dài.',
|
||||
en: 'High-performance running gear built for speed and distance.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ridgeline',
|
||||
name: { vi: 'Ridgeline', en: 'Ridgeline' },
|
||||
slug: { vi: 'ridgeline', en: 'ridgeline' },
|
||||
description: {
|
||||
vi: 'Trang phục tập luyện bền bỉ cho phòng gym và ngoài trời.',
|
||||
en: 'Durable training apparel for the gym and the outdoors.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'kinetix',
|
||||
name: { vi: 'Kinetix', en: 'Kinetix' },
|
||||
slug: { vi: 'kinetix', en: 'kinetix' },
|
||||
description: {
|
||||
vi: 'Phong cách thể thao đường phố, mặc từ sân tập đến phố.',
|
||||
en: 'Sport-street essentials that move from session to street.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface CategorySeed {
|
||||
path: string;
|
||||
parentPath: string | null;
|
||||
position: number;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
}
|
||||
|
||||
export const CATEGORIES: CategorySeed[] = [
|
||||
{
|
||||
path: 'men',
|
||||
parentPath: null,
|
||||
position: 0,
|
||||
name: { vi: 'Nam', en: 'Men' },
|
||||
slug: { vi: 'nam', en: 'men' },
|
||||
},
|
||||
{
|
||||
path: 'women',
|
||||
parentPath: null,
|
||||
position: 1,
|
||||
name: { vi: 'Nữ', en: 'Women' },
|
||||
slug: { vi: 'nu', en: 'women' },
|
||||
},
|
||||
|
||||
{
|
||||
path: 'men/tops',
|
||||
parentPath: 'men',
|
||||
position: 0,
|
||||
name: { vi: 'Áo', en: 'Tops' },
|
||||
slug: { vi: 'ao-nam', en: 'mens-tops' },
|
||||
},
|
||||
{
|
||||
path: 'men/bottoms',
|
||||
parentPath: 'men',
|
||||
position: 1,
|
||||
name: { vi: 'Quần', en: 'Bottoms' },
|
||||
slug: { vi: 'quan-nam', en: 'mens-bottoms' },
|
||||
},
|
||||
{
|
||||
path: 'men/footwear',
|
||||
parentPath: 'men',
|
||||
position: 2,
|
||||
name: { vi: 'Giày', en: 'Footwear' },
|
||||
slug: { vi: 'giay-nam', en: 'mens-footwear' },
|
||||
},
|
||||
|
||||
{
|
||||
path: 'women/tops',
|
||||
parentPath: 'women',
|
||||
position: 0,
|
||||
name: { vi: 'Áo', en: 'Tops' },
|
||||
slug: { vi: 'ao-nu', en: 'womens-tops' },
|
||||
},
|
||||
{
|
||||
path: 'women/bottoms',
|
||||
parentPath: 'women',
|
||||
position: 1,
|
||||
name: { vi: 'Quần', en: 'Bottoms' },
|
||||
slug: { vi: 'quan-nu', en: 'womens-bottoms' },
|
||||
},
|
||||
{
|
||||
path: 'women/footwear',
|
||||
parentPath: 'women',
|
||||
position: 2,
|
||||
name: { vi: 'Giày', en: 'Footwear' },
|
||||
slug: { vi: 'giay-nu', en: 'womens-footwear' },
|
||||
},
|
||||
];
|
||||
|
||||
export interface CollectionSeed {
|
||||
key: string;
|
||||
position: number;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
description: Localised;
|
||||
}
|
||||
|
||||
export const COLLECTIONS: CollectionSeed[] = [
|
||||
{
|
||||
key: 'new-arrivals',
|
||||
position: 0,
|
||||
name: { vi: 'Hàng mới về', en: 'New Arrivals' },
|
||||
slug: { vi: 'hang-moi-ve', en: 'new-arrivals' },
|
||||
description: {
|
||||
vi: 'Những mẫu mới nhất vừa cập bến.',
|
||||
en: 'The latest pieces, just landed.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'summer-drop',
|
||||
position: 1,
|
||||
name: { vi: 'Bộ sưu tập Hè', en: 'Summer Drop' },
|
||||
slug: { vi: 'bo-suu-tap-he', en: 'summer-drop' },
|
||||
description: {
|
||||
vi: 'Chất liệu nhẹ, thoáng khí cho những ngày nóng nhất.',
|
||||
en: 'Lightweight, breathable fabrics for the hottest sessions.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sale',
|
||||
position: 2,
|
||||
name: { vi: 'Giảm giá', en: 'Sale' },
|
||||
slug: { vi: 'giam-gia', en: 'sale' },
|
||||
description: {
|
||||
vi: 'Ưu đãi có thời hạn trên các mẫu chọn lọc.',
|
||||
en: 'Limited-time pricing on selected styles.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface ColourSeed {
|
||||
value: string;
|
||||
label: Localised;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export const COLOURS: Record<string, ColourSeed> = {
|
||||
black: { value: 'black', label: { vi: 'Đen', en: 'Black' }, hex: '#16181d' },
|
||||
white: { value: 'white', label: { vi: 'Trắng', en: 'White' }, hex: '#f2f2ef' },
|
||||
slate: { value: 'slate', label: { vi: 'Xám đá', en: 'Slate' }, hex: '#4a5361' },
|
||||
volt: { value: 'volt', label: { vi: 'Xanh neon', en: 'Volt' }, hex: '#c7f227' },
|
||||
crimson: { value: 'crimson', label: { vi: 'Đỏ thẫm', en: 'Crimson' }, hex: '#b4232a' },
|
||||
navy: { value: 'navy', label: { vi: 'Xanh navy', en: 'Navy' }, hex: '#1d2a45' },
|
||||
sand: { value: 'sand', label: { vi: 'Be cát', en: 'Sand' }, hex: '#d8c9b0' },
|
||||
teal: { value: 'teal', label: { vi: 'Xanh ngọc', en: 'Teal' }, hex: '#1f6f6b' },
|
||||
};
|
||||
|
||||
export type Gender = 'MEN' | 'WOMEN' | 'KIDS' | 'UNISEX';
|
||||
export type Sport = 'RUNNING' | 'FOOTBALL' | 'TRAINING' | 'GYM' | 'BADMINTON' | 'LIFESTYLE';
|
||||
|
||||
export interface ProductSeed {
|
||||
key: string;
|
||||
skuPrefix: string;
|
||||
brand: string;
|
||||
categoryPath: string;
|
||||
genders: Gender[];
|
||||
sports: Sport[];
|
||||
collections: string[];
|
||||
colours: string[];
|
||||
sizes: string[];
|
||||
/** VND, integer minor units (VND has no minor unit). */
|
||||
price: number;
|
||||
/** Present on discounted products only. */
|
||||
salePrice?: number;
|
||||
compareAt?: number;
|
||||
weightGrams: number;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
shortDescription: Localised;
|
||||
description: Localised;
|
||||
attributes: { key: string; label: Localised; value: Localised }[];
|
||||
}
|
||||
|
||||
const APPAREL_SIZES = ['XS', 'S', 'M', 'L', 'XL'];
|
||||
const SHOE_SIZES = ['39', '40', '41', '42', '43'];
|
||||
|
||||
export const PRODUCTS: ProductSeed[] = [
|
||||
{
|
||||
key: 'aero-run-tee',
|
||||
skuPrefix: 'VEL-ART',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN'],
|
||||
sports: ['RUNNING', 'TRAINING'],
|
||||
collections: ['new-arrivals', 'summer-drop'],
|
||||
colours: ['black', 'white', 'volt'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 690000,
|
||||
weightGrams: 135,
|
||||
name: { vi: 'Áo Chạy Bộ Aero', en: 'Aero Run Tee' },
|
||||
slug: { vi: 'ao-chay-bo-aero', en: 'aero-run-tee' },
|
||||
shortDescription: {
|
||||
vi: 'Áo chạy siêu nhẹ với công nghệ thoát ẩm.',
|
||||
en: 'Ultralight running tee with moisture-wicking knit.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Được thiết kế cho những buổi chạy dài dưới nắng. Chất liệu dệt kim siêu nhẹ đưa mồ hôi ra bề mặt và khô nhanh, trong khi đường may phẳng loại bỏ ma sát ở vai và sườn.',
|
||||
en: 'Built for long runs in the heat. An ultralight knit pulls sweat to the surface and dries fast, while flatlock seams remove the friction points at the shoulder and ribs.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '92% Polyester, 8% Elastane', en: '92% polyester, 8% elastane' },
|
||||
},
|
||||
{
|
||||
key: 'fit',
|
||||
label: { vi: 'Kiểu dáng', en: 'Fit' },
|
||||
value: { vi: 'Ôm vừa', en: 'Athletic fit' },
|
||||
},
|
||||
{
|
||||
key: 'care',
|
||||
label: { vi: 'Bảo quản', en: 'Care' },
|
||||
value: { vi: 'Giặt máy lạnh, không sấy', en: 'Machine wash cold, do not tumble dry' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tempo-split-short',
|
||||
skuPrefix: 'VEL-TSS',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/bottoms',
|
||||
genders: ['MEN'],
|
||||
sports: ['RUNNING'],
|
||||
collections: ['summer-drop'],
|
||||
colours: ['black', 'navy'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 850000,
|
||||
salePrice: 595000,
|
||||
compareAt: 850000,
|
||||
weightGrams: 110,
|
||||
name: { vi: 'Quần Short Chạy Tempo', en: 'Tempo Split Short' },
|
||||
slug: { vi: 'quan-short-chay-tempo', en: 'tempo-split-short' },
|
||||
shortDescription: {
|
||||
vi: 'Short xẻ tà 4 inch với lớp lót liền.',
|
||||
en: 'Four-inch split-hem short with a bonded liner.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Short xẻ tà cho sải chân tự do tuyệt đối. Lớp lót liền giữ form, túi sau có khoá kéo đủ chỗ cho chìa khoá và thẻ.',
|
||||
en: 'A split hem gives the stride complete freedom. The bonded liner holds its shape, and the zipped rear pocket takes a key and a card without bouncing.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '100% Polyester tái chế', en: '100% recycled polyester' },
|
||||
},
|
||||
{
|
||||
key: 'inseam',
|
||||
label: { vi: 'Chiều dài ống', en: 'Inseam' },
|
||||
value: { vi: '10 cm', en: '10 cm' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'forge-training-hoodie',
|
||||
skuPrefix: 'RDG-FTH',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN', 'UNISEX'],
|
||||
sports: ['GYM', 'TRAINING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['slate', 'black', 'sand'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1490000,
|
||||
weightGrams: 520,
|
||||
name: { vi: 'Áo Hoodie Tập Luyện Forge', en: 'Forge Training Hoodie' },
|
||||
slug: { vi: 'ao-hoodie-tap-luyen-forge', en: 'forge-training-hoodie' },
|
||||
shortDescription: {
|
||||
vi: 'Hoodie nỉ dày cho buổi khởi động và nghỉ giữa hiệp.',
|
||||
en: 'Heavyweight fleece hoodie for warm-ups and rest days.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Nỉ bông chải dày 380gsm giữ nhiệt qua phần khởi động, cổ tay bo sườn giữ ống tay đúng chỗ khi kéo tạ.',
|
||||
en: 'A brushed 380gsm fleece holds heat through the warm-up, and ribbed cuffs keep the sleeves where you put them under the bar.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '80% Cotton, 20% Polyester', en: '80% cotton, 20% polyester' },
|
||||
},
|
||||
{
|
||||
key: 'weight',
|
||||
label: { vi: 'Định lượng', en: 'Fabric weight' },
|
||||
value: { vi: '380 gsm', en: '380 gsm' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'grid-training-short',
|
||||
skuPrefix: 'RDG-GTS',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/bottoms',
|
||||
genders: ['MEN'],
|
||||
sports: ['GYM', 'TRAINING'],
|
||||
collections: ['sale'],
|
||||
colours: ['black', 'slate'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 790000,
|
||||
salePrice: 550000,
|
||||
compareAt: 790000,
|
||||
weightGrams: 190,
|
||||
name: { vi: 'Quần Short Tập Grid', en: 'Grid Training Short' },
|
||||
slug: { vi: 'quan-short-tap-grid', en: 'grid-training-short' },
|
||||
shortDescription: {
|
||||
vi: 'Short co giãn 4 chiều cho squat và lunge.',
|
||||
en: 'Four-way stretch short for squats and lunges.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Vải co giãn bốn chiều với gusset ở đáy quần cho biên độ squat sâu mà không bị kéo căng.',
|
||||
en: 'Four-way stretch woven with a gusseted crotch, so a deep squat never pulls the waistband down with it.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '88% Nylon, 12% Elastane', en: '88% nylon, 12% elastane' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flux-seamless-leggings',
|
||||
skuPrefix: 'RDG-FSL',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'women/bottoms',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['GYM', 'TRAINING'],
|
||||
collections: ['new-arrivals', 'summer-drop'],
|
||||
colours: ['black', 'teal', 'sand'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1290000,
|
||||
weightGrams: 240,
|
||||
name: { vi: 'Quần Legging Liền Mạch Flux', en: 'Flux Seamless Leggings' },
|
||||
slug: { vi: 'quan-legging-lien-mach-flux', en: 'flux-seamless-leggings' },
|
||||
shortDescription: {
|
||||
vi: 'Legging dệt liền mạch, cạp cao, không lộ.',
|
||||
en: 'Seamless high-waist leggings with squat-proof knit.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Dệt liền mạch loại bỏ đường may ở đùi trong — nơi gây khó chịu nhất. Cạp cao ôm sát và giữ nguyên vị trí suốt buổi tập.',
|
||||
en: 'A seamless knit removes the inner-thigh seam entirely, which is where irritation always starts. The high waistband stays put for the whole session.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '75% Nylon, 25% Elastane', en: '75% nylon, 25% elastane' },
|
||||
},
|
||||
{
|
||||
key: 'rise',
|
||||
label: { vi: 'Cạp quần', en: 'Rise' },
|
||||
value: { vi: 'Cạp cao', en: 'High rise' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'aria-training-bra',
|
||||
skuPrefix: 'RDG-ATB',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'women/tops',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['GYM', 'TRAINING', 'RUNNING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['black', 'white', 'crimson'],
|
||||
sizes: ['XS', 'S', 'M', 'L'],
|
||||
price: 690000,
|
||||
weightGrams: 95,
|
||||
name: { vi: 'Áo Bra Tập Luyện Aria', en: 'Aria Training Bra' },
|
||||
slug: { vi: 'ao-bra-tap-luyen-aria', en: 'aria-training-bra' },
|
||||
shortDescription: {
|
||||
vi: 'Nâng đỡ mức trung bình, lưng chữ Y thoáng khí.',
|
||||
en: 'Medium support with a breathable Y-back.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Nâng đỡ mức trung bình cho tập tạ và chạy nhẹ. Lưng chữ Y mở rộng vai và giữ dây không trượt.',
|
||||
en: 'Medium support for lifting and easy runs. The Y-back opens the shoulders and keeps the straps from wandering.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'support',
|
||||
label: { vi: 'Mức nâng đỡ', en: 'Support' },
|
||||
value: { vi: 'Trung bình', en: 'Medium' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stride-runner',
|
||||
skuPrefix: 'VEL-STR',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/footwear',
|
||||
genders: ['MEN', 'UNISEX'],
|
||||
sports: ['RUNNING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['white', 'volt', 'navy'],
|
||||
sizes: SHOE_SIZES,
|
||||
price: 3290000,
|
||||
weightGrams: 245,
|
||||
name: { vi: 'Giày Chạy Stride', en: 'Stride Runner' },
|
||||
slug: { vi: 'giay-chay-stride', en: 'stride-runner' },
|
||||
shortDescription: {
|
||||
vi: 'Giày chạy hằng ngày với đế đàn hồi cao.',
|
||||
en: 'Daily trainer with a high-rebound midsole.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Đế giữa siêu nhẹ trả lại năng lượng qua từng bước, phù hợp cho cả buổi chạy phục hồi lẫn chạy dài cuối tuần.',
|
||||
en: 'A supercritical foam midsole returns energy step after step — equally at home on a recovery jog and a weekend long run.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'drop',
|
||||
label: { vi: 'Độ chênh gót', en: 'Heel drop' },
|
||||
value: { vi: '8 mm', en: '8 mm' },
|
||||
},
|
||||
{
|
||||
key: 'use',
|
||||
label: { vi: 'Mục đích', en: 'Best for' },
|
||||
value: { vi: 'Chạy đường nhựa hằng ngày', en: 'Daily road running' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pivot-court-shoe',
|
||||
skuPrefix: 'KNX-PCS',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'women/footwear',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['BADMINTON'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['white', 'teal'],
|
||||
sizes: ['36', '37', '38', '39'],
|
||||
price: 2490000,
|
||||
salePrice: 1990000,
|
||||
compareAt: 2490000,
|
||||
weightGrams: 280,
|
||||
name: { vi: 'Giày Cầu Lông Pivot', en: 'Pivot Court Shoe' },
|
||||
slug: { vi: 'giay-cau-long-pivot', en: 'pivot-court-shoe' },
|
||||
shortDescription: {
|
||||
vi: 'Đế bám sân, hỗ trợ đổi hướng nhanh.',
|
||||
en: 'Grippy non-marking outsole for fast direction changes.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Đế ngoài không để lại vệt, bám chắc khi bước lunge về trước và ổn định phần giữa bàn chân khi đổi hướng đột ngột.',
|
||||
en: 'A non-marking outsole bites on the forward lunge, and a reinforced midfoot keeps the shoe under you through sharp changes of direction.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'outsole',
|
||||
label: { vi: 'Đế ngoài', en: 'Outsole' },
|
||||
value: { vi: 'Cao su không vệt', en: 'Non-marking rubber' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'match-football-jersey',
|
||||
skuPrefix: 'KNX-MFJ',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN', 'UNISEX'],
|
||||
sports: ['FOOTBALL'],
|
||||
collections: ['summer-drop'],
|
||||
colours: ['crimson', 'navy', 'white'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 950000,
|
||||
weightGrams: 155,
|
||||
name: { vi: 'Áo Đấu Bóng Đá Match', en: 'Match Football Jersey' },
|
||||
slug: { vi: 'ao-dau-bong-da-match', en: 'match-football-jersey' },
|
||||
shortDescription: {
|
||||
vi: 'Áo đấu thoáng khí với lưới thoát nhiệt.',
|
||||
en: 'Breathable match jersey with ventilated mesh.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Các tấm lưới đặt đúng vị trí toả nhiệt ở lưng và nách, phần thân dài hơn giữ áo trong quần suốt trận.',
|
||||
en: 'Mesh panels sit exactly where heat builds — the back and the underarms — and a longer body keeps the shirt tucked for the full ninety.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '100% Polyester tái chế', en: '100% recycled polyester' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'district-track-jacket',
|
||||
skuPrefix: 'KNX-DTJ',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['UNISEX'],
|
||||
sports: ['LIFESTYLE'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['black', 'sand', 'teal'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1690000,
|
||||
weightGrams: 420,
|
||||
name: { vi: 'Áo Khoác Track District', en: 'District Track Jacket' },
|
||||
slug: { vi: 'ao-khoac-track-district', en: 'district-track-jacket' },
|
||||
shortDescription: {
|
||||
vi: 'Áo khoác track phom retro cho phố xá.',
|
||||
en: 'Retro-cut track jacket for off-duty days.',
|
||||
},
|
||||
// Vietnamese description intentionally omitted below to exercise the
|
||||
// field-level translation fallback — the VI page shows this English copy.
|
||||
description: {
|
||||
vi: '',
|
||||
en: 'A retro track cut with a full-length zip and side stripes, in a heavier tricot that hangs properly instead of clinging.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'fit',
|
||||
label: { vi: 'Kiểu dáng', en: 'Fit' },
|
||||
value: { vi: 'Rộng vừa', en: 'Relaxed fit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'core-lifting-tank',
|
||||
skuPrefix: 'RDG-CLT',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN'],
|
||||
sports: ['GYM'],
|
||||
collections: ['sale', 'summer-drop'],
|
||||
colours: ['black', 'slate', 'volt'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 490000,
|
||||
salePrice: 340000,
|
||||
compareAt: 490000,
|
||||
weightGrams: 120,
|
||||
name: { vi: 'Áo Ba Lỗ Tập Tạ Core', en: 'Core Lifting Tank' },
|
||||
slug: { vi: 'ao-ba-lo-tap-ta-core', en: 'core-lifting-tank' },
|
||||
shortDescription: {
|
||||
vi: 'Áo ba lỗ nách rộng, thoáng cho ngày tập tạ.',
|
||||
en: 'Drop-armhole tank that stays out of the way under the bar.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Nách khoét sâu cho vai chuyển động tự do khi đẩy qua đầu, thân áo đủ dài để không bị kéo lên khi deadlift.',
|
||||
en: 'A dropped armhole lets the shoulder travel freely overhead, and the body runs long enough that it does not ride up mid-deadlift.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '60% Cotton, 40% Polyester', en: '60% cotton, 40% polyester' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lumen-running-jacket',
|
||||
skuPrefix: 'VEL-LRJ',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'women/tops',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['RUNNING', 'LIFESTYLE'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['volt', 'black'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1890000,
|
||||
weightGrams: 180,
|
||||
name: { vi: 'Áo Khoác Chạy Lumen', en: 'Lumen Running Jacket' },
|
||||
slug: { vi: 'ao-khoac-chay-lumen', en: 'lumen-running-jacket' },
|
||||
shortDescription: {
|
||||
vi: 'Áo khoác gió nhẹ, phản quang 360°.',
|
||||
en: 'Featherweight windbreaker with 360° reflectivity.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Chắn gió mà không giữ nhiệt, chi tiết phản quang quanh thân giúp bạn được nhìn thấy từ mọi hướng khi chạy sớm hoặc tối.',
|
||||
en: 'Cuts the wind without trapping heat, and reflective detailing wraps the whole body so early and late runs stay visible from every angle.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'packable',
|
||||
label: { vi: 'Gấp gọn', en: 'Packable' },
|
||||
value: { vi: 'Gấp vào túi ngực', en: 'Packs into its chest pocket' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,114 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
/**
|
||||
* A dependency-free PNG encoder for seed placeholder imagery.
|
||||
*
|
||||
* Why not just point MediaAsset rows at URLs that do not exist: the seed should
|
||||
* exercise the real media path — bytes in object storage, only a key in
|
||||
* PostgreSQL (ADR-0009). Broken images would hide a misconfigured bucket, a
|
||||
* wrong public URL or a missing Next.js `remotePatterns` entry until much later.
|
||||
*
|
||||
* Why not `sharp`: a native dependency for solid-colour rectangles is not a
|
||||
* trade worth making. A solid PNG is ~60 lines of well-specified format.
|
||||
*/
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
|
||||
const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(typeAndData), 0);
|
||||
|
||||
return Buffer.concat([length, typeAndData, crc]);
|
||||
}
|
||||
|
||||
export interface Rgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** `#1a2b3c` → { r, g, b }. Falls back to mid-grey on anything unparseable. */
|
||||
export function hexToRgb(hex: string): Rgb {
|
||||
const match = /^#?([\da-f]{6})$/i.exec(hex.trim());
|
||||
if (!match?.[1]) return { r: 128, g: 128, b: 128 };
|
||||
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
return { r: (value >> 16) & 0xff, g: (value >> 8) & 0xff, b: value & 0xff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a solid PNG with a subtle vertical gradient, which reads as a studio
|
||||
* backdrop rather than a flat colour swatch in a product grid.
|
||||
*/
|
||||
export function solidPng(width: number, height: number, base: Rgb): Buffer {
|
||||
const bytesPerRow = width * 3 + 1; // +1 for the per-row filter byte
|
||||
const raw = Buffer.alloc(bytesPerRow * height);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowStart = y * bytesPerRow;
|
||||
raw[rowStart] = 0; // filter type 0 (None)
|
||||
|
||||
// -8%..+8% luminance across the height.
|
||||
const factor = 0.92 + (y / Math.max(height - 1, 1)) * 0.16;
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = rowStart + 1 + x * 3;
|
||||
raw[offset] = clampByte(base.r * factor);
|
||||
raw[offset + 1] = clampByte(base.g * factor);
|
||||
raw[offset + 2] = clampByte(base.b * factor);
|
||||
}
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr.writeUInt8(8, 8); // bit depth
|
||||
ihdr.writeUInt8(2, 9); // colour type 2 = truecolour RGB
|
||||
ihdr.writeUInt8(0, 10); // compression
|
||||
ihdr.writeUInt8(0, 11); // filter
|
||||
ihdr.writeUInt8(0, 12); // interlace
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A 1×1 PNG of the same colour, base64-encoded, used as the LQIP blur
|
||||
* placeholder so grids never flash empty while images load.
|
||||
*/
|
||||
export function blurDataUrl(base: Rgb): string {
|
||||
return `data:image/png;base64,${solidPng(1, 1, base).toString('base64')}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||
import { MediaUrlModule } from './common/media/media.module';
|
||||
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
|
||||
import type { AppConfig } from './config/configuration';
|
||||
import { EventsModule } from './infrastructure/events/events.module';
|
||||
@@ -53,6 +54,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
|
||||
RedisModule,
|
||||
StorageModule,
|
||||
EventsModule,
|
||||
MediaUrlModule,
|
||||
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [APP_CONFIG],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { RequestLocale, parseAcceptLanguage } from './locale.decorator';
|
||||
export {
|
||||
coalesce,
|
||||
coalesceRequired,
|
||||
pickTranslation,
|
||||
toDbLocale,
|
||||
type DbLocale,
|
||||
} from './translation.util';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from '@sport/types';
|
||||
|
||||
/**
|
||||
* Resolves the locale for a request, in priority order:
|
||||
*
|
||||
* 1. `?locale=` query parameter — explicit, cacheable, self-describing.
|
||||
* 2. `Accept-Language` header — the browser's preference.
|
||||
* 3. DEFAULT_LOCALE (`vi`) — this is a Vietnamese store.
|
||||
*
|
||||
* The query parameter wins because a URL should fully determine its response.
|
||||
* If language depended only on a header, the same link would render differently
|
||||
* for different people and every CDN entry would need `Vary: Accept-Language`,
|
||||
* which fragments the cache badly.
|
||||
*/
|
||||
export const RequestLocale = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): Locale => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const fromQuery = request.query['locale'];
|
||||
if (typeof fromQuery === 'string' && isLocale(fromQuery)) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
return parseAcceptLanguage(request.header('accept-language')) ?? DEFAULT_LOCALE;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Minimal `Accept-Language` parser: ordered by q-value, first supported wins.
|
||||
* Deliberately not a full RFC 4647 implementation — we support two locales and
|
||||
* a dependency for that would be absurd.
|
||||
*/
|
||||
export function parseAcceptLanguage(header: string | undefined): Locale | null {
|
||||
if (!header) return null;
|
||||
|
||||
const candidates = header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag = '', ...params] = part.trim().split(';');
|
||||
const qParam = params.find((param) => param.trim().startsWith('q='));
|
||||
const quality = qParam ? Number.parseFloat(qParam.trim().slice(2)) : 1;
|
||||
return { tag: tag.trim().toLowerCase(), quality: Number.isNaN(quality) ? 0 : quality };
|
||||
})
|
||||
.filter((candidate) => candidate.tag.length > 0)
|
||||
.sort((a, b) => b.quality - a.quality);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// `vi-VN` and `vi` both resolve to `vi`.
|
||||
const base = candidate.tag.split('-')[0] ?? '';
|
||||
if (isLocale(base)) return base;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { parseAcceptLanguage } from './locale.decorator';
|
||||
import { coalesce, coalesceRequired, pickTranslation } from './translation.util';
|
||||
|
||||
/**
|
||||
* Translation fallback is load-bearing: it decides what a shopper actually
|
||||
* reads when a merchandiser has only half-finished a translation. These tests
|
||||
* pin the exact behaviour so a refactor cannot quietly turn a partially
|
||||
* translated product into a page of blanks.
|
||||
*/
|
||||
describe('pickTranslation', () => {
|
||||
const viRow = { locale: 'VI' as const, name: 'Áo Chạy Bộ' };
|
||||
const enRow = { locale: 'EN' as const, name: 'Running Tee' };
|
||||
const rows = [viRow, enRow];
|
||||
|
||||
it('selects the row matching the requested locale', () => {
|
||||
expect(pickTranslation(rows, 'vi')?.name).toBe('Áo Chạy Bộ');
|
||||
expect(pickTranslation(rows, 'en')?.name).toBe('Running Tee');
|
||||
});
|
||||
|
||||
it('returns undefined when the locale has no row', () => {
|
||||
expect(pickTranslation([enRow], 'vi')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tolerates a missing translations array', () => {
|
||||
expect(pickTranslation(undefined, 'vi')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coalesce', () => {
|
||||
it('prefers the first present value', () => {
|
||||
expect(coalesce('translated', 'base')).toBe('translated');
|
||||
});
|
||||
|
||||
it('falls back past null and undefined', () => {
|
||||
expect(coalesce(null, undefined, 'base')).toBe('base');
|
||||
});
|
||||
|
||||
it('treats a blank string as missing, not as an intentional blank', () => {
|
||||
// A translation row saved with an empty description is a gap. Rendering it
|
||||
// would show an empty product page instead of the original copy.
|
||||
expect(coalesce(' ', 'base')).toBe('base');
|
||||
});
|
||||
|
||||
it('returns null when nothing is present', () => {
|
||||
expect(coalesce(null, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('coalesceRequired degrades to an empty string rather than throwing', () => {
|
||||
expect(coalesceRequired(null, undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAcceptLanguage', () => {
|
||||
it('matches a region-qualified tag to its base locale', () => {
|
||||
expect(parseAcceptLanguage('vi-VN,vi;q=0.9')).toBe('vi');
|
||||
});
|
||||
|
||||
it('honours q-value ordering rather than document order', () => {
|
||||
expect(parseAcceptLanguage('fr;q=0.9,en;q=1.0')).toBe('en');
|
||||
});
|
||||
|
||||
it('skips unsupported languages and takes the first supported one', () => {
|
||||
expect(parseAcceptLanguage('de-DE,fr;q=0.8,en;q=0.6')).toBe('en');
|
||||
});
|
||||
|
||||
it('returns null when nothing is supported, so the caller applies the default', () => {
|
||||
expect(parseAcceptLanguage('de-DE,fr')).toBeNull();
|
||||
expect(parseAcceptLanguage(undefined)).toBeNull();
|
||||
expect(parseAcceptLanguage('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { LOCALE_TO_DB, type Locale } from '@sport/types';
|
||||
|
||||
/**
|
||||
* Translation resolution, used by every catalog mapper.
|
||||
*
|
||||
* Rules:
|
||||
* - A translation row for the requested locale wins.
|
||||
* - Any field that is null/empty in that row falls back to the base row.
|
||||
* - No translation row at all falls back entirely to the base row.
|
||||
*
|
||||
* Field-level fallback matters: a merchandiser who has translated a product
|
||||
* name but not yet its long description should get the translated name and the
|
||||
* original description, not a blank page. The API resolves all of this, so the
|
||||
* frontend never sees a translation table or writes fallback logic.
|
||||
*/
|
||||
|
||||
/** Prisma's `Locale` enum values. */
|
||||
export type DbLocale = 'VI' | 'EN';
|
||||
|
||||
export function toDbLocale(locale: Locale): DbLocale {
|
||||
return LOCALE_TO_DB[locale];
|
||||
}
|
||||
|
||||
interface LocalisedRow {
|
||||
locale: DbLocale;
|
||||
}
|
||||
|
||||
/** Picks the row matching the locale, if present. */
|
||||
export function pickTranslation<T extends LocalisedRow>(
|
||||
translations: readonly T[] | undefined,
|
||||
locale: Locale,
|
||||
): T | undefined {
|
||||
const target = toDbLocale(locale);
|
||||
return translations?.find((translation) => translation.locale === target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first non-empty value. Empty strings count as missing — a
|
||||
* translation row saved with a blank field is a gap, not an intentional blank.
|
||||
*/
|
||||
export function coalesce<T>(...values: readonly (T | null | undefined)[]): T | null {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined) continue;
|
||||
if (typeof value === 'string' && value.trim().length === 0) continue;
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `coalesce` for fields that must always produce a string. */
|
||||
export function coalesceRequired(...values: readonly (string | null | undefined)[]): string {
|
||||
return coalesce(...values) ?? '';
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { ImageRef, ProductImage } from '@sport/types';
|
||||
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
/** The shape every mapper receives from a `media` include. */
|
||||
export interface MediaAssetRow {
|
||||
id: string;
|
||||
storageKey: string;
|
||||
altText: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
blurDataUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a stored object key into a public URL.
|
||||
*
|
||||
* URLs are composed at read time and never persisted (ADR-0009), so moving to a
|
||||
* different bucket or CDN domain is a config change rather than a data
|
||||
* migration. This is the only place that knows the media base URL.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MediaUrlService {
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||
|
||||
url(storageKey: string): string {
|
||||
return `${this.config.storage.publicUrl}/${storageKey}`;
|
||||
}
|
||||
|
||||
toImageRef(media: MediaAssetRow | null | undefined): ImageRef | null {
|
||||
if (!media) return null;
|
||||
|
||||
return {
|
||||
id: media.id,
|
||||
url: this.url(media.storageKey),
|
||||
altText: media.altText,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
blurDataUrl: media.blurDataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
toProductImage(
|
||||
row:
|
||||
{ media: MediaAssetRow; position: number; optionValueId: string | null } | null | undefined,
|
||||
): ProductImage | null {
|
||||
if (!row) return null;
|
||||
|
||||
const ref = this.toImageRef(row.media);
|
||||
if (!ref) return null;
|
||||
|
||||
return { ...ref, position: row.position, optionValueId: row.optionValueId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { MediaUrlService } from './media-url.service';
|
||||
|
||||
/**
|
||||
* Global because every catalog mapper needs it and it holds no state beyond
|
||||
* one config value.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MediaUrlService],
|
||||
exports: [MediaUrlService],
|
||||
})
|
||||
export class MediaUrlModule {}
|
||||
@@ -10,10 +10,22 @@
|
||||
*/
|
||||
export const CACHE_KEYS = {
|
||||
// --- Catalog read cache (invalidated on write, TTL as a safety net) -------
|
||||
productBySlug: (slug: string) => `catalog:product:slug:${slug}`,
|
||||
productListing: (fingerprint: string) => `catalog:listing:${fingerprint}`,
|
||||
categoryTree: () => 'catalog:category:tree',
|
||||
navigationMenu: () => 'catalog:navigation',
|
||||
//
|
||||
// Every catalog key is namespaced by locale. Forgetting that is the classic
|
||||
// i18n cache bug: the first visitor's language gets served to everyone.
|
||||
productBySlug: (locale: string, slug: string) => `catalog:${locale}:product:slug:${slug}`,
|
||||
productListing: (locale: string, fingerprint: string) =>
|
||||
`catalog:${locale}:listing:${fingerprint}`,
|
||||
categoryTree: (locale: string) => `catalog:${locale}:category:tree`,
|
||||
categoryBySlug: (locale: string, slug: string) => `catalog:${locale}:category:slug:${slug}`,
|
||||
collectionList: (locale: string) => `catalog:${locale}:collection:list`,
|
||||
collectionBySlug: (locale: string, slug: string) => `catalog:${locale}:collection:slug:${slug}`,
|
||||
brandList: (locale: string) => `catalog:${locale}:brand:list`,
|
||||
brandBySlug: (locale: string, slug: string) => `catalog:${locale}:brand:slug:${slug}`,
|
||||
navigationMenu: (locale: string) => `catalog:${locale}:navigation`,
|
||||
|
||||
/** Prefix used to drop the entire catalog cache on a write. */
|
||||
catalogPrefix: () => 'catalog:',
|
||||
|
||||
// --- Guest cart (authoritative until checkout, then persisted) -----------
|
||||
guestCart: (cartToken: string) => `cart:guest:${cartToken}`,
|
||||
@@ -40,6 +52,8 @@ export const CACHE_TTL = {
|
||||
productDetail: 300,
|
||||
productListing: 60,
|
||||
categoryTree: 900,
|
||||
collectionList: 300,
|
||||
brandList: 900,
|
||||
navigation: 900,
|
||||
guestCart: 60 * 60 * 24 * 30,
|
||||
otp: 300,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { Brand, Locale } from '@sport/types';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { RequestLocale } from '@/common/i18n';
|
||||
|
||||
import { BrandsService } from './brands.service';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('brands')
|
||||
export class BrandsController {
|
||||
constructor(private readonly brandsService: BrandsService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List active brands' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
@ApiOkResponse({ description: 'Brands, localised.' })
|
||||
list(@RequestLocale() locale: Locale): Promise<Brand[]> {
|
||||
return this.brandsService.list(locale);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':slug')
|
||||
@ApiOperation({ summary: 'Get one brand by its (translated or canonical) slug' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getBySlug(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<Brand> {
|
||||
return this.brandsService.getBySlug(slug, locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Brand, Locale } from '@sport/types';
|
||||
|
||||
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import type { BrandRow } from './brands.repository';
|
||||
|
||||
/**
|
||||
* Prisma row → API type.
|
||||
*
|
||||
* Mappers exist so Prisma's generated types never leak past the module
|
||||
* boundary. The API contract is `@sport/types`, and a schema change that does
|
||||
* not change the contract should not ripple outward.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BrandsMapper {
|
||||
constructor(private readonly mediaUrl: MediaUrlService) {}
|
||||
|
||||
toBrand(row: BrandRow, locale: Locale): Brand {
|
||||
const translation = pickTranslation(row.translations, locale);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: coalesceRequired(translation?.name, row.name),
|
||||
slug: coalesceRequired(translation?.slug, row.slug),
|
||||
description: coalesce(translation?.description, row.description),
|
||||
logo: this.mediaUrl.toImageRef(row.logo),
|
||||
isActive: row.isActive,
|
||||
seo: {
|
||||
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
|
||||
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BrandsController } from './brands.controller';
|
||||
import { BrandsMapper } from './brands.mapper';
|
||||
import { BrandsRepository } from './brands.repository';
|
||||
import { BrandsService } from './brands.service';
|
||||
|
||||
/**
|
||||
* BrandsModule — boundary declared, implementation pending.
|
||||
* BrandsModule — owns `brands` and `brand_translations`.
|
||||
*
|
||||
* Owns (exclusively): `brands`
|
||||
*
|
||||
* Deliberately thin. Kept separate anyway because brand pages, filters and (later) brand-level commercial terms all hang off it.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* brands.module.ts wiring only
|
||||
* brands.controller.ts HTTP surface, no logic
|
||||
* brands.service.ts business rules
|
||||
* brands.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Deliberately thin. Kept as its own module anyway because brand pages, the
|
||||
* brand filter facet and (later) brand-level commercial terms all hang off it.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [BrandsController],
|
||||
providers: [BrandsService, BrandsRepository, BrandsMapper],
|
||||
exports: [BrandsService],
|
||||
})
|
||||
export class BrandsModule {}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
/** Shared shape so the mapper has one input type regardless of the query used. */
|
||||
const brandSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
isActive: true,
|
||||
metaTitle: true,
|
||||
metaDescription: true,
|
||||
logo: {
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
altText: true,
|
||||
width: true,
|
||||
height: true,
|
||||
blurDataUrl: true,
|
||||
},
|
||||
},
|
||||
translations: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The only file in this module that touches Prisma. Services depend on it, so
|
||||
* they stay unit-testable without a database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BrandsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findActive(locale: Locale) {
|
||||
return this.prisma.brand.findMany({
|
||||
where: { isActive: true },
|
||||
select: { ...brandSelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves either a translated slug or the canonical one.
|
||||
*
|
||||
* Both are accepted so that a link created before a translation existed keeps
|
||||
* working — old URLs breaking is an SEO and support cost that a single extra
|
||||
* OR clause avoids.
|
||||
*/
|
||||
findBySlug(slug: string, locale: Locale) {
|
||||
return this.prisma.brand.findFirst({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [{ translations: { some: { locale: toDbLocale(locale), slug } } }, { slug }],
|
||||
},
|
||||
select: { ...brandSelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type BrandRow = NonNullable<Awaited<ReturnType<BrandsRepository['findBySlug']>>>;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES, type Brand, type Locale } from '@sport/types';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
import { BrandsMapper } from './brands.mapper';
|
||||
import { BrandsRepository } from './brands.repository';
|
||||
|
||||
@Injectable()
|
||||
export class BrandsService {
|
||||
constructor(
|
||||
private readonly repository: BrandsRepository,
|
||||
private readonly mapper: BrandsMapper,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Brands change perhaps monthly and are read on every filter rail, which
|
||||
* makes this the cheapest cache in the system to justify.
|
||||
*/
|
||||
async list(locale: Locale): Promise<Brand[]> {
|
||||
return this.redis.getOrSet(CACHE_KEYS.brandList(locale), CACHE_TTL.brandList, async () => {
|
||||
const rows = await this.repository.findActive(locale);
|
||||
return rows.map((row) => this.mapper.toBrand(row, locale));
|
||||
});
|
||||
}
|
||||
|
||||
async getBySlug(slug: string, locale: Locale): Promise<Brand> {
|
||||
const cached = await this.redis.getOrSet(
|
||||
CACHE_KEYS.brandBySlug(locale, slug),
|
||||
CACHE_TTL.brandList,
|
||||
async () => {
|
||||
const row = await this.repository.findBySlug(slug, locale);
|
||||
// `null` is cached too: a bot hammering nonexistent slugs must not
|
||||
// become a stream of database queries.
|
||||
return row ? this.mapper.toBrand(row, locale) : null;
|
||||
},
|
||||
);
|
||||
|
||||
if (!cached) {
|
||||
throw AppException.notFound('Brand', API_ERROR_CODES.NOT_FOUND);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Public surface of BrandsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* Other modules import from here and nowhere else — the repository and mapper
|
||||
* are private, and the ESLint boundary rule enforces it.
|
||||
*/
|
||||
export {};
|
||||
export { BrandsService } from '../brands.service';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { Category, CategoryNode, Locale, NavigationMenu } from '@sport/types';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { RequestLocale } from '@/common/i18n';
|
||||
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller()
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@Public()
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: 'The full active category tree' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getTree(@RequestLocale() locale: Locale): Promise<CategoryNode[]> {
|
||||
return this.categoriesService.getTree(locale);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('categories/:slug')
|
||||
@ApiOperation({ summary: 'Get one category by slug' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getBySlug(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<Category> {
|
||||
return this.categoriesService.getBySlug(slug, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sits outside `/categories` because it is a composed view rather than a
|
||||
* category resource — it also carries collections.
|
||||
*/
|
||||
@Public()
|
||||
@Get('navigation')
|
||||
@ApiOperation({ summary: 'Header and footer navigation, fully localised' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getNavigation(@RequestLocale() locale: Locale): Promise<NavigationMenu> {
|
||||
return this.categoriesService.getNavigation(locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Category, CategoryNode, Locale } from '@sport/types';
|
||||
|
||||
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import type { CategoryRow } from './categories.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesMapper {
|
||||
constructor(private readonly mediaUrl: MediaUrlService) {}
|
||||
|
||||
toCategory(row: CategoryRow, locale: Locale): Category {
|
||||
const translation = pickTranslation(row.translations, locale);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
name: coalesceRequired(translation?.name, row.name),
|
||||
slug: coalesceRequired(translation?.slug, row.slug),
|
||||
description: coalesce(translation?.description, row.description),
|
||||
image: this.mediaUrl.toImageRef(row.image),
|
||||
path: row.path,
|
||||
depth: row.depth,
|
||||
position: row.position,
|
||||
isActive: row.isActive,
|
||||
seo: {
|
||||
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
|
||||
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles a flat list into a tree in one pass.
|
||||
*
|
||||
* Rows arrive ordered by depth, so a parent is always placed before its
|
||||
* children and no second pass or recursion is needed.
|
||||
*/
|
||||
toTree(rows: readonly CategoryRow[], locale: Locale): CategoryNode[] {
|
||||
const nodes = new Map<string, CategoryNode & { children: CategoryNode[] }>();
|
||||
const roots: CategoryNode[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
nodes.set(row.id, { ...this.toCategory(row, locale), children: [] });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const node = nodes.get(row.id);
|
||||
if (!node) continue;
|
||||
|
||||
const parent = row.parentId ? nodes.get(row.parentId) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CollectionsModule } from '@/modules/collections/collections.module';
|
||||
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesMapper } from './categories.mapper';
|
||||
import { CategoriesRepository } from './categories.repository';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
/**
|
||||
* CategoriesModule — boundary declared, implementation pending.
|
||||
* CategoriesModule — owns `categories` and `category_translations`.
|
||||
*
|
||||
* Owns (exclusively): `categories`
|
||||
* The hierarchical merchandising tree plus the navigation menu it feeds.
|
||||
* Heavy read, near-zero write, so everything here is Redis-cached.
|
||||
*
|
||||
* The hierarchical merchandising tree and the navigation menu it feeds. Heavy read, near-zero write — the first thing that should be Redis-cached.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* categories.module.ts wiring only
|
||||
* categories.controller.ts HTTP surface, no logic
|
||||
* categories.service.ts business rules
|
||||
* categories.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Imports CollectionsModule explicitly: the dependency is visible in the module
|
||||
* graph rather than hidden behind an ambient global.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [CollectionsModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService, CategoriesRepository, CategoriesMapper],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const categorySelect = {
|
||||
id: true,
|
||||
parentId: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
path: true,
|
||||
depth: true,
|
||||
position: true,
|
||||
description: true,
|
||||
isActive: true,
|
||||
metaTitle: true,
|
||||
metaDescription: true,
|
||||
image: {
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
altText: true,
|
||||
width: true,
|
||||
height: true,
|
||||
blurDataUrl: true,
|
||||
},
|
||||
},
|
||||
translations: true,
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Fetches the whole active tree in one query and lets the service assemble it
|
||||
* in memory.
|
||||
*
|
||||
* A category tree is tens of rows, not thousands — one indexed query plus an
|
||||
* O(n) build beats a recursive CTE or N queries per level, and the whole
|
||||
* result is cached anyway.
|
||||
*/
|
||||
findAllActive(locale: Locale) {
|
||||
return this.prisma.category.findMany({
|
||||
where: { isActive: true },
|
||||
select: { ...categorySelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
orderBy: [{ depth: 'asc' }, { position: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
findBySlug(slug: string, locale: Locale) {
|
||||
return this.prisma.category.findFirst({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [{ translations: { some: { locale: toDbLocale(locale), slug } } }, { slug }],
|
||||
},
|
||||
select: { ...categorySelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ancestors of a materialised path, for breadcrumbs.
|
||||
*
|
||||
* `men/running/shoes` → the paths `men` and `men/running`. Turning the path
|
||||
* into an exact `IN` list keeps this a single index lookup instead of a
|
||||
* `LIKE` scan or a recursive walk.
|
||||
*/
|
||||
findByPaths(paths: readonly string[], locale: Locale) {
|
||||
if (paths.length === 0) return Promise.resolve([]);
|
||||
|
||||
return this.prisma.category.findMany({
|
||||
where: { isActive: true, path: { in: [...paths] } },
|
||||
select: { ...categorySelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
orderBy: { depth: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type CategoryRow = NonNullable<Awaited<ReturnType<CategoriesRepository['findBySlug']>>>;
|
||||
|
||||
/** `men/running/shoes` → `['men', 'men/running']` (excludes the node itself). */
|
||||
export function ancestorPaths(path: string): string[] {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join('/'));
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type Breadcrumb,
|
||||
type Category,
|
||||
type CategoryNode,
|
||||
type Locale,
|
||||
type NavigationMenu,
|
||||
} from '@sport/types';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { CollectionsService } from '@/modules/collections/public';
|
||||
|
||||
import { CategoriesMapper } from './categories.mapper';
|
||||
import { ancestorPaths, CategoriesRepository } from './categories.repository';
|
||||
|
||||
/**
|
||||
* Top-level categories that have a dedicated storefront route. Everything
|
||||
* deeper is reached as a filter on those routes.
|
||||
*
|
||||
* This mapping is the seam between the merchandising tree (data) and the URL
|
||||
* structure (code). It lives in one place so a route change is one edit.
|
||||
*/
|
||||
const ROOT_ROUTES: Readonly<Record<string, string>> = {
|
||||
men: '/men',
|
||||
women: '/women',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(
|
||||
private readonly repository: CategoriesRepository,
|
||||
private readonly mapper: CategoriesMapper,
|
||||
private readonly redis: RedisService,
|
||||
// Cross-module access through the public surface, never the repository.
|
||||
private readonly collectionsService: CollectionsService,
|
||||
) {}
|
||||
|
||||
async getTree(locale: Locale): Promise<CategoryNode[]> {
|
||||
return this.redis.getOrSet(
|
||||
CACHE_KEYS.categoryTree(locale),
|
||||
CACHE_TTL.categoryTree,
|
||||
async () => {
|
||||
const rows = await this.repository.findAllActive(locale);
|
||||
return this.mapper.toTree(rows, locale);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getBySlug(slug: string, locale: Locale): Promise<Category> {
|
||||
const cached = await this.redis.getOrSet(
|
||||
CACHE_KEYS.categoryBySlug(locale, slug),
|
||||
CACHE_TTL.categoryTree,
|
||||
async () => {
|
||||
const row = await this.repository.findBySlug(slug, locale);
|
||||
return row ? this.mapper.toCategory(row, locale) : null;
|
||||
},
|
||||
);
|
||||
|
||||
if (!cached) {
|
||||
throw AppException.notFound('Category', API_ERROR_CODES.NOT_FOUND);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a slug to its materialised path, or null.
|
||||
*
|
||||
* Non-throwing on purpose: `ProductsModule` uses this to translate a
|
||||
* `?category=` filter into a subtree match, and an unknown slug there should
|
||||
* narrow to nothing, not 404 the whole listing.
|
||||
*/
|
||||
async resolvePath(slug: string, locale: Locale): Promise<string | null> {
|
||||
const row = await this.repository.findBySlug(slug, locale);
|
||||
return row?.path ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumbs for a category, resolved from its materialised path.
|
||||
* Returns ancestors first, then the category itself.
|
||||
*/
|
||||
async getBreadcrumbs(path: string, locale: Locale): Promise<Breadcrumb[]> {
|
||||
const rows = await this.repository.findByPaths([...ancestorPaths(path), path], locale);
|
||||
|
||||
return rows.map((row) => {
|
||||
const category = this.mapper.toCategory(row, locale);
|
||||
return {
|
||||
id: category.id,
|
||||
label: category.name,
|
||||
slug: category.slug,
|
||||
href: this.hrefFor(category),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The header menu: root categories with their immediate children, plus live
|
||||
* collections.
|
||||
*
|
||||
* Assembled server-side and cached as one payload because navigation renders
|
||||
* on every single page — it is the highest-traffic read in the system, and
|
||||
* three round-trips per page view would be three too many.
|
||||
*
|
||||
* Sport entries are deliberately absent: they come from a fixed enum, so the
|
||||
* storefront renders them from its own message catalog. Database content is
|
||||
* translated in the database; code-level enums are translated in the UI.
|
||||
*/
|
||||
async getNavigation(locale: Locale): Promise<NavigationMenu> {
|
||||
return this.redis.getOrSet(
|
||||
CACHE_KEYS.navigationMenu(locale),
|
||||
CACHE_TTL.navigation,
|
||||
async () => {
|
||||
const [tree, collections] = await Promise.all([
|
||||
this.getTree(locale),
|
||||
this.collectionsService.list(locale),
|
||||
]);
|
||||
|
||||
const primary = tree
|
||||
.filter((node) => ROOT_ROUTES[node.path] !== undefined)
|
||||
.map((node) => ({
|
||||
id: node.id,
|
||||
label: node.name,
|
||||
slug: node.slug,
|
||||
href: this.hrefFor(node),
|
||||
children: node.children.map((child) => ({
|
||||
id: child.id,
|
||||
label: child.name,
|
||||
slug: child.slug,
|
||||
href: this.hrefFor(child),
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
primary,
|
||||
featuredCollections: collections.slice(0, 4).map((collection) => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
slug: collection.slug,
|
||||
href: `/collections/${collection.slug}`,
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A root category maps to its own route; a child is a filter on the nearest
|
||||
* ancestor route. Keeping category browsing on the listing routes preserves
|
||||
* the URL structure the storefront was designed around.
|
||||
*/
|
||||
private hrefFor(category: Pick<Category, 'path' | 'slug'>): string {
|
||||
const rootSegment = category.path.split('/')[0] ?? '';
|
||||
const rootRoute = ROOT_ROUTES[rootSegment];
|
||||
|
||||
if (!rootRoute) {
|
||||
return `/search?category=${encodeURIComponent(category.slug)}`;
|
||||
}
|
||||
|
||||
return category.path === rootSegment
|
||||
? rootRoute
|
||||
: `${rootRoute}?category=${encodeURIComponent(category.slug)}`;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Public surface of CategoriesModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* `ProductsModule` uses this for breadcrumbs and to resolve a category slug to
|
||||
* its subtree path when filtering.
|
||||
*/
|
||||
export {};
|
||||
export { CategoriesService } from '../categories.service';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { Collection, Locale } from '@sport/types';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { RequestLocale } from '@/common/i18n';
|
||||
|
||||
import { CollectionsService } from './collections.service';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('collections')
|
||||
export class CollectionsController {
|
||||
constructor(private readonly collectionsService: CollectionsService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List live collections' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
list(@RequestLocale() locale: Locale): Promise<Collection[]> {
|
||||
return this.collectionsService.list(locale);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':slug')
|
||||
@ApiOperation({ summary: 'Get one collection by slug' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getBySlug(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<Collection> {
|
||||
return this.collectionsService.getBySlug(slug, locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Collection, CollectionType, Locale } from '@sport/types';
|
||||
|
||||
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import type { CollectionRow } from './collections.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CollectionsMapper {
|
||||
constructor(private readonly mediaUrl: MediaUrlService) {}
|
||||
|
||||
toCollection(row: CollectionRow, locale: Locale): Collection {
|
||||
const translation = pickTranslation(row.translations, locale);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: coalesceRequired(translation?.name, row.name),
|
||||
slug: coalesceRequired(translation?.slug, row.slug),
|
||||
type: row.type as CollectionType,
|
||||
description: coalesce(translation?.description, row.description),
|
||||
banner: this.mediaUrl.toImageRef(row.banner),
|
||||
startsAt: row.startsAt?.toISOString() ?? null,
|
||||
endsAt: row.endsAt?.toISOString() ?? null,
|
||||
isActive: row.isActive,
|
||||
seo: {
|
||||
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
|
||||
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CollectionsController } from './collections.controller';
|
||||
import { CollectionsMapper } from './collections.mapper';
|
||||
import { CollectionsRepository } from './collections.repository';
|
||||
import { CollectionsService } from './collections.service';
|
||||
|
||||
/**
|
||||
* CollectionsModule — boundary declared, implementation pending.
|
||||
* CollectionsModule — owns `collections`, `collection_translations` and
|
||||
* `product_collections`.
|
||||
*
|
||||
* Owns (exclusively): `collections`, `product_collections`
|
||||
*
|
||||
* Editorial and campaign groupings, including rule evaluation for AUTOMATED collections.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* collections.module.ts wiring only
|
||||
* collections.controller.ts HTTP surface, no logic
|
||||
* collections.service.ts business rules
|
||||
* collections.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Editorial and campaign groupings, including their scheduling window.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [CollectionsController],
|
||||
providers: [CollectionsService, CollectionsRepository, CollectionsMapper],
|
||||
exports: [CollectionsService],
|
||||
})
|
||||
export class CollectionsModule {}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const collectionSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
type: true,
|
||||
description: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
isActive: true,
|
||||
position: true,
|
||||
metaTitle: true,
|
||||
metaDescription: true,
|
||||
banner: {
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
altText: true,
|
||||
width: true,
|
||||
height: true,
|
||||
blurDataUrl: true,
|
||||
},
|
||||
},
|
||||
translations: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A collection is live when it is active AND inside its scheduling window.
|
||||
* Expressing that once, here, keeps a campaign from leaking early because one
|
||||
* query forgot the date check.
|
||||
*/
|
||||
function liveWindow(now: Date) {
|
||||
return {
|
||||
isActive: true,
|
||||
AND: [
|
||||
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
|
||||
{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CollectionsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findLive(locale: Locale, now = new Date()) {
|
||||
return this.prisma.collection.findMany({
|
||||
where: liveWindow(now),
|
||||
select: { ...collectionSelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
findBySlug(slug: string, locale: Locale, now = new Date()) {
|
||||
return this.prisma.collection.findFirst({
|
||||
where: {
|
||||
...liveWindow(now),
|
||||
OR: [{ translations: { some: { locale: toDbLocale(locale), slug } } }, { slug }],
|
||||
},
|
||||
select: { ...collectionSelect, translations: { where: { locale: toDbLocale(locale) } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type CollectionRow = NonNullable<Awaited<ReturnType<CollectionsRepository['findBySlug']>>>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES, type Collection, type Locale } from '@sport/types';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
import { CollectionsMapper } from './collections.mapper';
|
||||
import { CollectionsRepository } from './collections.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CollectionsService {
|
||||
constructor(
|
||||
private readonly repository: CollectionsRepository,
|
||||
private readonly mapper: CollectionsMapper,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* TTL is short (5 min) rather than long, because collections are
|
||||
* time-boxed: a campaign that starts at 09:00 should not be delayed by a
|
||||
* 15-minute cache. Scheduling correctness beats a marginal hit-rate gain.
|
||||
*/
|
||||
async list(locale: Locale): Promise<Collection[]> {
|
||||
return this.redis.getOrSet(
|
||||
CACHE_KEYS.collectionList(locale),
|
||||
CACHE_TTL.collectionList,
|
||||
async () => {
|
||||
const rows = await this.repository.findLive(locale);
|
||||
return rows.map((row) => this.mapper.toCollection(row, locale));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getBySlug(slug: string, locale: Locale): Promise<Collection> {
|
||||
const cached = await this.redis.getOrSet(
|
||||
CACHE_KEYS.collectionBySlug(locale, slug),
|
||||
CACHE_TTL.collectionList,
|
||||
async () => {
|
||||
const row = await this.repository.findBySlug(slug, locale);
|
||||
return row ? this.mapper.toCollection(row, locale) : null;
|
||||
},
|
||||
);
|
||||
|
||||
if (!cached) {
|
||||
throw AppException.notFound('Collection', API_ERROR_CODES.NOT_FOUND);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Public surface of CollectionsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* `CategoriesModule` consumes this to build the navigation menu — the only
|
||||
* sanctioned way for it to read collection data.
|
||||
*/
|
||||
export {};
|
||||
export { CollectionsService } from '../collections.service';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { Locale, ProductListResult, StorefrontProduct } from '@sport/types';
|
||||
import { productFilterSchema, type ProductFilter } from '@sport/validation';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { RequestLocale } from '@/common/i18n';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('products')
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
/**
|
||||
* The listing endpoint behind every browse surface. One contract, many
|
||||
* presets — /men is this with `gender=MEN`, /sports/running is this with
|
||||
* `sport=RUNNING`, /search is this with `q`.
|
||||
*/
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List products with filters, facets and cursor pagination' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
@ApiQuery({ name: 'q', required: false })
|
||||
@ApiQuery({ name: 'categorySlug', required: false })
|
||||
@ApiQuery({ name: 'collectionSlug', required: false })
|
||||
@ApiQuery({ name: 'brandSlugs', required: false, description: 'Comma-separated' })
|
||||
@ApiQuery({
|
||||
name: 'gender',
|
||||
required: false,
|
||||
description: 'Comma-separated: MEN,WOMEN,KIDS,UNISEX',
|
||||
})
|
||||
@ApiQuery({ name: 'sport', required: false, description: 'Comma-separated sport types' })
|
||||
@ApiQuery({ name: 'colors', required: false, description: 'Comma-separated option values' })
|
||||
@ApiQuery({ name: 'sizes', required: false, description: 'Comma-separated option values' })
|
||||
@ApiQuery({ name: 'minPrice', required: false, type: Number })
|
||||
@ApiQuery({ name: 'maxPrice', required: false, type: Number })
|
||||
@ApiQuery({ name: 'onSale', required: false, type: Boolean })
|
||||
@ApiQuery({ name: 'inStockOnly', required: false, type: Boolean })
|
||||
@ApiQuery({
|
||||
name: 'sort',
|
||||
required: false,
|
||||
enum: ['newest', 'price_asc', 'price_desc', 'best_selling', 'relevance'],
|
||||
})
|
||||
@ApiQuery({ name: 'cursor', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(productFilterSchema)) filter: ProductFilter,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<ProductListResult> {
|
||||
return this.productsService.list(filter, locale);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('slugs')
|
||||
@ApiOperation({ summary: 'All product slugs for the locale (sitemap / static params)' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
listSlugs(@RequestLocale() locale: Locale): Promise<{ slug: string; updatedAt: string }[]> {
|
||||
return this.productsService.listSlugs(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared after `slugs` so the literal route is matched first — otherwise
|
||||
* `/products/slugs` would resolve as a product with the slug "slugs".
|
||||
*/
|
||||
@Public()
|
||||
@Get(':slug')
|
||||
@ApiOperation({ summary: 'Product detail with options, variants and stock' })
|
||||
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
|
||||
getBySlug(
|
||||
@Param('slug') slug: string,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<StorefrontProduct> {
|
||||
return this.productsService.getBySlug(slug, locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
VARIANT_AVAILABILITY,
|
||||
type Breadcrumb,
|
||||
type ColorSwatch,
|
||||
type CurrencyCode,
|
||||
type GenderTarget,
|
||||
type Locale,
|
||||
type Money,
|
||||
type PriceRange,
|
||||
type ProductAttribute,
|
||||
type ProductImage,
|
||||
type ProductListItem,
|
||||
type ProductOption,
|
||||
type SportType,
|
||||
type StorefrontProduct,
|
||||
type StorefrontVariant,
|
||||
type VariantAvailability,
|
||||
} from '@sport/types';
|
||||
|
||||
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import type { ProductDetailRow, ProductListRow } from './products.repository';
|
||||
|
||||
/** Below this, the PDP shows "only N left" instead of a plain in-stock badge. */
|
||||
const LOW_STOCK_THRESHOLD = 5;
|
||||
|
||||
@Injectable()
|
||||
export class ProductsMapper {
|
||||
constructor(private readonly mediaUrl: MediaUrlService) {}
|
||||
|
||||
// ---- Listing -------------------------------------------------------------
|
||||
|
||||
toListItem(row: ProductListRow, locale: Locale): ProductListItem {
|
||||
const translation = pickTranslation(row.translations, locale);
|
||||
const brandTranslation = pickTranslation(row.brand?.translations, locale);
|
||||
const images = row.images.map((image) => this.toProductImage(image)).filter(isPresent);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: coalesceRequired(translation?.name, row.name),
|
||||
slug: coalesceRequired(translation?.slug, row.slug),
|
||||
brandName: row.brand ? coalesce(brandTranslation?.name, row.brand.name) : null,
|
||||
primaryImage: images[0] ?? null,
|
||||
// The second image is the hover state — a standard fashion-grid pattern
|
||||
// that shows the garment from another angle without a click.
|
||||
hoverImage: images[1] ?? null,
|
||||
priceRange: this.priceRangeOf(row.variants),
|
||||
isOnSale: row.isOnSale,
|
||||
colorSwatches: this.colorSwatchesOf(row.options, locale),
|
||||
// Reviews land in M7; the field exists so the card layout is final now.
|
||||
rating: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Detail --------------------------------------------------------------
|
||||
|
||||
toStorefrontProduct(
|
||||
row: ProductDetailRow,
|
||||
locale: Locale,
|
||||
breadcrumbs: readonly Breadcrumb[],
|
||||
): StorefrontProduct {
|
||||
const translation = pickTranslation(row.translations, locale);
|
||||
const options = row.options.map((option) => this.toOption(option, locale));
|
||||
const variants = row.variants.map((variant) => this.toStorefrontVariant(variant, locale));
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: coalesceRequired(translation?.name, row.name),
|
||||
slug: coalesceRequired(translation?.slug, row.slug),
|
||||
description: coalesce(translation?.description, row.description),
|
||||
shortDescription: coalesce(translation?.shortDescription, row.shortDescription),
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
brandId: row.brand?.id ?? null,
|
||||
primaryCategoryId: row.primaryCategory?.id ?? null,
|
||||
genderTargets: row.genderTargets as GenderTarget[],
|
||||
sportTypes: row.sportTypes as SportType[],
|
||||
options,
|
||||
images: row.images.map((image) => this.toProductImage(image)).filter(isPresent),
|
||||
attributes: row.attributes.map((attribute): ProductAttribute => ({
|
||||
id: attribute.id,
|
||||
key: attribute.key,
|
||||
label: coalesceRequired(
|
||||
pickTranslation(attribute.translations, locale)?.label,
|
||||
attribute.label,
|
||||
),
|
||||
value: coalesceRequired(
|
||||
pickTranslation(attribute.translations, locale)?.value,
|
||||
attribute.value,
|
||||
),
|
||||
group: attribute.group,
|
||||
position: attribute.position,
|
||||
isFilterable: attribute.isFilterable,
|
||||
})),
|
||||
brand: row.brand
|
||||
? {
|
||||
id: row.brand.id,
|
||||
name: coalesceRequired(
|
||||
pickTranslation(row.brand.translations, locale)?.name,
|
||||
row.brand.name,
|
||||
),
|
||||
slug: coalesceRequired(
|
||||
pickTranslation(row.brand.translations, locale)?.slug,
|
||||
row.brand.slug,
|
||||
),
|
||||
description: coalesce(
|
||||
pickTranslation(row.brand.translations, locale)?.description,
|
||||
row.brand.description,
|
||||
),
|
||||
logo: this.mediaUrl.toImageRef(row.brand.logo),
|
||||
isActive: row.brand.isActive,
|
||||
seo: {
|
||||
metaTitle: coalesce(
|
||||
pickTranslation(row.brand.translations, locale)?.metaTitle,
|
||||
row.brand.metaTitle,
|
||||
),
|
||||
metaDescription: coalesce(
|
||||
pickTranslation(row.brand.translations, locale)?.metaDescription,
|
||||
row.brand.metaDescription,
|
||||
),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
// The full Category object is not needed on a PDP — breadcrumbs carry
|
||||
// everything the page renders, and fetching it would be a wasted join.
|
||||
primaryCategory: null,
|
||||
collections: row.collections.map((link) => {
|
||||
const collectionTranslation = pickTranslation(link.collection.translations, locale);
|
||||
return {
|
||||
id: link.collection.id,
|
||||
name: coalesceRequired(collectionTranslation?.name, link.collection.name),
|
||||
slug: coalesceRequired(collectionTranslation?.slug, link.collection.slug),
|
||||
type: 'MANUAL' as const,
|
||||
description: null,
|
||||
banner: null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
isActive: true,
|
||||
seo: { metaTitle: null, metaDescription: null },
|
||||
};
|
||||
}),
|
||||
variants,
|
||||
priceRange: this.priceRangeOf(row.variants),
|
||||
rating: null,
|
||||
breadcrumbs,
|
||||
alternateSlugs: Object.fromEntries(
|
||||
row.translations.map((entry) => [entry.locale === 'VI' ? 'vi' : 'en', entry.slug]),
|
||||
),
|
||||
seo: {
|
||||
metaTitle: coalesce(translation?.metaTitle, row.metaTitle),
|
||||
metaDescription: coalesce(translation?.metaDescription, row.metaDescription),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Variants ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A variant is the purchasable unit: its own SKU, its own price, its own
|
||||
* stock. `effectivePrice` resolves the sale price once, here, so no consumer
|
||||
* re-implements "which price applies".
|
||||
*/
|
||||
private toStorefrontVariant(
|
||||
variant: ProductDetailRow['variants'][number],
|
||||
locale: Locale,
|
||||
): StorefrontVariant {
|
||||
const currency = variant.currency as CurrencyCode;
|
||||
const price = money(variant.priceAmount, currency);
|
||||
const salePrice =
|
||||
variant.salePriceAmount !== null ? money(variant.salePriceAmount, currency) : null;
|
||||
|
||||
const available = variant.stockLevels.reduce(
|
||||
(total, level) => total + (level.onHand - level.reserved),
|
||||
0,
|
||||
);
|
||||
|
||||
// Sorted by the product's own option order (Colour, then Size). Prisma
|
||||
// returns join rows in no guaranteed order, which otherwise produced
|
||||
// "XS / Black" on one request and "Black / XS" on the next.
|
||||
const optionValues = [...variant.optionValues]
|
||||
.sort((a, b) => a.option.position - b.option.position)
|
||||
.map((link) => ({
|
||||
optionId: link.optionId,
|
||||
optionKey: link.option.key,
|
||||
optionValueId: link.optionValueId,
|
||||
label: coalesceRequired(
|
||||
pickTranslation(link.optionValue.translations, locale)?.label,
|
||||
link.optionValue.label,
|
||||
),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: variant.id,
|
||||
productId: variant.productId,
|
||||
sku: variant.sku,
|
||||
barcode: variant.barcode,
|
||||
// Composed from the translated option values rather than the stored
|
||||
// `title`. The stored one is the canonical internal label kept for order
|
||||
// snapshots; a Vietnamese shopper must not see "Black / M".
|
||||
title: optionValues.length > 0 ? optionValues.map((v) => v.label).join(' / ') : variant.title,
|
||||
price,
|
||||
salePrice,
|
||||
compareAtPrice:
|
||||
variant.compareAtAmount !== null ? money(variant.compareAtAmount, currency) : null,
|
||||
weightGrams: variant.weightGrams,
|
||||
dimensions:
|
||||
variant.lengthMm !== null && variant.widthMm !== null && variant.heightMm !== null
|
||||
? { lengthMm: variant.lengthMm, widthMm: variant.widthMm, heightMm: variant.heightMm }
|
||||
: null,
|
||||
optionValues,
|
||||
status: 'ACTIVE',
|
||||
position: variant.position,
|
||||
effectivePrice: salePrice ?? price,
|
||||
isOnSale: salePrice !== null,
|
||||
availability: toAvailability(available),
|
||||
};
|
||||
}
|
||||
|
||||
private toOption(option: ProductDetailRow['options'][number], locale: Locale): ProductOption {
|
||||
return {
|
||||
id: option.id,
|
||||
name: coalesceRequired(pickTranslation(option.translations, locale)?.name, option.name),
|
||||
key: option.key,
|
||||
position: option.position,
|
||||
values: option.values.map((value) => ({
|
||||
id: value.id,
|
||||
optionId: value.optionId,
|
||||
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
|
||||
value: value.value,
|
||||
position: value.position,
|
||||
swatchHex: value.swatchHex,
|
||||
swatchImageUrl: value.swatchImage ? this.mediaUrl.url(value.swatchImage.storageKey) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Shared helpers ------------------------------------------------------
|
||||
|
||||
private colorSwatchesOf(options: ProductListRow['options'], locale: Locale): ColorSwatch[] {
|
||||
const colourOption = options.find((option) => option.key === 'colour');
|
||||
if (!colourOption) return [];
|
||||
|
||||
return colourOption.values.map((value) => ({
|
||||
optionValueId: value.id,
|
||||
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
|
||||
swatchHex: value.swatchHex,
|
||||
swatchImageUrl: value.swatchImage ? this.mediaUrl.url(value.swatchImage.storageKey) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed from the variants already loaded rather than from the denormalised
|
||||
* projection columns. Those exist for WHERE and ORDER BY; display always
|
||||
* comes from the real rows, so a stale projection can never show a wrong
|
||||
* price on a page.
|
||||
*/
|
||||
private priceRangeOf(
|
||||
variants: readonly {
|
||||
currency: string;
|
||||
priceAmount: number;
|
||||
salePriceAmount: number | null;
|
||||
compareAtAmount: number | null;
|
||||
}[],
|
||||
): PriceRange {
|
||||
const currency = (variants[0]?.currency ?? 'VND') as CurrencyCode;
|
||||
|
||||
if (variants.length === 0) {
|
||||
return { min: money(0, currency), max: money(0, currency), compareAtMax: null };
|
||||
}
|
||||
|
||||
const effective = variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount);
|
||||
const compareAt = variants
|
||||
.map((variant) => variant.compareAtAmount)
|
||||
.filter((amount): amount is number => amount !== null);
|
||||
|
||||
return {
|
||||
min: money(Math.min(...effective), currency),
|
||||
max: money(Math.max(...effective), currency),
|
||||
compareAtMax: compareAt.length > 0 ? money(Math.max(...compareAt), currency) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private toProductImage(row: ProductListRow['images'][number]): ProductImage | null {
|
||||
return this.mediaUrl.toProductImage(row);
|
||||
}
|
||||
}
|
||||
|
||||
function money(amount: number, currency: CurrencyCode): Money {
|
||||
return { amount, currency };
|
||||
}
|
||||
|
||||
function toAvailability(available: number): VariantAvailability {
|
||||
if (available <= 0) return VARIANT_AVAILABILITY.OUT_OF_STOCK;
|
||||
if (available <= LOW_STOCK_THRESHOLD) return VARIANT_AVAILABILITY.LOW_STOCK;
|
||||
return VARIANT_AVAILABILITY.IN_STOCK;
|
||||
}
|
||||
|
||||
function isPresent<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CategoriesModule } from '@/modules/categories/categories.module';
|
||||
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsMapper } from './products.mapper';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
/**
|
||||
* ProductsModule — boundary declared, implementation pending.
|
||||
* ProductsModule — owns `products`, `product_translations`, `product_options`,
|
||||
* `product_option_values`, `product_images` and `product_attributes`.
|
||||
*
|
||||
* Owns (exclusively): `products`, `product_options`, `product_option_values`, `product_images`, `product_attributes`
|
||||
*
|
||||
* The catalog aggregate root. Every other module references a product by id and reads through this module’s public service.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* products.module.ts wiring only
|
||||
* products.controller.ts HTTP surface, no logic
|
||||
* products.service.ts business rules
|
||||
* products.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* The catalog aggregate root. Other modules reference a product by id and read
|
||||
* through this module's public service.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [CategoriesModule],
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService, ProductsRepository, ProductsMapper],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
import type { ProductFilter } from '@sport/validation';
|
||||
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const imageSelect = {
|
||||
position: true,
|
||||
optionValueId: true,
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
altText: true,
|
||||
width: true,
|
||||
height: true,
|
||||
blurDataUrl: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const optionSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
key: true,
|
||||
position: true,
|
||||
translations: true,
|
||||
values: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
optionId: true,
|
||||
label: true,
|
||||
value: true,
|
||||
position: true,
|
||||
swatchHex: true,
|
||||
translations: true,
|
||||
swatchImage: { select: { storageKey: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Card payload: small on purpose — it is fetched 24 at a time. */
|
||||
const listSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
isOnSale: true,
|
||||
translations: true,
|
||||
brand: { select: { id: true, name: true, translations: true } },
|
||||
images: { orderBy: { position: 'asc' }, take: 4, select: imageSelect },
|
||||
options: { where: { key: 'colour' }, select: optionSelect },
|
||||
variants: {
|
||||
where: { status: 'ACTIVE', deletedAt: null },
|
||||
select: { currency: true, priceAmount: true, salePriceAmount: true, compareAtAmount: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Full PDP payload. */
|
||||
const detailSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
shortDescription: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
genderTargets: true,
|
||||
sportTypes: true,
|
||||
metaTitle: true,
|
||||
metaDescription: true,
|
||||
translations: true,
|
||||
brand: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
isActive: true,
|
||||
metaTitle: true,
|
||||
metaDescription: true,
|
||||
translations: true,
|
||||
logo: {
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
altText: true,
|
||||
width: true,
|
||||
height: true,
|
||||
blurDataUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
primaryCategory: { select: { id: true, path: true } },
|
||||
collections: {
|
||||
select: {
|
||||
collection: {
|
||||
select: { id: true, name: true, slug: true, translations: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
images: { orderBy: { position: 'asc' }, select: imageSelect },
|
||||
options: { orderBy: { position: 'asc' }, select: optionSelect },
|
||||
attributes: { orderBy: { position: 'asc' }, include: { translations: true } },
|
||||
variants: {
|
||||
where: { status: 'ACTIVE', deletedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
productId: true,
|
||||
sku: true,
|
||||
barcode: true,
|
||||
title: true,
|
||||
currency: true,
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
compareAtAmount: true,
|
||||
weightGrams: true,
|
||||
lengthMm: true,
|
||||
widthMm: true,
|
||||
heightMm: true,
|
||||
status: true,
|
||||
position: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
optionValues: {
|
||||
select: {
|
||||
optionId: true,
|
||||
optionValueId: true,
|
||||
option: { select: { key: true, position: true } },
|
||||
optionValue: { select: { label: true, translations: true } },
|
||||
},
|
||||
},
|
||||
// Availability is derived, never stored: available = onHand - reserved.
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ResolvedFilter extends ProductFilter {
|
||||
/** Materialised path of the requested category, if it resolved. */
|
||||
categoryPath?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Only products that are ACTIVE, not soft-deleted and actually published.
|
||||
* Every catalog read starts here — a scheduled product leaking early because
|
||||
* one query forgot `publishedAt` is exactly the bug this prevents.
|
||||
*/
|
||||
private visible(): Prisma.ProductWhereInput {
|
||||
return {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
OR: [{ publishedAt: null }, { publishedAt: { lte: new Date() } }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Context filters — the ones that define "which shelf am I looking at".
|
||||
* Facet counts are computed against these ONLY, so selecting "Black" still
|
||||
* shows how many White items exist. Counting against the refinements too
|
||||
* produces a filter UI that dead-ends the moment you use it.
|
||||
*/
|
||||
buildContextWhere(filter: ResolvedFilter, locale: Locale): Prisma.ProductWhereInput {
|
||||
const and: Prisma.ProductWhereInput[] = [this.visible()];
|
||||
|
||||
if (filter.q) {
|
||||
and.push({
|
||||
OR: [
|
||||
{ name: { contains: filter.q, mode: 'insensitive' } },
|
||||
{
|
||||
translations: {
|
||||
some: {
|
||||
locale: toDbLocale(locale),
|
||||
name: { contains: filter.q, mode: 'insensitive' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.categoryPath) {
|
||||
// Materialised path: one indexed prefix match covers the whole subtree.
|
||||
and.push({ primaryCategory: { path: { startsWith: filter.categoryPath } } });
|
||||
} else if (filter.categorySlug) {
|
||||
// Slug given but unresolvable — narrow to nothing rather than ignore it.
|
||||
and.push({ id: { in: [] } });
|
||||
}
|
||||
|
||||
if (filter.collectionSlug) {
|
||||
and.push({
|
||||
collections: {
|
||||
some: {
|
||||
collection: {
|
||||
OR: [
|
||||
{ slug: filter.collectionSlug },
|
||||
{
|
||||
translations: {
|
||||
some: { locale: toDbLocale(locale), slug: filter.collectionSlug },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.gender?.length) {
|
||||
and.push({ genderTargets: { hasSome: filter.gender } });
|
||||
}
|
||||
|
||||
if (filter.sport?.length) {
|
||||
and.push({ sportTypes: { hasSome: filter.sport } });
|
||||
}
|
||||
|
||||
return { AND: and };
|
||||
}
|
||||
|
||||
/** Context filters plus the refinements the shopper ticked. */
|
||||
buildWhere(filter: ResolvedFilter, locale: Locale): Prisma.ProductWhereInput {
|
||||
const and: Prisma.ProductWhereInput[] = [this.buildContextWhere(filter, locale)];
|
||||
|
||||
if (filter.brandSlugs?.length) {
|
||||
and.push({
|
||||
brand: {
|
||||
OR: [
|
||||
{ slug: { in: [...filter.brandSlugs] } },
|
||||
{
|
||||
translations: {
|
||||
some: { locale: toDbLocale(locale), slug: { in: [...filter.brandSlugs] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.colors?.length) {
|
||||
and.push(this.optionValueFilter('colour', filter.colors));
|
||||
}
|
||||
|
||||
if (filter.sizes?.length) {
|
||||
and.push(this.optionValueFilter('size', filter.sizes));
|
||||
}
|
||||
|
||||
if (filter.onSale) {
|
||||
and.push({ isOnSale: true });
|
||||
}
|
||||
|
||||
if (filter.inStockOnly) {
|
||||
and.push({ inStock: true });
|
||||
}
|
||||
|
||||
// Overlap, not containment: a product priced 200k–900k matches a
|
||||
// 300k–500k filter because it has something in that band.
|
||||
if (filter.minPrice !== undefined) {
|
||||
and.push({ maxPriceAmount: { gte: filter.minPrice } });
|
||||
}
|
||||
if (filter.maxPrice !== undefined) {
|
||||
and.push({ minPriceAmount: { lte: filter.maxPrice } });
|
||||
}
|
||||
|
||||
return { AND: and };
|
||||
}
|
||||
|
||||
private optionValueFilter(key: string, values: readonly string[]): Prisma.ProductWhereInput {
|
||||
return {
|
||||
options: {
|
||||
some: { key, values: { some: { value: { in: [...values] } } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `id` is always the final sort key. Cursor pagination needs a total order —
|
||||
* without the tiebreaker, two products at the same price can be returned
|
||||
* twice or skipped entirely as pages advance.
|
||||
*/
|
||||
private buildOrderBy(sort: ProductFilter['sort']): Prisma.ProductOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case 'price_asc':
|
||||
return [{ minPriceAmount: 'asc' }, { id: 'asc' }];
|
||||
case 'price_desc':
|
||||
return [{ minPriceAmount: 'desc' }, { id: 'desc' }];
|
||||
// `best_selling` has no order data yet (M5) and `relevance` has no
|
||||
// ranking yet (M6). Both fall back to newest, which is honest; pretending
|
||||
// to rank would not be.
|
||||
case 'best_selling':
|
||||
case 'relevance':
|
||||
case 'newest':
|
||||
default:
|
||||
return [{ publishedAt: 'desc' }, { id: 'desc' }];
|
||||
}
|
||||
}
|
||||
|
||||
findList(where: Prisma.ProductWhereInput, filter: ProductFilter, locale: Locale) {
|
||||
return this.prisma.product.findMany({
|
||||
where,
|
||||
select: {
|
||||
...listSelect,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
brand: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
where: { key: 'colour' },
|
||||
select: {
|
||||
...optionSelect,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
values: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
...optionSelect.values.select,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: this.buildOrderBy(filter.sort),
|
||||
take: filter.limit + 1,
|
||||
...(filter.cursor ? { cursor: { id: filter.cursor }, skip: 1 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
count(where: Prisma.ProductWhereInput) {
|
||||
return this.prisma.product.count({ where });
|
||||
}
|
||||
|
||||
findBySlug(slug: string, locale: Locale) {
|
||||
const dbLocale = toDbLocale(locale);
|
||||
|
||||
return this.prisma.product.findFirst({
|
||||
where: {
|
||||
...this.visible(),
|
||||
// Matches the slug in ANY locale, not just the requested one, so an
|
||||
// `/en/products/<vietnamese-slug>` link resolves instead of 404ing.
|
||||
// The service then redirects to the canonical URL for the locale.
|
||||
OR: [{ translations: { some: { slug } } }, { slug }],
|
||||
},
|
||||
select: {
|
||||
...detailSelect,
|
||||
// Unfiltered: two rows per product, and both are needed for
|
||||
// `alternateSlugs` (hreflang + the language switcher).
|
||||
translations: true,
|
||||
brand: {
|
||||
select: {
|
||||
...detailSelect.brand.select,
|
||||
translations: { where: { locale: dbLocale } },
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
select: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
translations: { where: { locale: dbLocale } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
...optionSelect,
|
||||
translations: { where: { locale: dbLocale } },
|
||||
values: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
...optionSelect.values.select,
|
||||
translations: { where: { locale: dbLocale } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
orderBy: { position: 'asc' },
|
||||
include: { translations: { where: { locale: dbLocale } } },
|
||||
},
|
||||
variants: {
|
||||
...detailSelect.variants,
|
||||
select: {
|
||||
...detailSelect.variants.select,
|
||||
optionValues: {
|
||||
select: {
|
||||
optionId: true,
|
||||
optionValueId: true,
|
||||
option: { select: { key: true, position: true } },
|
||||
optionValue: {
|
||||
select: { label: true, translations: { where: { locale: dbLocale } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** All translated slugs for a locale — feeds `generateStaticParams`/sitemaps. */
|
||||
findAllSlugs(locale: Locale) {
|
||||
return this.prisma.productTranslation.findMany({
|
||||
where: { locale: toDbLocale(locale), product: this.visible() },
|
||||
select: { slug: true, product: { select: { updatedAt: true } } },
|
||||
orderBy: { slug: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Facets --------------------------------------------------------------
|
||||
|
||||
brandFacet(where: Prisma.ProductWhereInput) {
|
||||
return this.prisma.product.groupBy({
|
||||
by: ['brandId'],
|
||||
where: { AND: [where, { brandId: { not: null } }] },
|
||||
_count: { _all: true },
|
||||
});
|
||||
}
|
||||
|
||||
brandsByIds(ids: readonly string[], locale: Locale) {
|
||||
return this.prisma.brand.findMany({
|
||||
where: { id: { in: [...ids] } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts products offering each value of an option.
|
||||
*
|
||||
* One `product_option_values` row exists per product per value, so a row
|
||||
* count IS the product count — no DISTINCT and no large intermediate fetch.
|
||||
*/
|
||||
optionValueFacet(where: Prisma.ProductWhereInput, key: string) {
|
||||
return this.prisma.productOptionValue.groupBy({
|
||||
by: ['value'],
|
||||
where: { option: { key, product: where } },
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { value: 'desc' } },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
/** Representative rows so each facet bucket gets a translated label + swatch. */
|
||||
optionValueSamples(
|
||||
where: Prisma.ProductWhereInput,
|
||||
key: string,
|
||||
values: readonly string[],
|
||||
locale: Locale,
|
||||
) {
|
||||
return this.prisma.productOptionValue.findMany({
|
||||
where: { option: { key, product: where }, value: { in: [...values] } },
|
||||
distinct: ['value'],
|
||||
select: {
|
||||
value: true,
|
||||
label: true,
|
||||
position: true,
|
||||
swatchHex: true,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
priceRangeFacet(where: Prisma.ProductWhereInput) {
|
||||
return this.prisma.product.aggregate({
|
||||
where,
|
||||
_min: { minPriceAmount: true },
|
||||
_max: { maxPriceAmount: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Read-model maintenance ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Recomputes the denormalised pricing/stock projection on Product.
|
||||
*
|
||||
* The single writer for `minPriceAmount`, `maxPriceAmount`, `isOnSale` and
|
||||
* `inStock`. Every variant or stock mutation in M3 must call this; treat any
|
||||
* other write to those columns as a bug.
|
||||
*/
|
||||
async recomputePricing(productId: string): Promise<void> {
|
||||
const variants = await this.prisma.productVariant.findMany({
|
||||
where: { productId, status: 'ACTIVE', deletedAt: null },
|
||||
select: {
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (variants.length === 0) {
|
||||
await this.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: { minPriceAmount: null, maxPriceAmount: null, isOnSale: false, inStock: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const effective = variants.map((v) => v.salePriceAmount ?? v.priceAmount);
|
||||
|
||||
await this.prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
minPriceAmount: Math.min(...effective),
|
||||
maxPriceAmount: Math.max(...effective),
|
||||
isOnSale: variants.some((v) => v.salePriceAmount !== null),
|
||||
inStock: variants.some((v) =>
|
||||
v.stockLevels.some((level) => level.onHand - level.reserved > 0),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductListRow = Awaited<ReturnType<ProductsRepository['findList']>>[number];
|
||||
export type ProductDetailRow = NonNullable<Awaited<ReturnType<ProductsRepository['findBySlug']>>>;
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type Locale,
|
||||
type ProductFacets,
|
||||
type ProductListResult,
|
||||
type StorefrontProduct,
|
||||
} from '@sport/types';
|
||||
import type { ProductFilter } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { coalesceRequired, pickTranslation } from '@/common/i18n';
|
||||
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { CategoriesService } from '@/modules/categories/public';
|
||||
|
||||
import { ProductsMapper } from './products.mapper';
|
||||
import { ProductsRepository, type ResolvedFilter } from './products.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
private readonly repository: ProductsRepository,
|
||||
private readonly mapper: ProductsMapper,
|
||||
private readonly redis: RedisService,
|
||||
private readonly categoriesService: CategoriesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The one query behind every listing page: /men, /women, /sports/*,
|
||||
* /collections/* and /search are the same call with different presets.
|
||||
*
|
||||
* Cached for 60s only. Listings must reflect a product going out of stock
|
||||
* reasonably quickly, and at listing volumes a short TTL still absorbs the
|
||||
* overwhelming majority of load.
|
||||
*/
|
||||
async list(filter: ProductFilter, locale: Locale): Promise<ProductListResult> {
|
||||
const resolved = await this.resolveFilter(filter, locale);
|
||||
|
||||
return this.redis.getOrSet(
|
||||
CACHE_KEYS.productListing(locale, fingerprint(resolved)),
|
||||
CACHE_TTL.productListing,
|
||||
async () => {
|
||||
const where = this.repository.buildWhere(resolved, locale);
|
||||
const contextWhere = this.repository.buildContextWhere(resolved, locale);
|
||||
|
||||
const [rows, totalCount, facets] = await Promise.all([
|
||||
this.repository.findList(where, resolved, locale),
|
||||
this.repository.count(where),
|
||||
this.buildFacets(contextWhere, locale),
|
||||
]);
|
||||
|
||||
// One row over the limit was fetched purely to answer hasNextPage
|
||||
// without a second count query.
|
||||
const hasNextPage = rows.length > resolved.limit;
|
||||
const page = hasNextPage ? rows.slice(0, resolved.limit) : rows;
|
||||
|
||||
return {
|
||||
items: page.map((row) => this.mapper.toListItem(row, locale)),
|
||||
pageInfo: {
|
||||
hasNextPage,
|
||||
nextCursor: hasNextPage ? (page[page.length - 1]?.id ?? null) : null,
|
||||
},
|
||||
totalCount,
|
||||
facets,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getBySlug(slug: string, locale: Locale): Promise<StorefrontProduct> {
|
||||
const cached = await this.redis.getOrSet(
|
||||
CACHE_KEYS.productBySlug(locale, slug),
|
||||
CACHE_TTL.productDetail,
|
||||
async () => {
|
||||
const row = await this.repository.findBySlug(slug, locale);
|
||||
if (!row) return null;
|
||||
|
||||
const breadcrumbs = row.primaryCategory
|
||||
? await this.categoriesService.getBreadcrumbs(row.primaryCategory.path, locale)
|
||||
: [];
|
||||
|
||||
return this.mapper.toStorefrontProduct(row, locale, breadcrumbs);
|
||||
},
|
||||
);
|
||||
|
||||
if (!cached) {
|
||||
throw AppException.notFound('Product', API_ERROR_CODES.PRODUCT_NOT_FOUND);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Slugs + last-modified for `generateStaticParams` and the sitemap. */
|
||||
async listSlugs(locale: Locale): Promise<{ slug: string; updatedAt: string }[]> {
|
||||
const rows = await this.repository.findAllSlugs(locale);
|
||||
return rows.map((row) => ({ slug: row.slug, updatedAt: row.product.updatedAt.toISOString() }));
|
||||
}
|
||||
|
||||
/** See ProductsRepository.recomputePricing — the single writer for the projection. */
|
||||
recomputePricing(productId: string): Promise<void> {
|
||||
return this.repository.recomputePricing(productId);
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Turns a category slug into a materialised path before the query runs, so
|
||||
* the repository never has to reach into another module's tables.
|
||||
*/
|
||||
private async resolveFilter(filter: ProductFilter, locale: Locale): Promise<ResolvedFilter> {
|
||||
if (!filter.categorySlug) return filter;
|
||||
|
||||
return {
|
||||
...filter,
|
||||
categoryPath: await this.categoriesService.resolvePath(filter.categorySlug, locale),
|
||||
};
|
||||
}
|
||||
|
||||
private async buildFacets(
|
||||
contextWhere: Parameters<ProductsRepository['brandFacet']>[0],
|
||||
locale: Locale,
|
||||
): Promise<ProductFacets> {
|
||||
const [brandGroups, colorGroups, sizeGroups, priceAggregate] = await Promise.all([
|
||||
this.repository.brandFacet(contextWhere),
|
||||
this.repository.optionValueFacet(contextWhere, 'colour'),
|
||||
this.repository.optionValueFacet(contextWhere, 'size'),
|
||||
this.repository.priceRangeFacet(contextWhere),
|
||||
]);
|
||||
|
||||
const brandIds = brandGroups
|
||||
.map((group) => group.brandId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
const [brands, colorSamples, sizeSamples] = await Promise.all([
|
||||
this.repository.brandsByIds(brandIds, locale),
|
||||
this.repository.optionValueSamples(
|
||||
contextWhere,
|
||||
'colour',
|
||||
colorGroups.map((group) => group.value),
|
||||
locale,
|
||||
),
|
||||
this.repository.optionValueSamples(
|
||||
contextWhere,
|
||||
'size',
|
||||
sizeGroups.map((group) => group.value),
|
||||
locale,
|
||||
),
|
||||
]);
|
||||
|
||||
const brandCounts = new Map(brandGroups.map((group) => [group.brandId, group._count._all]));
|
||||
const colorCounts = new Map(colorGroups.map((group) => [group.value, group._count._all]));
|
||||
const sizeCounts = new Map(sizeGroups.map((group) => [group.value, group._count._all]));
|
||||
|
||||
const min = priceAggregate._min.minPriceAmount;
|
||||
const max = priceAggregate._max.maxPriceAmount;
|
||||
|
||||
return {
|
||||
brands: brands
|
||||
.map((brand) => {
|
||||
const translation = pickTranslation(brand.translations, locale);
|
||||
return {
|
||||
value: coalesceRequired(translation?.slug, brand.slug),
|
||||
label: coalesceRequired(translation?.name, brand.name),
|
||||
count: brandCounts.get(brand.id) ?? 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count),
|
||||
|
||||
colors: colorSamples.map((sample) => ({
|
||||
value: sample.value,
|
||||
label: coalesceRequired(pickTranslation(sample.translations, locale)?.label, sample.label),
|
||||
count: colorCounts.get(sample.value) ?? 0,
|
||||
swatchHex: sample.swatchHex,
|
||||
})),
|
||||
|
||||
// Sizes are ordered by size, never by count. `position` cannot be used:
|
||||
// it is per-product, so a listing mixing apparel and footwear yields
|
||||
// nonsense like "36, XS, 39, S". See `compareSizes`.
|
||||
sizes: sizeSamples
|
||||
.map((sample) => ({
|
||||
value: sample.value,
|
||||
label: coalesceRequired(
|
||||
pickTranslation(sample.translations, locale)?.label,
|
||||
sample.label,
|
||||
),
|
||||
count: sizeCounts.get(sample.value) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => compareSizes(a.value, b.value)),
|
||||
|
||||
priceRange:
|
||||
min !== null && max !== null
|
||||
? { min: { amount: min, currency: 'VND' }, max: { amount: max, currency: 'VND' } }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical apparel progression. Anything not listed is treated as numeric. */
|
||||
const APPAREL_SIZE_ORDER = ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl', '2xl', '3xl', '4xl'];
|
||||
|
||||
/**
|
||||
* Orders a mixed size facet the way a human expects: apparel sizes in their
|
||||
* canonical progression first, then numeric (footwear) sizes ascending.
|
||||
*
|
||||
* Sorting alphabetically gives "L, M, S, XL" and sorting by popularity gives a
|
||||
* jumble — both make a size selector unusable, which is why this is explicit.
|
||||
*/
|
||||
function compareSizes(a: string, b: string): number {
|
||||
const indexA = APPAREL_SIZE_ORDER.indexOf(a.toLowerCase());
|
||||
const indexB = APPAREL_SIZE_ORDER.indexOf(b.toLowerCase());
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
|
||||
const numA = Number.parseFloat(a);
|
||||
const numB = Number.parseFloat(b);
|
||||
if (!Number.isNaN(numA) && !Number.isNaN(numB)) return numA - numB;
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cache key for a filter combination.
|
||||
*
|
||||
* Keys are sorted before hashing so `?sort=newest&onSale=true` and
|
||||
* `?onSale=true&sort=newest` share one cache entry instead of two.
|
||||
*/
|
||||
function fingerprint(filter: ResolvedFilter): string {
|
||||
const normalised = Object.entries(filter)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}=${Array.isArray(value) ? [...value].sort().join('|') : String(value)}`,
|
||||
)
|
||||
.join('&');
|
||||
|
||||
return createHash('sha1').update(normalised).digest('hex').slice(0, 16);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Public surface of ProductsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* Note what is NOT exported: the repository, the mapper and every Prisma row
|
||||
* type. Cart, checkout and order modules get `ProductsService` and the types
|
||||
* from `@sport/types` — nothing that would couple them to the schema.
|
||||
*/
|
||||
export {};
|
||||
export { ProductsService } from '../products.service';
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
/**
|
||||
* Points next-intl at the request config. Without this the message catalogs are
|
||||
* never registered and every server-side `getTranslations` call fails at build
|
||||
* time rather than at runtime — which is the good failure mode, but only if the
|
||||
* plugin is wired up in the first place.
|
||||
*/
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
@@ -31,4 +40,4 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
"@sport/validation": "workspace:*",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"next": "catalog:",
|
||||
"next-intl": "^4.13.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"zustand": "^5.0.14",
|
||||
"zod": "catalog:"
|
||||
"zod": "catalog:",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sport/config": "workspace:*",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Addresses' };
|
||||
|
||||
export default function AddressesPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Addresses"
|
||||
description="Address book with Vietnamese province/district/ward selection, stored with administrative codes for shipping-provider integration."
|
||||
milestone="M8 — customer account"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { SiteFooter } from '@/components/layout/site-footer';
|
||||
import { SiteHeader } from '@/components/layout/site-header';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
const ACCOUNT_NAV = [
|
||||
{ href: routes.accountProfile(), label: 'Profile' },
|
||||
{ href: routes.accountOrders(), label: 'Orders' },
|
||||
{ href: routes.accountAddresses(), label: 'Addresses' },
|
||||
{ href: routes.accountWishlist(), label: 'Wishlist' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Everything under /account requires a signed-in customer. That check will live
|
||||
* in middleware (a cheap cookie presence test) plus a server-side verification
|
||||
* here — never in a Client Component, which can be bypassed.
|
||||
*/
|
||||
export default function AccountLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<div className="max-w-page px-gutter mx-auto flex w-full flex-1 gap-12 py-16">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">Account</h2>
|
||||
<nav className="mt-4 space-y-1">
|
||||
{ACCOUNT_NAV.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-ink-600 hover:text-ink-950 block py-1.5 text-sm"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Orders' };
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Orders"
|
||||
description="Order history and detail. Every line renders the snapshot taken at purchase time, never the live catalog."
|
||||
milestone="M8 — customer account"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Account' };
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Account"
|
||||
description="Overview: recent orders, saved addresses and profile at a glance."
|
||||
milestone="M8 — customer account"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Profile' };
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Profile"
|
||||
description="Name, email, phone, password and marketing preferences."
|
||||
milestone="M8 — customer account"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Wishlist' };
|
||||
|
||||
export default function WishlistPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Wishlist"
|
||||
description="Saved variants, and the signal source for back-in-stock notifications."
|
||||
milestone="M8 — customer account"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Checkout' };
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Checkout"
|
||||
description="Contact, shipping address, delivery method, payment. Each step is server-validated; stock is reserved before a payment intent is created."
|
||||
milestone="M5 — cart & checkout"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Journal' };
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Journal"
|
||||
title="Journal"
|
||||
description="Editorial content served from the CMS module: training guides, drops, athlete stories."
|
||||
milestone="M7 — content"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Your bag' };
|
||||
|
||||
export default function CartPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Bag"
|
||||
title="Your bag"
|
||||
description="Line items with variant title and live availability. Totals are always recomputed by the API — the client never submits a price."
|
||||
milestone="M5 — cart & checkout"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return { title: slug };
|
||||
}
|
||||
|
||||
export default async function CollectionPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Collection"
|
||||
title={slug}
|
||||
description="Editorial collection page: campaign banner, curated copy and the shared product grid."
|
||||
milestone="M4 — storefront catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { SiteFooter } from '@/components/layout/site-footer';
|
||||
import { SiteHeader } from '@/components/layout/site-header';
|
||||
|
||||
/**
|
||||
* Chrome shared by every browsing route. Checkout deliberately sits in its own
|
||||
* route group with a stripped-back layout — removing navigation from checkout
|
||||
* is one of the highest-leverage conversion decisions there is.
|
||||
*/
|
||||
export default function ShopLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Men' };
|
||||
|
||||
export default function MenPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Shop"
|
||||
title="Men"
|
||||
description="Gender-facet listing. Same product-grid feature as every other listing page, preset with gender=MEN."
|
||||
milestone="M4 — storefront catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sport Store — Performance sportswear' };
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Built for the work"
|
||||
description="Homepage: hero, featured collections, new arrivals and sport entry points. Assembled from CMS-driven blocks so merchandisers can reorder the page without a deploy."
|
||||
milestone="M4 — storefront catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
// Replaced with a real fetch (title, description, OG image) once the catalog
|
||||
// endpoints exist; PDP metadata is the single most important SEO surface here.
|
||||
return { title: slug };
|
||||
}
|
||||
|
||||
/**
|
||||
* Product detail page.
|
||||
*
|
||||
* The page renders a Product, but everything purchasable on it is a
|
||||
* ProductVariant: the colour swatches select an option value, the size buttons
|
||||
* select another, and together they resolve to exactly one variant id with its
|
||||
* own price and stock. The "Add to bag" button submits that variant id.
|
||||
*/
|
||||
export default async function ProductPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Product"
|
||||
title={slug}
|
||||
description="Gallery, variant selector (colour → size → variant id), price with sale handling, availability, size guide and specifications."
|
||||
milestone="M4 — storefront catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Search' };
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Search"
|
||||
title="Search"
|
||||
description="Full-text search over the catalog with the same facets as category listings. Backed by PostgreSQL FTS behind a SearchProvider interface."
|
||||
milestone="M6 — search"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { SPORT_NAV, isSportSlug } from '@/lib/routes';
|
||||
|
||||
type PageProps = { params: Promise<{ sport: string }> };
|
||||
|
||||
/**
|
||||
* The sport facets are a fixed, small set, so the routes are pre-rendered at
|
||||
* build time. Category pages, which are database-driven, will use generateStaticParams
|
||||
* against the API instead.
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return SPORT_NAV.map((sport) => ({ sport: sport.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { sport } = await params;
|
||||
const match = SPORT_NAV.find((entry) => entry.slug === sport);
|
||||
return { title: match ? match.label : 'Sports' };
|
||||
}
|
||||
|
||||
export default async function SportPage({ params }: PageProps) {
|
||||
const { sport } = await params;
|
||||
|
||||
// Unknown facet is a 404, not an empty grid: empty results for a nonexistent
|
||||
// URL are an SEO liability and hide typos in internal links.
|
||||
if (!isSportSlug(sport)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const label = SPORT_NAV.find((entry) => entry.slug === sport)?.label ?? sport;
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Sports"
|
||||
title={label}
|
||||
description="Sport-facet listing. Same product-grid feature as every other listing page, preset with this sport."
|
||||
milestone="M4 — storefront catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Women' };
|
||||
|
||||
export default function WomenPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
eyebrow="Shop"
|
||||
title="Women"
|
||||
description="Gender-facet listing. Same product-grid feature as every other listing page, preset with gender=WOMEN."
|
||||
milestone="M4 — storefront catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.addresses' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function AddressesPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.addresses');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { SiteFooter } from '@/components/layout/site-footer';
|
||||
import { SiteHeader } from '@/components/layout/site-header';
|
||||
import { fetchNavigation } from '@/features/product/services/catalog';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* Everything under /account requires a signed-in customer. Enforcement will be
|
||||
* layered: the proxy checks for a session cookie, this layout verifies the
|
||||
* token server-side, and the API re-checks on every request. Only the last one
|
||||
* is real security; the first two are UX.
|
||||
*/
|
||||
export default async function AccountLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('account');
|
||||
const navigation = await fetchNavigation(locale as Locale);
|
||||
|
||||
const items = [
|
||||
{ href: routes.accountProfile(), label: t('profile') },
|
||||
{ href: routes.accountOrders(), label: t('orders') },
|
||||
{ href: routes.accountAddresses(), label: t('addresses') },
|
||||
{ href: routes.accountWishlist(), label: t('wishlist') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} />
|
||||
<div className="max-w-page px-gutter mx-auto flex w-full flex-1 gap-12 py-16">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<nav className="mt-4 space-y-1">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-ink-600 hover:text-ink-950 block py-1.5 text-sm"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
<SiteFooter navigation={navigation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.orders' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function OrdersPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.orders');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.account' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function AccountPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.account');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.profile' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function ProfilePage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.profile');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.wishlist' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function WishlistPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.wishlist');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.checkout' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CheckoutPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.checkout');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
|
||||
);
|
||||
}
|
||||
+20
-4
@@ -1,9 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/** Minimal chrome: no nav, no footer links, nothing competing with completion. */
|
||||
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Minimal chrome: no navigation, no footer links, nothing competing with
|
||||
* completion. Removing distractions from checkout is one of the
|
||||
* highest-leverage conversion decisions available.
|
||||
*/
|
||||
export default async function CheckoutLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.checkout');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-ink-200 border-b">
|
||||
@@ -12,7 +28,7 @@ export default function CheckoutLayout({ children }: { children: React.ReactNode
|
||||
Sport<span className="text-volt-600">.</span>
|
||||
</Link>
|
||||
<span className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
|
||||
Secure checkout
|
||||
{t('title')}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.blog' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function BlogPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.blog');
|
||||
|
||||
return <PageScaffold title={t('title')} description={t('body')} milestone="M7 — content" />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.cart' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CartPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.cart');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { ProductListing } from '@/features/product/components/product-listing';
|
||||
import { CATALOG_CACHE, getServerApi } from '@/lib/api';
|
||||
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
searchParams: Promise<RawSearchParams>;
|
||||
};
|
||||
|
||||
async function loadCollection(locale: Locale, slug: string) {
|
||||
try {
|
||||
return await getServerApi().catalog.getCollection(locale, slug, CATALOG_CACHE.collections);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const collection = await loadCollection(locale as Locale, slug);
|
||||
if (!collection) return {};
|
||||
|
||||
return {
|
||||
title: collection.seo.metaTitle ?? collection.name,
|
||||
description: collection.seo.metaDescription ?? collection.description ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CollectionPage({ params, searchParams }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const collection = await loadCollection(locale as Locale, slug);
|
||||
if (!collection) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const query = parseListingParams(await searchParams);
|
||||
|
||||
return (
|
||||
<ProductListing
|
||||
locale={locale as Locale}
|
||||
eyebrow="Collection"
|
||||
title={collection.name}
|
||||
description={collection.description}
|
||||
query={{ ...query, collectionSlug: collection.slug }}
|
||||
basePath={`/collections/${collection.slug}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { SiteFooter } from '@/components/layout/site-footer';
|
||||
import { SiteHeader } from '@/components/layout/site-header';
|
||||
import { fetchNavigation } from '@/features/product/services/catalog';
|
||||
|
||||
/**
|
||||
* Chrome shared by every browsing route.
|
||||
*
|
||||
* Navigation is fetched once here rather than per page: it renders on every
|
||||
* screen, and the API response is cached both in Redis and by Next's fetch
|
||||
* cache, so this is one warm read per revalidation window.
|
||||
*
|
||||
* Checkout deliberately sits in its own route group with a stripped-back
|
||||
* layout — removing navigation from checkout is one of the highest-leverage
|
||||
* conversion decisions there is.
|
||||
*/
|
||||
export default async function ShopLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const navigation = await fetchNavigation(locale as Locale);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter navigation={navigation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user