Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
+48 -26
View File
@@ -2,10 +2,9 @@
A modern sports-fashion e-commerce platform. A modern sports-fashion e-commerce platform.
**Status: milestone 2 — auth and RBAC, on top of a live bilingual catalog.** **Status: milestone 3 — the admin can now write to the catalog.**
The storefront renders real products in Vietnamese and English; the admin has working sign-in Products, variants, media and stock are editable from the back office and appear on the
with rotating refresh tokens and permission-filtered navigation. Cart, checkout and orders are bilingual storefront immediately. Cart, checkout and orders are next; see [Roadmap](#roadmap).
next; see [Roadmap](#roadmap).
``` ```
Storefront (Next.js) ─┐ Storefront (Next.js) ─┐
@@ -123,12 +122,12 @@ sport-store/
│ ├── storefront/ Next.js customer site (:3000) │ ├── storefront/ Next.js customer site (:3000)
│ │ └── src/ │ │ └── src/
│ │ ├── app/[locale]/ (shop) (checkout) (account) route groups │ │ ├── app/[locale]/ (shop) (checkout) (account) route groups
│ │ ├── components/ Cross-feature UI (layout, chrome) │ │ ├── components/
│ │ ├── features/ auth · product · category · collection · search │ │ │ └── commerce/ Hand-built brand UI — hero, mega menu, header,
│ │ │ cart · checkout · order · wishlist · account │ │ │ product card, gallery, PDP, filter sheet
│ │ ├── i18n/ next-intl routing, request config, navigation │ │ ├── i18n/ next-intl routing, request config, navigation
│ │ ├── messages/ vi.json · en.json (UI strings) │ │ ├── messages/ vi.json · en.json (UI strings)
│ │ ├── hooks/ lib/ services/ stores/ styles/ types/ │ │ ├── hooks/ lib/ stores/ styles/ types/
│ │ │ │
│ ├── admin/ Next.js back office (:3001) │ ├── admin/ Next.js back office (:3001)
│ │ └── src/ │ │ └── src/
@@ -148,7 +147,7 @@ sport-store/
│ ├── types/ Framework-free domain + API contracts (zero deps) │ ├── types/ Framework-free domain + API contracts (zero deps)
│ ├── validation/ Zod schemas shared by API and both frontends │ ├── validation/ Zod schemas shared by API and both frontends
│ ├── api-client/ The only sanctioned way for a frontend to reach the API │ ├── api-client/ The only sanctioned way for a frontend to reach the API
│ ├── ui/ Design-system primitives (Button, Input, Badge, Skeleton) │ ├── ui/ shadcn/ui infrastructure, owned as source (ADR-0017)
│ ├── config/ Shared tsconfig bases + Tailwind theme tokens │ ├── config/ Shared tsconfig bases + Tailwind theme tokens
│ └── eslint-config/ Flat configs incl. the architectural boundary rules │ └── eslint-config/ Flat configs incl. the architectural boundary rules
│ │
@@ -159,7 +158,7 @@ sport-store/
│ │
├── docs/ ├── docs/
│ ├── architecture.md Boundaries, conventions, risks — read this first │ ├── architecture.md Boundaries, conventions, risks — read this first
│ └── adr/ 15 decision records │ └── adr/ 17 decision records
│ │
├── docker-compose.yml Backing services; `--profile full` runs everything ├── docker-compose.yml Backing services; `--profile full` runs everything
├── turbo.json pnpm-workspace.yaml package.json ├── turbo.json pnpm-workspace.yaml package.json
@@ -205,6 +204,13 @@ Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that
dev and production authenticating identically. dev and production authenticating identically.
([ADR-0015](./docs/adr/0015-frontends-reach-the-api-through-their-own-origin.md)) ([ADR-0015](./docs/adr/0015-frontends-reach-the-api-through-their-own-origin.md))
10. **shadcn/ui for infrastructure, hand-built for brand.** Dialog, Sheet, Dropdown, Tabs, Button
and Input come from the registry and are owned as source in `@sport/ui`. The hero, mega menu,
header, product card, gallery and PDP are written by hand in
`apps/storefront/src/components/commerce/` — those are the store, and a registry component
would make them look like a template.
([ADR-0017](./docs/adr/0017-shadcn-for-infrastructure-hand-built-for-brand.md))
### Languages ### Languages
Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`. Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`.
@@ -239,22 +245,23 @@ locale-in-path would buy nothing.
## Roadmap ## Roadmap
| Milestone | Scope | | Milestone | Scope |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------- | | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | | **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI |
| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data | | **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data |
| **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management | | **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management |
| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory | | **M3** ✅ | Admin catalog: write API, variant matrix, media uploads, inventory ledger, product editor (option builder + per-locale tabs) |
| **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain | | **M4** ✅ | Storefront catalog — listings, PDP, variant selector, filters, sort control, load-more pagination and a mobile filter sheet |
| **M5** | Cart, checkout, orders | | **M5** | Cart, checkout, orders |
| **M6** | Search + faceting | | **M6** | Search + faceting |
| **M7** | Promotions, coupons, reviews, CMS | | **M7** | Promotions, coupons, reviews, CMS |
| **M8** | Customer account | | **M8** | Customer account |
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
**Recommended next step: M3 (admin catalog write path).** Reads, auth and RBAC are in place, so **Recommended next step: M5 (cart & checkout).** M3 now closes the loop end to end: an operator
the product editor and variant matrix now have everything they need — a known operator, a creates a product with per-locale content, defines the option axes, gets a generated variant matrix,
permission to check, and a catalog to edit. It is also what makes the seed replaceable by real prices it, attaches imagery per colourway, receives stock through the ledger and publishes — and the
merchandising. result renders on the storefront in both languages. Cart and checkout are the first flows that put
the variant model under real concurrency.
--- ---
@@ -262,11 +269,13 @@ merchandising.
Everything below was run, not assumed: Everything below was run, not assumed:
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 25/25 Turborepo tasks pass; - `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 26/26 Turborepo tasks pass;
`pnpm format:check` clean `pnpm format:check` clean
- 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, - 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
- **25 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language` - **44 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
variant matrix planner, the HTTP client's fetch receiver and retry recursion, and the inventory
list's variant-driven projection
- Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in - Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in
both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`) both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`)
- Storefront: every route 200 in both locales; `/en/products/<vi-slug>` → 307 → - Storefront: every route 200 in both locales; `/en/products/<vi-slug>` → 307 →
@@ -283,15 +292,28 @@ Everything below was run, not assumed:
### Verified in a real browser ### Verified in a real browser
Server-side checks and curl are not sufficient for client behaviour — three bugs proved it. Server-side checks and curl are not sufficient for client behaviour. Confirmed by clicking
Confirmed by clicking through Chrome with the console and network panel open: through Chrome with the console and network panel open:
- Admin sign-in issues exactly **one** `POST /auth/admin/login`, then redirects to the dashboard - Admin sign-in issues exactly **one** request, then redirects; sidebar is permission-filtered;
- Sidebar is filtered by the signed-in operator's permissions; users table and role viewer load users table and role viewer load real data; language switch preserves session and page
real data; language switch preserves the session and the current page; sign-out returns to login
- Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes, - Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes,
SKU updates, and switching language moves between translated slugs SKU updates, language switch moves between translated slugs; filters apply; all grid images load
- Filters apply (`/men?colors=black&onSale=true`), and all 16 grid images load - **Admin catalog (M3):** product list with live stock and price ranges; publish/unpublish;
media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG
landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table
wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request
- **Listing controls (M4):** sort menu changes the order and the URL together
(`?sort=price_asc`), and is shareable; "load more" appends the next page in place without
touching the address bar, updates "showing N of M", and disappears when the set is exhausted;
following the same button's `href` with JavaScript off returns a distinct, correctly
locale-prefixed second page — page 1 and page 2 verified disjoint with a working cursor chain
- **The whole M3 loop, authored through the UI:** created a product with vi + en content, two
colourways and two sizes → 4 variants generated with correct SKUs and translated titles
(`Đen / M`, `Xanh Neon / L`) → edited two prices and one sale price, with only the changed rows
sent → attached one image per colourway → received stock on all four variants → published →
the PDP renders in both languages at per-locale slugs, the gallery and price track the colourway
swatch, and the sale price shows in red
Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They 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 come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are
+2
View File
@@ -17,10 +17,12 @@
"@sport/ui": "workspace:*", "@sport/ui": "workspace:*",
"@sport/validation": "workspace:*", "@sport/validation": "workspace:*",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"lucide-react": "1.31.0",
"next": "catalog:", "next": "catalog:",
"next-intl": "^4.13.6", "next-intl": "^4.13.6",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"tw-animate-css": "1.4.0",
"zod": "catalog:", "zod": "catalog:",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -1,22 +1,24 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { InventoryTable } from '@/features/inventory/inventory-table';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.inventory'); const t = await getTranslations('pages.inventory');
return { title: t('title') }; return { title: t('title') };
} }
export default async function InventoryPage() { export default async function Page() {
const t = await getTranslations('pages.inventory'); const t = await getTranslations('pages.inventory');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="inventory.read" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M3 — admin catalog" </header>
/>
<InventoryTable />
</div>
); );
} }
+10 -8
View File
@@ -1,22 +1,24 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { MediaLibrary } from '@/features/media/media-library';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.media'); const t = await getTranslations('pages.media');
return { title: t('title') }; return { title: t('title') };
} }
export default async function MediaPage() { export default async function Page() {
const t = await getTranslations('pages.media'); const t = await getTranslations('pages.media');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="media.read" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M3 — admin catalog" </header>
/>
<MediaLibrary />
</div>
); );
} }
@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { ProductEditor } from '@/features/products/product-editor';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('editor');
return { title: t('editTitle') };
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<div className="p-8">
<ProductEditor productId={id} />
</div>
);
}
@@ -0,0 +1,34 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { ProductForm } from '@/features/products/product-form';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('editor');
return { title: t('createTitle') };
}
/**
* Declared as a sibling of `[id]`, so Next matches this literal segment first —
* otherwise `/products/new` would load the editor for a product with the id
* "new".
*/
export default async function Page() {
const t = await getTranslations('editor');
return (
<div className="space-y-6 p-8">
<header className="flex flex-wrap items-center gap-3">
<Link href="/products" className="text-ink-500 hover:text-ink-950 text-sm">
← {t('backToList')}
</Link>
<h1 className="text-xl font-bold">{t('createTitle')}</h1>
</header>
<p className="text-ink-500 max-w-2xl text-sm">{t('createHint')}</p>
<ProductForm />
</div>
);
}
@@ -1,22 +1,24 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { ProductsTable } from '@/features/products/products-table';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.products'); const t = await getTranslations('pages.products');
return { title: t('title') }; return { title: t('title') };
} }
export default async function ProductsPage() { export default async function Page() {
const t = await getTranslations('pages.products'); const t = await getTranslations('pages.products');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="product.read" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M3 — admin catalog" </header>
/>
<ProductsTable />
</div>
); );
} }
+2 -2
View File
@@ -71,7 +71,7 @@ export function LoginForm() {
required required
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
invalid={Boolean(error)} aria-invalid={Boolean(error)}
/> />
</div> </div>
@@ -87,7 +87,7 @@ export function LoginForm() {
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
invalid={Boolean(error)} aria-invalid={Boolean(error)}
/> />
</div> </div>
@@ -0,0 +1,189 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError, type StockAdjustment } from '@sport/api-client';
import { PERMISSIONS, type InventoryLevel } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
const LOW_STOCK = 5;
export function InventoryTable() {
const t = useTranslations('inventory');
const { can } = useSession();
const [levels, setLevels] = useState<InventoryLevel[] | null>(null);
const [query, setQuery] = useState('');
const [lowOnly, setLowOnly] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [delta, setDelta] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const result = await browserApi.catalogAdmin.listInventory({
perPage: 50,
...(query ? { q: query } : {}),
...(lowOnly ? { lowStockOnly: true } : {}),
});
setLevels([...result.items]);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, lowOnly, t]);
useEffect(() => {
const timer = setTimeout(() => void load(), 250);
return () => clearTimeout(timer);
}, [load]);
async function adjust(level: InventoryLevel) {
const amount = Number.parseInt(delta, 10);
if (!Number.isInteger(amount) || amount === 0) return;
setBusy(true);
try {
const payload: StockAdjustment = {
variantId: level.variantId,
// A manual correction from this screen is exactly that. Receipts,
// returns and damage are separate reasons the fuller inventory screen
// will offer — mislabelling them here would poison the ledger.
reason: 'MANUAL_ADJUSTMENT',
quantityDelta: amount,
note: 'Adjusted from the inventory table',
};
await browserApi.catalogAdmin.adjustStock(payload);
setEditing(null);
setDelta('');
await load();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('adjustFailed'));
} finally {
setBusy(false);
}
}
if (!can(PERMISSIONS.INVENTORY_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
type="search"
placeholder={t('searchPlaceholder')}
value={query}
onChange={(event) => setQuery(event.target.value)}
className="max-w-xs"
/>
<label className="text-ink-600 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={lowOnly}
onChange={(event) => setLowOnly(event.target.checked)}
/>
{t('lowStockOnly')}
</label>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!levels ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('table.sku')}</th>
<th className="px-4 py-3 font-semibold">{t('table.product')}</th>
<th className="px-4 py-3 font-semibold">{t('table.onHand')}</th>
<th className="px-4 py-3 font-semibold">{t('table.reserved')}</th>
<th className="px-4 py-3 font-semibold">{t('table.available')}</th>
<th className="px-4 py-3 font-semibold" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{levels.map((level) => (
<tr key={`${level.variantId}-${level.locationId}`}>
<td className="px-4 py-3 font-mono text-xs">{level.sku}</td>
<td className="px-4 py-3">
<p className="truncate">{level.productName}</p>
<p className="text-ink-400 text-xs">{level.variantTitle}</p>
</td>
<td className="px-4 py-3">{level.onHand}</td>
<td className="text-ink-500 px-4 py-3">{level.reserved}</td>
<td className="px-4 py-3">
<span
className={cn(
'font-medium',
level.available === 0
? 'text-danger'
: level.available <= LOW_STOCK
? 'text-warning'
: '',
)}
>
{level.available}
</span>
{level.available === 0 ? (
<Badge variant="neutral" className="ml-2">
{t('outOfStock')}
</Badge>
) : null}
</td>
<td className="px-4 py-3 text-right">
{!can(PERMISSIONS.INVENTORY_UPDATE) ? null : editing === level.variantId ? (
<div className="flex items-center justify-end gap-2">
<Input
type="number"
value={delta}
onChange={(event) => setDelta(event.target.value)}
placeholder="+10"
className="h-9 w-24"
aria-label={t('deltaLabel')}
/>
<Button size="sm" disabled={busy} onClick={() => void adjust(level)}>
{t('apply')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>
{t('cancel')}
</Button>
</div>
) : (
<Button
size="sm"
variant="secondary"
onClick={() => {
setEditing(level.variantId);
setDelta('');
}}
>
{t('adjust')}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,197 @@
'use client';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type MediaAssetSummary } from '@sport/types';
import { Button, Skeleton } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
interface UploadState {
name: string;
status: 'uploading' | 'failed';
error?: string;
}
/**
* Asset library with direct-to-storage upload.
*
* The browser gets a presigned URL, PUTs the file straight to object storage,
* then tells the API the asset exists (ADR-0009). Nothing streams through the
* API, so a 20 MB file is the storage provider's problem, not ours.
*/
export function MediaLibrary() {
const t = useTranslations('media');
const { can } = useSession();
const inputRef = useRef<HTMLInputElement>(null);
const [assets, setAssets] = useState<MediaAssetSummary[] | null>(null);
const [uploads, setUploads] = useState<UploadState[]>([]);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const result = await browserApi.catalogAdmin.listMedia({ perPage: 60 });
setAssets([...result.items]);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [t]);
useEffect(() => {
let cancelled = false;
// Declared inside the effect and guarded, rather than calling `load()`
// directly: the lint rule cannot see that every setState here happens after
// an await, and the cancellation flag is what stops a slow response from
// writing into an unmounted component anyway.
async function initialLoad() {
try {
const result = await browserApi.catalogAdmin.listMedia({ perPage: 60 });
if (cancelled) return;
setAssets([...result.items]);
setError(null);
} catch (caught) {
if (cancelled) return;
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}
void initialLoad();
return () => {
cancelled = true;
};
}, [t]);
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const list = [...files];
setUploads(list.map((file) => ({ name: file.name, status: 'uploading' as const })));
// Sequential rather than parallel: a merchandiser dropping 30 photos at
// once would otherwise open 30 simultaneous uploads and starve the rest of
// the page's requests.
for (const file of list) {
try {
await browserApi.catalogAdmin.uploadFile(file);
setUploads((current) => current.filter((upload) => upload.name !== file.name));
} catch (caught) {
const message = isApiClientError(caught) ? caught.message : t('uploadFailed');
setUploads((current) =>
current.map((upload) =>
upload.name === file.name ? { ...upload, status: 'failed', error: message } : upload,
),
);
}
}
await load();
if (inputRef.current) inputRef.current.value = '';
}
async function remove(asset: MediaAssetSummary) {
try {
await browserApi.catalogAdmin.deleteMedia(asset.id);
await load();
} catch (caught) {
// The API refuses to delete an asset a product still uses — surface that
// reason rather than a generic failure.
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
}
}
if (!can(PERMISSIONS.MEDIA_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-6">
{can(PERMISSIONS.MEDIA_UPLOAD) ? (
<div className="border-ink-300 flex flex-wrap items-center gap-3 border border-dashed bg-white p-6">
<input
ref={inputRef}
id="media-upload"
aria-label={t('chooseFiles')}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,image/avif"
className="sr-only"
onChange={(event) => void handleFiles(event.target.files)}
/>
<Button type="button" onClick={() => inputRef.current?.click()}>
{t('chooseFiles')}
</Button>
<p className="text-ink-500 text-sm">{t('uploadHint')}</p>
</div>
) : null}
{uploads.length > 0 ? (
<ul className="space-y-1 text-sm">
{uploads.map((upload) => (
<li
key={upload.name}
className={upload.status === 'failed' ? 'text-danger' : 'text-ink-500'}
>
{upload.name} — {upload.status === 'failed' ? upload.error : t('uploading')}
</li>
))}
</ul>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!assets ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6" aria-busy="true">
{Array.from({ length: 6 }, (_, index) => (
<Skeleton key={index} className="aspect-square w-full" />
))}
</div>
) : assets.length === 0 ? (
<p className="text-ink-500 py-12 text-center text-sm">{t('empty')}</p>
) : (
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
{assets.map((asset) => (
<li key={asset.id} className="border-ink-200 group relative border bg-white">
<div className="bg-ink-100 relative aspect-square overflow-hidden">
<Image
src={asset.url}
alt={asset.altText ?? ''}
fill
sizes="(min-width: 1024px) 16vw, 33vw"
className="object-cover"
/>
</div>
<div className="p-2">
<p className="text-ink-600 truncate text-xs">{asset.altText ?? asset.storageKey}</p>
<p className="text-ink-400 text-[0.625rem]">
{asset.width && asset.height ? `${asset.width}×${asset.height} · ` : ''}
{Math.round(asset.sizeBytes / 1024)} KB
</p>
</div>
{can(PERMISSIONS.MEDIA_DELETE) ? (
<button
type="button"
onClick={() => void remove(asset)}
className="rounded-card bg-ink-950/80 absolute right-1 top-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest text-white opacity-0 transition-opacity group-hover:opacity-100"
>
{t('delete')}
</button>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,262 @@
'use client';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import {
DEFAULT_LOCALE,
PERMISSIONS,
type AdminProductDetail,
type MediaAssetSummary,
} from '@sport/types';
import { Button, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
interface Slot {
mediaId: string;
url: string;
optionValueId: string | null;
}
/**
* Product imagery: which assets, in what order, and which colourway each belongs
* to.
*
* That last part is what makes the storefront gallery swap when a shopper picks
* a colour — `ProductImage.optionValueId` is the link, and this is the only
* place it gets set.
*/
export function ImageManager({
product,
onChange,
}: {
product: AdminProductDetail;
onChange: (product: AdminProductDetail) => void;
}) {
const t = useTranslations('editor');
const { can } = useSession();
const [slots, setSlots] = useState<Slot[]>(() =>
product.images.map((image) => ({
mediaId: image.mediaId,
url: image.url,
optionValueId: image.optionValueId,
})),
);
const [library, setLibrary] = useState<MediaAssetSummary[] | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
// Colourway options an image can be attached to.
const colourValues = product.options.find((option) => option.key === 'colour')?.values ?? [];
useEffect(() => {
let cancelled = false;
async function loadLibrary() {
try {
const result = await browserApi.catalogAdmin.listMedia({ perPage: 60 });
if (!cancelled) setLibrary([...result.items]);
} catch (caught) {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}
void loadLibrary();
return () => {
cancelled = true;
};
}, [t]);
async function save() {
setSaving(true);
setError(null);
setSaved(false);
try {
const updated = await browserApi.catalogAdmin.setImages(
product.id,
// Position is the array index — the order shown is the order stored.
slots.map((slot, index) => ({
mediaId: slot.mediaId,
position: index,
optionValueId: slot.optionValueId,
})),
);
setSlots(
updated.images.map((image) => ({
mediaId: image.mediaId,
url: image.url,
optionValueId: image.optionValueId,
})),
);
onChange(updated);
setSaved(true);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= slots.length) return;
setSlots((current) => {
const moved = current[index];
if (!moved) return current;
const next = [...current];
next.splice(index, 1);
next.splice(target, 0, moved);
return next;
});
}
const editable = can(PERMISSIONS.PRODUCT_UPDATE);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold uppercase tracking-widest">{t('attached')}</h3>
{saved ? <span className="text-success ml-auto text-xs">{t('saved')}</span> : null}
{editable ? (
<Button
size="sm"
className={cn(!saved && 'ml-auto')}
disabled={saving}
onClick={() => void save()}
>
{saving ? t('saving') : t('saveImages')}
</Button>
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{slots.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noImages')}</p>
) : (
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
{slots.map((slot, index) => (
<li key={`${slot.mediaId}-${index}`} className="border-ink-200 space-y-2 border p-2">
<div className="bg-ink-100 relative aspect-square overflow-hidden">
<Image src={slot.url} alt="" fill sizes="16vw" className="object-cover" />
{index === 0 ? (
<span className="bg-ink-950 absolute left-1 top-1 px-1.5 py-0.5 text-[0.5rem] font-semibold uppercase tracking-widest text-white">
{t('primary')}
</span>
) : null}
</div>
{colourValues.length > 0 ? (
<select
value={slot.optionValueId ?? ''}
disabled={!editable}
onChange={(event) =>
setSlots((current) =>
current.map((item, i) =>
i === index ? { ...item, optionValueId: event.target.value || null } : item,
),
)
}
aria-label={t('colourway')}
className="border-ink-200 w-full border px-1 py-1 text-xs"
>
<option value="">{t('anyColourway')}</option>
{colourValues.map((value) => (
<option key={value.id} value={value.id}>
{value.labels[DEFAULT_LOCALE] ?? value.value}
</option>
))}
</select>
) : null}
{editable ? (
<div className="flex justify-between gap-1">
<button
type="button"
onClick={() => move(index, -1)}
className="text-ink-500 hover:text-ink-950 px-1 text-xs"
aria-label={t('moveLeft')}
>
←
</button>
<button
type="button"
onClick={() => setSlots((c) => c.filter((_, i) => i !== index))}
className="text-danger text-xs"
>
{t('remove')}
</button>
<button
type="button"
onClick={() => move(index, 1)}
className="text-ink-500 hover:text-ink-950 px-1 text-xs"
aria-label={t('moveRight')}
>
→
</button>
</div>
) : null}
</li>
))}
</ul>
)}
{editable ? (
<div className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-widest">{t('library')}</h3>
{!library ? (
<div className="grid grid-cols-4 gap-3 lg:grid-cols-8" aria-busy="true">
{Array.from({ length: 8 }, (_, index) => (
<Skeleton key={index} className="aspect-square w-full" />
))}
</div>
) : (
<ul className="grid grid-cols-4 gap-3 lg:grid-cols-8">
{library.map((asset) => {
const used = slots.some((slot) => slot.mediaId === asset.id);
return (
<li key={asset.id}>
<button
type="button"
disabled={used}
onClick={() =>
setSlots((current) => [
...current,
{ mediaId: asset.id, url: asset.url, optionValueId: null },
])
}
className={cn(
'bg-ink-100 relative block aspect-square w-full overflow-hidden border transition-colors',
used
? 'border-ink-200 cursor-not-allowed opacity-30'
: 'border-ink-200 hover:border-ink-950',
)}
title={asset.altText ?? asset.storageKey}
>
<Image src={asset.url} alt="" fill sizes="12vw" className="object-cover" />
</button>
</li>
);
})}
</ul>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminProductDetail } from '@sport/types';
import { Badge, Button, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { ImageManager } from './image-manager';
import { ProductForm } from './product-form';
import { VariantGrid } from './variant-grid';
type Tab = 'details' | 'variants' | 'images';
const STATUS_VARIANT = { ACTIVE: 'success', DRAFT: 'warning', ARCHIVED: 'neutral' } as const;
/**
* The editor shell.
*
* Loads the product client-side rather than on the server because every tab
* mutates it and then needs the authoritative version back — the API returns
* the full product from each write, so the page can stay in step without a
* round-trip through server rendering.
*/
export function ProductEditor({ productId }: { productId: string }) {
const t = useTranslations('editor');
const { can } = useSession();
const [product, setProduct] = useState<AdminProductDetail | null>(null);
const [tab, setTab] = useState<Tab>('details');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const result = await browserApi.catalogAdmin.getProduct(productId);
if (!cancelled) setProduct(result);
} catch (caught) {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}
void load();
return () => {
cancelled = true;
};
}, [productId, t]);
async function setStatus(status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED') {
setBusy(true);
setError(null);
try {
setProduct(await browserApi.catalogAdmin.setStatus(productId, status));
} catch (caught) {
// Publishing without variants is rejected by the API — surface that
// reason rather than a generic failure.
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setBusy(false);
}
}
if (error && !product) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!product) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const name = product.translations.vi?.name ?? product.translations.en?.name ?? '—';
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center gap-3">
<Link href="/products" className="text-ink-500 hover:text-ink-950 text-sm">
← {t('backToList')}
</Link>
<h1 className="text-xl font-bold">{name}</h1>
<Badge variant={STATUS_VARIANT[product.status]}>{product.status}</Badge>
{can(PERMISSIONS.PRODUCT_PUBLISH) ? (
<div className="ml-auto flex gap-2">
{product.status === 'ACTIVE' ? (
<Button
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void setStatus('DRAFT')}
>
{t('unpublish')}
</Button>
) : (
<Button size="sm" disabled={busy} onClick={() => void setStatus('ACTIVE')}>
{t('publish')}
</Button>
)}
{product.status !== 'ARCHIVED' ? (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => void setStatus('ARCHIVED')}
>
{t('archive')}
</Button>
) : null}
</div>
) : null}
</header>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<nav className="border-ink-200 flex gap-1 border-b">
{(['details', 'variants', 'images'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
aria-current={tab === value ? 'page' : undefined}
className={cn(
'-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors',
tab === value
? 'border-ink-950 text-ink-950'
: 'text-ink-500 hover:text-ink-950 border-transparent',
)}
>
{t(`tab.${value}`)}
{value === 'variants' ? (
<span className="text-ink-400 ml-2 text-xs">{product.variants.length}</span>
) : null}
</button>
))}
</nav>
{tab === 'details' ? <ProductForm product={product} onChange={setProduct} /> : null}
{/*
Keyed on the matrix shape: editing the options regenerates variants, and
the grid seeds its draft from props exactly once. Remounting it when the
set of variant ids changes is what keeps the inputs honest.
*/}
{tab === 'variants' ? (
<VariantGrid
key={product.variants.map((variant) => variant.id).join(',')}
product={product}
onChange={setProduct}
/>
) : null}
{tab === 'images' ? <ImageManager product={product} onChange={setProduct} /> : null}
</div>
);
}
@@ -0,0 +1,523 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import {
DEFAULT_LOCALE,
LOCALES,
LOCALE_LABELS,
type AdminProductDetail,
type Locale,
} from '@sport/types';
import { Badge, Button, Input, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
const GENDERS = ['MEN', 'WOMEN', 'KIDS', 'UNISEX'] as const;
const SPORTS = ['RUNNING', 'FOOTBALL', 'TRAINING', 'GYM', 'BADMINTON', 'LIFESTYLE'] as const;
interface DraftTranslation {
name: string;
shortDescription: string;
description: string;
}
interface DraftValue {
value: string;
swatchHex: string;
labels: Record<Locale, string>;
}
interface DraftOption {
key: string;
names: Record<Locale, string>;
values: DraftValue[];
}
const emptyTranslations = (): Record<Locale, DraftTranslation> =>
Object.fromEntries(
LOCALES.map((locale) => [locale, { name: '', shortDescription: '', description: '' }]),
) as Record<Locale, DraftTranslation>;
/** A sensible starting point: every apparel product has these two axes. */
const starterOptions = (): DraftOption[] => [
{
key: 'colour',
names: { vi: 'Màu sắc', en: 'Colour' },
values: [{ value: 'black', swatchHex: '#111111', labels: { vi: 'Đen', en: 'Black' } }],
},
{
key: 'size',
names: { vi: 'Kích cỡ', en: 'Size' },
values: [{ value: 'm', swatchHex: '', labels: { vi: 'M', en: 'M' } }],
},
];
/**
* Create and edit form for a product's content and option axes.
*
* Saving options re-runs the variant matrix on the server, so this form owns
* *what varies* while the variant grid owns *what each combination costs*.
* Splitting them that way means editing a price never risks regenerating the
* matrix, and adding a colour never silently overwrites a price.
*/
export function ProductForm({
product,
onChange,
}: {
product?: AdminProductDetail;
/**
* Hands the saved product back to the editor shell, which owns it. Without
* this the shell keeps rendering the version it loaded, and a tab that
* compares its draft against that stale prop never sees its own save.
*/
onChange?: (product: AdminProductDetail) => void;
}) {
const t = useTranslations('editor');
const router = useRouter();
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
const [translations, setTranslations] = useState<Record<Locale, DraftTranslation>>(() => {
if (!product) return emptyTranslations();
const seeded = emptyTranslations();
for (const key of LOCALES) {
const existing = product.translations[key];
if (existing) {
seeded[key] = {
name: existing.name,
shortDescription: existing.shortDescription ?? '',
description: existing.description ?? '',
};
}
}
return seeded;
});
const [genders, setGenders] = useState<string[]>(() => [...(product?.genderTargets ?? [])]);
const [sports, setSports] = useState<string[]>(() => [...(product?.sportTypes ?? [])]);
const [skuPrefix, setSkuPrefix] = useState('');
const [basePrice, setBasePrice] = useState('0');
const [options, setOptions] = useState<DraftOption[]>(() => {
if (!product || product.options.length === 0) return starterOptions();
return product.options.map((option) => ({
key: option.key,
names: Object.fromEntries(LOCALES.map((key) => [key, option.names[key] ?? ''])) as Record<
Locale,
string
>,
values: option.values.map((value) => ({
value: value.value,
swatchHex: value.swatchHex ?? '',
labels: Object.fromEntries(LOCALES.map((key) => [key, value.labels[key] ?? ''])) as Record<
Locale,
string
>,
})),
}));
});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
function buildPayload() {
// Only locales the operator actually filled in are sent. Posting an empty
// name would create a translation row that resolves to a blank product page
// in that language — worse than having no translation at all, because the
// fallback would no longer apply.
const filled = Object.fromEntries(
LOCALES.filter((key) => translations[key].name.trim().length > 0).map((key) => [
key,
{
name: translations[key].name.trim(),
shortDescription: translations[key].shortDescription.trim() || null,
description: translations[key].description.trim() || null,
},
]),
);
return {
translations: filled,
genderTargets: genders,
sportTypes: sports,
...(skuPrefix.trim() ? { skuPrefix: skuPrefix.trim() } : {}),
basePriceAmount: Number.parseInt(basePrice, 10) || 0,
options: options.map((option, index) => ({
key: option.key,
position: index,
names: Object.fromEntries(
LOCALES.filter((key) => option.names[key].trim()).map((key) => [
key,
option.names[key].trim(),
]),
),
values: option.values.map((value, valueIndex) => ({
value: value.value,
position: valueIndex,
swatchHex: value.swatchHex.trim() || null,
labels: Object.fromEntries(
LOCALES.filter((key) => value.labels[key].trim()).map((key) => [
key,
value.labels[key].trim(),
]),
),
})),
})),
};
}
async function save() {
setSaving(true);
setError(null);
setSaved(false);
try {
const payload = buildPayload();
if (product) {
onChange?.(await browserApi.catalogAdmin.updateProduct(product.id, payload));
setSaved(true);
} else {
const created = await browserApi.catalogAdmin.createProduct(payload);
// Straight into the editor: the operator's next step is always pricing
// the matrix that was just generated.
router.push(`/products/${created.id}`);
}
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
return (
<div className="space-y-8">
{/* ---- Content, per locale ---- */}
<section className="border-ink-200 space-y-4 border bg-white p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('content')}</h2>
<div className="flex gap-1">
{LOCALES.map((key) => (
<button
key={key}
type="button"
onClick={() => setLocale(key)}
className={cn(
'rounded-card border px-3 py-1.5 text-xs font-medium transition-colors',
locale === key
? 'border-ink-950 bg-ink-950 text-white'
: 'border-ink-200 text-ink-600 hover:border-ink-400',
)}
>
{LOCALE_LABELS[key]}
{translations[key].name.trim() ? '' : ' •'}
</button>
))}
</div>
</div>
<p className="text-ink-400 text-xs">{t('localeHint')}</p>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('name')}</span>
<Input
value={translations[locale].name}
onChange={(event) =>
setTranslations((current) => ({
...current,
[locale]: { ...current[locale], name: event.target.value },
}))
}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('shortDescription')}
</span>
<Input
value={translations[locale].shortDescription}
onChange={(event) =>
setTranslations((current) => ({
...current,
[locale]: { ...current[locale], shortDescription: event.target.value },
}))
}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('description')}
</span>
<textarea
rows={5}
value={translations[locale].description}
onChange={(event) =>
setTranslations((current) => ({
...current,
[locale]: { ...current[locale], description: event.target.value },
}))
}
className="border-ink-300 focus:border-ink-950 w-full border bg-white px-4 py-3 text-sm focus:outline-none"
/>
</label>
</section>
{/* ---- Merchandising facets ---- */}
<section className="border-ink-200 space-y-4 border bg-white p-6">
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('facets')}</h2>
<Chips label={t('gender')} all={GENDERS} selected={genders} onChange={setGenders} />
<Chips label={t('sport')} all={SPORTS} selected={sports} onChange={setSports} />
</section>
{/* ---- Options ---- */}
<section className="border-ink-200 space-y-4 border bg-white p-6">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('options')}</h2>
<Button
size="sm"
variant="secondary"
onClick={() =>
setOptions((current) => [
...current,
{ key: `option_${current.length + 1}`, names: { vi: '', en: '' }, values: [] },
])
}
>
{t('addOption')}
</Button>
</div>
<p className="text-ink-400 text-xs">{t('optionsHint')}</p>
{options.map((option, optionIndex) => (
<OptionEditor
key={optionIndex}
option={option}
locale={locale}
onChange={(next) =>
setOptions((current) => current.map((item, i) => (i === optionIndex ? next : item)))
}
onRemove={() => setOptions((current) => current.filter((_, i) => i !== optionIndex))}
/>
))}
</section>
{!product ? (
<section className="border-ink-200 space-y-4 border bg-white p-6">
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('generation')}</h2>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('skuPrefix')}
</span>
<Input
value={skuPrefix}
onChange={(event) => setSkuPrefix(event.target.value)}
placeholder="VEL-ART"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('basePrice')}
</span>
<Input
type="number"
value={basePrice}
onChange={(event) => setBasePrice(event.target.value)}
/>
</label>
</div>
<p className="text-ink-400 text-xs">{t('generationHint')}</p>
</section>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<div className="flex items-center gap-3">
<Button size="lg" disabled={saving} onClick={() => void save()}>
{saving ? t('saving') : product ? t('save') : t('create')}
</Button>
{product ? <Badge variant="outline">{t('matrixWarning')}</Badge> : null}
{saved ? <span className="text-success text-xs">{t('saved')}</span> : null}
</div>
</div>
);
}
function Chips({
label,
all,
selected,
onChange,
}: {
label: string;
all: readonly string[];
selected: string[];
onChange: (next: string[]) => void;
}) {
return (
<div className="space-y-2">
<span className="text-xs font-semibold uppercase tracking-widest">{label}</span>
<div className="flex flex-wrap gap-2">
{all.map((item) => {
const active = selected.includes(item);
return (
<button
key={item}
type="button"
aria-pressed={active}
onClick={() =>
onChange(active ? selected.filter((x) => x !== item) : [...selected, item])
}
className={cn(
'rounded-card border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-ink-950 bg-ink-950 text-white'
: 'border-ink-200 text-ink-600 hover:border-ink-400',
)}
>
{item}
</button>
);
})}
</div>
</div>
);
}
function OptionEditor({
option,
locale,
onChange,
onRemove,
}: {
option: DraftOption;
locale: Locale;
onChange: (next: DraftOption) => void;
onRemove: () => void;
}) {
const t = useTranslations('editor');
return (
<div className="border-ink-200 space-y-3 border p-4">
<div className="flex flex-wrap items-end gap-3">
<label className="space-y-1.5">
<span className="text-ink-500 text-[0.625rem] font-semibold uppercase tracking-widest">
{t('optionKey')}
</span>
<Input
value={option.key}
onChange={(event) => onChange({ ...option, key: event.target.value })}
className="w-40"
/>
</label>
<label className="space-y-1.5">
<span className="text-ink-500 text-[0.625rem] font-semibold uppercase tracking-widest">
{t('optionName')}
</span>
<Input
value={option.names[locale]}
onChange={(event) =>
onChange({ ...option, names: { ...option.names, [locale]: event.target.value } })
}
className="w-56"
/>
</label>
<Button size="sm" variant="ghost" onClick={onRemove} className="ml-auto">
{t('removeOption')}
</Button>
</div>
<ul className="space-y-2">
{option.values.map((value, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<Input
value={value.value}
onChange={(event) =>
onChange({
...option,
values: option.values.map((item, i) =>
i === index ? { ...item, value: event.target.value } : item,
),
})
}
placeholder={t('valueKey')}
aria-label={t('valueKey')}
className="w-32"
/>
<Input
value={value.labels[locale]}
onChange={(event) =>
onChange({
...option,
values: option.values.map((item, i) =>
i === index
? { ...item, labels: { ...item.labels, [locale]: event.target.value } }
: item,
),
})
}
placeholder={t('valueLabel')}
aria-label={t('valueLabel')}
className="w-44"
/>
<Input
value={value.swatchHex}
onChange={(event) =>
onChange({
...option,
values: option.values.map((item, i) =>
i === index ? { ...item, swatchHex: event.target.value } : item,
),
})
}
placeholder="#111111"
aria-label={t('swatch')}
className="w-28"
/>
{value.swatchHex ? (
<span
className="border-ink-200 size-8 shrink-0 rounded-full border"
style={{ backgroundColor: value.swatchHex }}
aria-hidden
/>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() =>
onChange({ ...option, values: option.values.filter((_, i) => i !== index) })
}
>
{t('removeValue')}
</Button>
</li>
))}
</ul>
<Button
size="sm"
variant="secondary"
onClick={() =>
onChange({
...option,
values: [...option.values, { value: '', swatchHex: '', labels: { vi: '', en: '' } }],
})
}
>
{t('addValue')}
</Button>
</div>
);
}
@@ -0,0 +1,220 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { MINOR_UNIT_SCALE, PERMISSIONS, type AdminProductListItem, type Money } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
const STATUS_VARIANT = {
ACTIVE: 'success',
DRAFT: 'warning',
ARCHIVED: 'neutral',
} as const;
/** Admin money display. VND has no minor unit, so this is a plain group format. */
function formatMoney(money: Money): string {
const scale = MINOR_UNIT_SCALE[money.currency];
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: money.currency,
minimumFractionDigits: scale,
maximumFractionDigits: scale,
}).format(money.amount / 10 ** scale);
}
export function ProductsTable() {
const t = useTranslations('catalog');
const { can } = useSession();
const [items, setItems] = useState<AdminProductListItem[] | null>(null);
const [total, setTotal] = useState(0);
const [query, setQuery] = useState('');
const [status, setStatus] = useState<'' | 'DRAFT' | 'ACTIVE' | 'ARCHIVED'>('');
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const result = await browserApi.catalogAdmin.listProducts({
perPage: 50,
...(query ? { q: query } : {}),
...(status ? { status } : {}),
});
setItems([...result.items]);
setTotal(result.pageInfo.totalItems);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, status, t]);
useEffect(() => {
// Debounced so typing in the search box does not fire a request per keystroke.
const timer = setTimeout(() => void load(), 250);
return () => clearTimeout(timer);
}, [load]);
async function toggleStatus(product: AdminProductListItem) {
setBusyId(product.id);
try {
await browserApi.catalogAdmin.setStatus(
product.id,
product.status === 'ACTIVE' ? 'DRAFT' : 'ACTIVE',
);
await load();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('actionFailed'));
} finally {
setBusyId(null);
}
}
if (!can(PERMISSIONS.PRODUCT_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
type="search"
placeholder={t('searchPlaceholder')}
value={query}
onChange={(event) => setQuery(event.target.value)}
className="max-w-xs"
/>
<div className="flex gap-1">
{(['', 'ACTIVE', 'DRAFT', 'ARCHIVED'] as const).map((value) => (
<button
key={value || 'all'}
type="button"
onClick={() => setStatus(value)}
className={cn(
'rounded-card border px-3 py-1.5 text-xs font-medium transition-colors',
status === value
? 'border-ink-950 bg-ink-950 text-white'
: 'border-ink-200 text-ink-600 hover:border-ink-400',
)}
>
{value ? t(`status.${value}`) : t('status.ALL')}
</button>
))}
</div>
<span className="text-ink-500 ml-auto text-xs">{t('count', { count: total })}</span>
{can(PERMISSIONS.PRODUCT_CREATE) ? (
<Button size="sm" asChild>
<Link href="/products/new">{t('newProduct')}</Link>
</Button>
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!items ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : items.length === 0 ? (
<p className="text-ink-500 py-12 text-center text-sm">{t('empty')}</p>
) : (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-4xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('table.product')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3 font-semibold">{t('table.variants')}</th>
<th className="px-4 py-3 font-semibold">{t('table.stock')}</th>
<th className="px-4 py-3 font-semibold">{t('table.price')}</th>
<th className="px-4 py-3 font-semibold" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{items.map((product) => (
<tr key={product.id}>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="bg-ink-100 relative size-10 shrink-0 overflow-hidden">
{product.thumbnailUrl ? (
<Image
src={product.thumbnailUrl}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<div className="min-w-0">
<Link
href={`/products/${product.id}`}
className="block truncate font-medium hover:underline"
>
{product.name}
</Link>
<p className="text-ink-400 truncate text-xs">
{product.brandName ?? '—'} · {product.slug}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[product.status]}>
{t(`status.${product.status}`)}
</Badge>
</td>
<td className="text-ink-600 px-4 py-3">{product.variantCount}</td>
<td className="px-4 py-3">
<span className={cn(product.totalStock === 0 && 'text-danger')}>
{product.totalStock}
</span>
</td>
<td className="text-ink-600 px-4 py-3">
{product.priceRange
? product.priceRange.min.amount === product.priceRange.max.amount
? formatMoney(product.priceRange.min)
: `${formatMoney(product.priceRange.min)} – ${formatMoney(product.priceRange.max)}`
: '—'}
</td>
<td className="px-4 py-3">
{/* Flex, not inline spacing: the labels are translated, and
in Vietnamese "NGỪNG BÁN" is long enough to wrap the two
buttons onto separate lines without an explicit row. */}
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="secondary" asChild>
<Link href={`/products/${product.id}`}>{t('edit')}</Link>
</Button>
{can(PERMISSIONS.PRODUCT_PUBLISH) ? (
<Button
size="sm"
variant={product.status === 'ACTIVE' ? 'ghost' : 'default'}
disabled={busyId === product.id}
onClick={() => void toggleStatus(product)}
>
{product.status === 'ACTIVE' ? t('unpublish') : t('publish')}
</Button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,217 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { isApiClientError, type VariantPatch } from '@sport/api-client';
import { PERMISSIONS, type AdminProductDetail, type AdminProductVariant } from '@sport/types';
import { Badge, Button, Input, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
type Draft = Record<string, { sku: string; price: string; sale: string }>;
function toDraft(variants: readonly AdminProductVariant[]): Draft {
return Object.fromEntries(
variants.map((variant) => [
variant.id,
{
sku: variant.sku,
price: String(variant.priceAmount),
sale: variant.salePriceAmount === null ? '' : String(variant.salePriceAmount),
},
]),
);
}
/**
* The variant matrix, editable in place.
*
* This is where ADR-0003 becomes an interface: one row per purchasable
* combination, each with its own SKU, price and stock. Stock is shown but not
* editable here — it moves through inventory so that every change carries a
* reason and lands in the ledger.
*
* Only rows the operator actually changed are sent. Posting all 60 rows on
* every save would make the audit entry useless and would overwrite a
* colleague's concurrent edit with stale values from this page's load.
*/
export function VariantGrid({
product,
onChange,
}: {
product: AdminProductDetail;
onChange: (product: AdminProductDetail) => void;
}) {
const t = useTranslations('editor');
const { can } = useSession();
const [draft, setDraft] = useState<Draft>(() => toDraft(product.variants));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const active = product.variants.filter((variant) => variant.status === 'ACTIVE');
const archived = product.variants.filter((variant) => variant.status === 'ARCHIVED');
// Pairs rather than variants alone: carrying the draft row along proves it
// exists, so building the patch below needs no assertion.
const dirty = product.variants.flatMap((variant) => {
const row = draft[variant.id];
if (!row) return [];
const changed =
row.sku !== variant.sku ||
row.price !== String(variant.priceAmount) ||
row.sale !== (variant.salePriceAmount === null ? '' : String(variant.salePriceAmount));
return changed ? [{ variant, row }] : [];
});
function patchRow(id: string, patch: Partial<Draft[string]>) {
setDraft((current) => {
const row = current[id];
return row ? { ...current, [id]: { ...row, ...patch } } : current;
});
}
async function save() {
if (dirty.length === 0) return;
setSaving(true);
setError(null);
setSaved(false);
try {
const patches: VariantPatch[] = dirty.map(({ variant, row }) => {
return {
id: variant.id,
sku: row.sku.trim(),
priceAmount: Number.parseInt(row.price, 10) || 0,
// An empty sale field means "no sale", which is null rather than 0 —
// a sale price of zero would make the product free.
salePriceAmount: row.sale.trim() === '' ? null : Number.parseInt(row.sale, 10),
};
});
const updated = await browserApi.catalogAdmin.updateVariants(product.id, patches);
// Both, and in this order: the draft holds what the inputs display, and
// `dirty` compares it against the product. Resetting only the draft would
// leave every saved row comparing against pre-save values and therefore
// still marked dirty.
setDraft(toDraft(updated.variants));
onChange(updated);
setSaved(true);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
if (product.variants.length === 0) {
return <p className="text-ink-500 text-sm">{t('noVariants')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<p className="text-ink-500 text-xs">{t('variantCount', { count: active.length })}</p>
{archived.length > 0 ? (
<Badge variant="neutral">{t('archivedCount', { count: archived.length })}</Badge>
) : null}
<span className="ml-auto flex items-center gap-3">
{saved ? <span className="text-success text-xs">{t('saved')}</span> : null}
{can(PERMISSIONS.PRODUCT_UPDATE) ? (
<Button size="sm" disabled={saving || dirty.length === 0} onClick={() => void save()}>
{saving ? t('saving') : t('saveChanged', { count: dirty.length })}
</Button>
) : null}
</span>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('grid.variant')}</th>
<th className="px-4 py-3 font-semibold">{t('grid.sku')}</th>
<th className="px-4 py-3 font-semibold">{t('grid.price')}</th>
<th className="px-4 py-3 font-semibold">{t('grid.sale')}</th>
<th className="px-4 py-3 font-semibold">{t('grid.stock')}</th>
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{product.variants.map((variant) => {
const row = draft[variant.id];
const isArchived = variant.status === 'ARCHIVED';
const changed = dirty.some((item) => item.variant.id === variant.id);
return (
<tr
key={variant.id}
className={cn(isArchived && 'opacity-50', changed && 'bg-volt-50')}
>
<td className="px-4 py-2">
<span className="font-medium">{variant.title}</span>
{isArchived ? (
<Badge variant="neutral" className="ml-2">
{t('archived')}
</Badge>
) : null}
</td>
<td className="px-4 py-2">
<Input
value={row?.sku ?? ''}
disabled={isArchived || !can(PERMISSIONS.PRODUCT_UPDATE)}
onChange={(event) => patchRow(variant.id, { sku: event.target.value })}
aria-label={`${t('grid.sku')} ${variant.title}`}
className="h-9 w-44 font-mono text-xs"
/>
</td>
<td className="px-4 py-2">
<Input
type="number"
value={row?.price ?? ''}
disabled={isArchived || !can(PERMISSIONS.PRODUCT_UPDATE)}
onChange={(event) => patchRow(variant.id, { price: event.target.value })}
aria-label={`${t('grid.price')} ${variant.title}`}
className="h-9 w-32"
/>
</td>
<td className="px-4 py-2">
<Input
type="number"
value={row?.sale ?? ''}
placeholder="—"
disabled={isArchived || !can(PERMISSIONS.PRODUCT_UPDATE)}
onChange={(event) => patchRow(variant.id, { sale: event.target.value })}
aria-label={`${t('grid.sale')} ${variant.title}`}
className="h-9 w-32"
/>
</td>
<td className="px-4 py-2">
{/* Read-only by design: stock moves through inventory. */}
<span className={cn(variant.available === 0 && 'text-danger')}>
{variant.available}
</span>
<span className="text-ink-400 ml-1 text-xs">/ {variant.onHand}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-ink-400 text-xs">{t('stockHint')}</p>
</div>
);
}
+122
View File
@@ -104,5 +104,127 @@
"status": "Status", "status": "Status",
"lastLogin": "Last sign-in" "lastLogin": "Last sign-in"
} }
},
"catalog": {
"searchPlaceholder": "Search name or SKU",
"count": "{count} products",
"empty": "No products match.",
"loadFailed": "Could not load products.",
"actionFailed": "That action failed.",
"noPermission": "You do not have permission to view this.",
"publish": "Publish",
"unpublish": "Unpublish",
"status": {
"ALL": "All",
"ACTIVE": "Active",
"DRAFT": "Draft",
"ARCHIVED": "Archived"
},
"table": {
"product": "Product",
"status": "Status",
"variants": "Variants",
"stock": "Stock",
"price": "Price"
},
"newProduct": "New product",
"edit": "Edit"
},
"media": {
"chooseFiles": "Choose files",
"uploadHint": "PNG, JPEG, WebP or AVIF, up to 25 MB. Uploaded straight to storage.",
"uploading": "uploading…",
"uploadFailed": "Upload failed.",
"deleteFailed": "Could not delete.",
"loadFailed": "Could not load the library.",
"empty": "No media yet.",
"delete": "Delete",
"noPermission": "You do not have permission to view this."
},
"inventory": {
"searchPlaceholder": "Search SKU or product",
"lowStockOnly": "Low stock only",
"loadFailed": "Could not load stock levels.",
"adjustFailed": "Adjustment failed.",
"noPermission": "You do not have permission to view this.",
"adjust": "Adjust",
"apply": "Apply",
"cancel": "Cancel",
"outOfStock": "Out of stock",
"deltaLabel": "Quantity change",
"table": {
"sku": "SKU",
"product": "Product",
"onHand": "On hand",
"reserved": "Reserved",
"available": "Available"
}
},
"editor": {
"createTitle": "New product",
"editTitle": "Edit product",
"createHint": "Fill in the content and the option axes. Saving generates one variant per combination, which you then price on the Variants tab.",
"backToList": "Products",
"content": "Content",
"facets": "Merchandising",
"options": "Options",
"generation": "Variant generation",
"localeHint": "A language with no name is skipped — the storefront falls back per field.",
"name": "Name",
"shortDescription": "Short description",
"description": "Description",
"gender": "Gender",
"sport": "Sport",
"optionsHint": "Every combination of these values becomes a purchasable variant with its own SKU, price and stock.",
"addOption": "Add option",
"removeOption": "Remove",
"optionKey": "Key",
"optionName": "Label",
"addValue": "Add value",
"removeValue": "Remove",
"valueKey": "value",
"valueLabel": "Label",
"swatch": "Swatch",
"skuPrefix": "SKU prefix",
"basePrice": "Base price (VND)",
"generationHint": "Applied to every generated variant. Adjust individual prices afterwards.",
"save": "Save changes",
"create": "Create product",
"saving": "Saving…",
"saved": "Saved",
"saveFailed": "Could not save.",
"loadFailed": "Could not load this product.",
"matrixWarning": "Saving re-runs the variant matrix",
"publish": "Publish",
"unpublish": "Unpublish",
"archive": "Archive",
"tab": {
"details": "Details",
"variants": "Variants",
"images": "Images"
},
"noVariants": "No variants yet. Add option values on the Details tab.",
"variantCount": "{count} active",
"archivedCount": "{count} archived",
"archived": "Archived",
"saveChanged": "Save {count} change(s)",
"grid": {
"variant": "Variant",
"sku": "SKU",
"price": "Price",
"sale": "Sale price",
"stock": "Available / on hand"
},
"stockHint": "Stock is read-only here. Adjust it from Inventory so every change lands in the ledger with a reason.",
"attached": "Attached images",
"library": "Library",
"noImages": "No images attached yet.",
"saveImages": "Save images",
"primary": "Primary",
"remove": "Remove",
"colourway": "Colourway",
"anyColourway": "All colourways",
"moveLeft": "Move earlier",
"moveRight": "Move later"
} }
} }
+122
View File
@@ -104,5 +104,127 @@
"status": "Trạng thái", "status": "Trạng thái",
"lastLogin": "Đăng nhập gần nhất" "lastLogin": "Đăng nhập gần nhất"
} }
},
"catalog": {
"searchPlaceholder": "Tìm theo tên hoặc SKU",
"count": "{count} sản phẩm",
"empty": "Không có sản phẩm phù hợp.",
"loadFailed": "Không tải được danh sách sản phẩm.",
"actionFailed": "Thao tác thất bại.",
"noPermission": "Bạn không có quyền xem mục này.",
"publish": "Đăng bán",
"unpublish": "Ngừng bán",
"status": {
"ALL": "Tất cả",
"ACTIVE": "Đang bán",
"DRAFT": "Nháp",
"ARCHIVED": "Lưu trữ"
},
"table": {
"product": "Sản phẩm",
"status": "Trạng thái",
"variants": "Phiên bản",
"stock": "Tồn kho",
"price": "Giá"
},
"newProduct": "Sản phẩm mới",
"edit": "Sửa"
},
"media": {
"chooseFiles": "Chọn tệp",
"uploadHint": "PNG, JPEG, WebP hoặc AVIF, tối đa 25 MB. Tải thẳng lên kho lưu trữ.",
"uploading": "đang tải lên…",
"uploadFailed": "Tải lên thất bại.",
"deleteFailed": "Không xoá được.",
"loadFailed": "Không tải được thư viện.",
"empty": "Chưa có media.",
"delete": "Xoá",
"noPermission": "Bạn không có quyền xem mục này."
},
"inventory": {
"searchPlaceholder": "Tìm SKU hoặc sản phẩm",
"lowStockOnly": "Chỉ hàng sắp hết",
"loadFailed": "Không tải được tồn kho.",
"adjustFailed": "Điều chỉnh thất bại.",
"noPermission": "Bạn không có quyền xem mục này.",
"adjust": "Điều chỉnh",
"apply": "Áp dụng",
"cancel": "Huỷ",
"outOfStock": "Hết hàng",
"deltaLabel": "Thay đổi số lượng",
"table": {
"sku": "SKU",
"product": "Sản phẩm",
"onHand": "Tồn kho",
"reserved": "Đang giữ",
"available": "Có sẵn"
}
},
"editor": {
"createTitle": "Sản phẩm mới",
"editTitle": "Sửa sản phẩm",
"createHint": "Điền nội dung và các trục tuỳ chọn. Khi lưu, hệ thống sinh một phiên bản cho mỗi tổ hợp để bạn đặt giá ở tab Phiên bản.",
"backToList": "Sản phẩm",
"content": "Nội dung",
"facets": "Phân loại",
"options": "Tuỳ chọn",
"generation": "Sinh phiên bản",
"localeHint": "Ngôn ngữ chưa có tên sẽ được bỏ qua — storefront tự dùng nội dung thay thế theo từng trường.",
"name": "Tên",
"shortDescription": "Mô tả ngắn",
"description": "Mô tả",
"gender": "Giới tính",
"sport": "Môn thể thao",
"optionsHint": "Mỗi tổ hợp giá trị sẽ thành một phiên bản bán được, có SKU, giá và tồn kho riêng.",
"addOption": "Thêm tuỳ chọn",
"removeOption": "Xoá",
"optionKey": "Mã",
"optionName": "Nhãn",
"addValue": "Thêm giá trị",
"removeValue": "Xoá",
"valueKey": "giá trị",
"valueLabel": "Nhãn",
"swatch": "Màu",
"skuPrefix": "Tiền tố SKU",
"basePrice": "Giá cơ bản (VND)",
"generationHint": "Áp dụng cho mọi phiên bản được sinh ra. Bạn có thể chỉnh từng giá sau.",
"save": "Lưu thay đổi",
"create": "Tạo sản phẩm",
"saving": "Đang lưu…",
"saved": "Đã lưu",
"saveFailed": "Không lưu được.",
"loadFailed": "Không tải được sản phẩm này.",
"matrixWarning": "Lưu sẽ sinh lại ma trận phiên bản",
"publish": "Đăng bán",
"unpublish": "Ngừng bán",
"archive": "Lưu trữ",
"tab": {
"details": "Chi tiết",
"variants": "Phiên bản",
"images": "Hình ảnh"
},
"noVariants": "Chưa có phiên bản. Thêm giá trị tuỳ chọn ở tab Chi tiết.",
"variantCount": "{count} đang bán",
"archivedCount": "{count} đã lưu trữ",
"archived": "Lưu trữ",
"saveChanged": "Lưu {count} thay đổi",
"grid": {
"variant": "Phiên bản",
"sku": "SKU",
"price": "Giá",
"sale": "Giá giảm",
"stock": "Có sẵn / tồn kho"
},
"stockHint": "Tồn kho chỉ để xem ở đây. Điều chỉnh ở mục Kho hàng để mọi thay đổi được ghi vào sổ nhật ký kèm lý do.",
"attached": "Hình đã gắn",
"library": "Thư viện",
"noImages": "Chưa gắn hình nào.",
"saveImages": "Lưu hình ảnh",
"primary": "Chính",
"remove": "Xoá",
"colourway": "Màu",
"anyColourway": "Mọi màu",
"moveLeft": "Chuyển lên trước",
"moveRight": "Chuyển xuống sau"
} }
} }
+4
View File
@@ -1,5 +1,9 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@sport/config/tailwind/theme.css'; @import '@sport/config/tailwind/theme.css';
/* Supplies the enter/exit utilities Radix-driven overlays animate with
(`animate-in`, `fade-in-0`, `slide-in-from-right`). Registry components
assume these exist; without it Dialog and Sheet appear instantly. */
@import 'tw-animate-css';
@source "../../../../packages/ui/src"; @source "../../../../packages/ui/src";
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AuditModule } from './common/audit/audit.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor'; import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { MediaUrlModule } from './common/media/media.module'; import { MediaUrlModule } from './common/media/media.module';
@@ -57,6 +58,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
EventsModule, EventsModule,
MediaUrlModule, MediaUrlModule,
SecurityModule, SecurityModule,
AuditModule,
ThrottlerModule.forRootAsync({ ThrottlerModule.forRootAsync({
inject: [APP_CONFIG], inject: [APP_CONFIG],
+11
View File
@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
/** Global: every write path in the admin records what it did. */
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
export interface AuditEntry {
actorUserId: string | null;
action: string;
resourceType: string;
resourceId: string | null;
changes?: Prisma.InputJsonValue;
ipAddress?: string | null;
}
/**
* Append-only record of who changed what.
*
* Two deliberate properties:
*
* 1. **Writing an audit row never fails the operation.** If the log write
* throws, the product edit that succeeded must still stand — losing an audit
* row is bad, silently rolling back an operator's work because of it is
* worse. Failures are logged loudly instead.
*
* 2. **It is fire-and-forget from the caller's perspective.** Audit writes must
* not add latency to the write path.
*
* If audit completeness ever becomes a compliance requirement, this becomes an
* outbox row inside the same transaction as the change. That is a deliberate
* future step, not an oversight.
*/
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(private readonly prisma: PrismaService) {}
record(entry: AuditEntry): void {
void this.write(entry);
}
private async write(entry: AuditEntry): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
actorUserId: entry.actorUserId,
action: entry.action,
resourceType: entry.resourceType,
resourceId: entry.resourceId,
changes: entry.changes,
ipAddress: entry.ipAddress ?? null,
},
});
} catch (error) {
this.logger.error(
`Failed to write audit entry ${entry.action} on ${entry.resourceType}: ${String(error)}`,
);
}
}
}
@@ -98,6 +98,43 @@ export class RedisService implements OnModuleDestroy {
return typeof count === 'number' ? count : 0; return typeof count === 'number' ? count : 0;
} }
/**
* Drops every key under a prefix.
*
* SCAN, never KEYS: KEYS blocks the entire Redis server for the duration of
* the scan, and this runs on the admin write path where a catalog import can
* fire it hundreds of times.
*
* Note the prefix handling — the client is configured with `keyPrefix`, so
* SCAN returns fully-prefixed keys while `del` would prefix them again. The
* prefix is stripped before deleting.
*/
async deleteByPrefix(prefix: string): Promise<number> {
const keyPrefix = this.client.options.keyPrefix ?? '';
let cursor = '0';
let removed = 0;
do {
const [next, keys] = await this.client.scan(
cursor,
'MATCH',
`${keyPrefix}${prefix}*`,
'COUNT',
500,
);
cursor = next;
if (keys.length > 0) {
const unprefixed = keys.map((key) =>
keyPrefix && key.startsWith(keyPrefix) ? key.slice(keyPrefix.length) : key,
);
removed += await this.client.del(...unprefixed);
}
} while (cursor !== '0');
return removed;
}
async ping(): Promise<void> { async ping(): Promise<void> {
await this.client.ping(); await this.client.ping();
} }
@@ -0,0 +1,131 @@
import type { InventoryListQuery } from '@sport/validation';
import type { AuditService } from '@/common/audit/audit.service';
import type { PrismaService } from '@/infrastructure/prisma/prisma.service';
import type { RedisService } from '@/infrastructure/redis/redis.service';
import { InventoryService } from './inventory.service';
/**
* Pins the one thing that made stock unreachable in the admin: the list is
* driven by `ProductVariant`, not by `StockLevel`.
*
* A variant that has never moved has no level row — the level is a projection
* of the ledger. Listing the projection hid every freshly created variant, and
* since this screen is the only route to `adjust`, those variants could never
* be given stock at all. That is invisible from the API contract, so it is
* asserted here rather than left to inspection.
*/
const location = { id: 'loc-main', name: 'Main Warehouse' };
interface Stub {
service: InventoryService;
variantArgs: () => Record<string, unknown>;
stockLevelTouched: () => boolean;
}
function stubService(rows: unknown[]): Stub {
let variantArgs: Record<string, unknown> = {};
let stockLevelTouched = false;
const prisma = {
inventoryLocation: { findFirst: async () => location },
productVariant: {
findMany: async (args: Record<string, unknown>) => {
variantArgs = args;
return rows;
},
count: async () => rows.length,
},
stockLevel: {
findMany: async () => {
stockLevelTouched = true;
return [];
},
count: async () => {
stockLevelTouched = true;
return 0;
},
},
} as unknown as PrismaService;
return {
service: new InventoryService(prisma, {} as RedisService, {} as AuditService),
variantArgs: () => variantArgs,
stockLevelTouched: () => stockLevelTouched,
};
}
const query = (overrides: Partial<InventoryListQuery> = {}): InventoryListQuery =>
({ page: 1, perPage: 24, lowStockOnly: false, ...overrides }) as InventoryListQuery;
describe('InventoryService.list', () => {
it('reports a variant with no level row as zero rather than omitting it', async () => {
const updatedAt = new Date('2026-08-12T03:00:00.000Z');
const stub = stubService([
{
id: 'v-1',
sku: 'VEL-NOC-BLACK-M',
title: 'Black / M',
updatedAt,
product: { name: 'Nocturne Jacket' },
stockLevels: [],
},
]);
const result = await stub.service.list(query());
expect(stub.stockLevelTouched()).toBe(false);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
variantId: 'v-1',
sku: 'VEL-NOC-BLACK-M',
locationId: location.id,
locationName: location.name,
onHand: 0,
reserved: 0,
available: 0,
// Nothing has moved, so the variant's own timestamp is the most recent
// thing that is true about its stock.
updatedAt: updatedAt.toISOString(),
});
});
it('derives available from the level when one exists', async () => {
const stub = stubService([
{
id: 'v-2',
sku: 'VEL-NOC-VOLT-L',
title: 'Volt / L',
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
product: { name: 'Nocturne Jacket' },
stockLevels: [{ onHand: 12, reserved: 5, updatedAt: new Date('2026-08-12T00:00:00.000Z') }],
},
]);
const result = await stub.service.list(query());
expect(result.items[0]).toMatchObject({ onHand: 12, reserved: 5, available: 7 });
});
it('counts a missing level row as low stock', async () => {
const stub = stubService([]);
await stub.service.list(query({ lowStockOnly: true }));
// A variant sitting at an implicit zero is the most urgent kind of low, not
// an absent one — so the filter must reach rows with no level at all.
expect(JSON.stringify(stub.variantArgs().where)).toContain('"none"');
});
it('keeps search and low-stock filters independent', async () => {
const stub = stubService([]);
await stub.service.list(query({ q: 'VEL-NOC', lowStockOnly: true }));
// Both are `OR` groups; combining them at the same level would let a
// low-stock match escape the search term. They must be `AND`ed.
const where = stub.variantArgs().where as { AND?: unknown[] };
expect(where.AND).toHaveLength(2);
});
});
@@ -0,0 +1,60 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AuthenticatedActor,
type InventoryLevel,
type OffsetPaginated,
type StockMovementEntry,
} from '@sport/types';
import {
adjustStockSchema,
inventoryListQuerySchema,
type AdjustStockInput,
type InventoryListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { InventoryService } from './inventory.service';
@ApiTags('admin/inventory')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/inventory')
export class InventoryController {
constructor(private readonly service: InventoryService) {}
@Get()
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
@ApiOperation({ summary: 'Stock levels by variant and location' })
list(
@Query(new ZodValidationPipe(inventoryListQuerySchema)) query: InventoryListQuery,
): Promise<OffsetPaginated<InventoryLevel>> {
return this.service.list(query);
}
@Get('movements/:variantId')
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
@ApiOperation({ summary: 'Movement ledger for one variant' })
movements(@Param('variantId') variantId: string): Promise<StockMovementEntry[]> {
return this.service.movements(variantId);
}
@Post('adjust')
@RequirePermissions(PERMISSIONS.INVENTORY_UPDATE)
@ApiOperation({ summary: 'Adjust stock; always writes a ledger entry' })
adjust(
@Body(new ZodValidationPipe(adjustStockSchema)) body: AdjustStockInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<InventoryLevel> {
return this.service.adjust(body, actor.userId);
}
}
@@ -1,23 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
/** /**
* InventoryModule — boundary declared, implementation pending. * InventoryModule — owns `inventory_locations`, `stock_levels` and
* `stock_movements`.
* *
* Owns (exclusively): `inventory_locations`, `stock_levels`, `stock_movements` * The stock level is a projection of the append-only movement ledger, which is
* * what makes a discrepancy answerable. EXTRACTION CANDIDATE: it communicates
* Stock ledger, reservations and release. Consumes order events rather than being called by OrdersModule. * outward through events and reads no other module's tables.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* inventory.module.ts wiring only
* inventory.controller.ts HTTP surface, no logic
* inventory.service.ts business rules
* inventory.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
controllers: [InventoryController],
providers: [InventoryService],
exports: [InventoryService],
})
export class InventoryModule {} export class InventoryModule {}
@@ -0,0 +1,316 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { InventoryLevel, OffsetPaginated, StockMovementEntry } from '@sport/types';
import type { AdjustStockInput, InventoryListQuery } from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
/** Below this, the admin flags a variant as needing attention. */
const LOW_STOCK_THRESHOLD = 5;
@Injectable()
export class InventoryService {
private readonly logger = new Logger(InventoryService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly audit: AuditService,
) {}
/**
* Lists sellable variants with what the ledger says about them.
*
* Driven by `ProductVariant`, not `StockLevel`, because the level is a
* projection and a variant that has never moved has no row yet. Listing the
* projection made freshly created variants invisible here — and since this
* screen is the only way to reach `adjust`, they could never be given stock
* at all. A missing projection means zero, which is exactly what it means
* everywhere else.
*/
async list(query: InventoryListQuery): Promise<OffsetPaginated<InventoryLevel>> {
const location = await this.defaultLocation();
const filters: Prisma.ProductVariantWhereInput[] = [];
if (query.q) {
filters.push({
OR: [
{ sku: { contains: query.q, mode: 'insensitive' } },
{ product: { name: { contains: query.q, mode: 'insensitive' } } },
],
});
}
if (query.lowStockOnly) {
// "Low" includes "has no level row at all" — a variant sitting at an
// implicit zero is the most urgent kind of low, not an absent one.
filters.push({
OR: [
{ stockLevels: { none: { locationId: location.id } } },
{
stockLevels: {
some: { locationId: location.id, onHand: { lte: LOW_STOCK_THRESHOLD } },
},
},
],
});
}
const where: Prisma.ProductVariantWhereInput = {
status: 'ACTIVE',
deletedAt: null,
...(filters.length > 0 ? { AND: filters } : {}),
};
const [rows, totalItems] = await Promise.all([
this.prisma.productVariant.findMany({
where,
// By SKU rather than by quantity: ordering on a relation would cost a
// join-and-sort on every page, and `lowStockOnly` is the tool for
// finding the shortages. Stable order matters more while paging.
orderBy: { sku: 'asc' },
skip: (query.page - 1) * query.perPage,
take: query.perPage,
select: {
id: true,
sku: true,
title: true,
updatedAt: true,
product: { select: { name: true } },
stockLevels: {
where: { locationId: location.id },
select: { onHand: true, reserved: true, updatedAt: true },
take: 1,
},
},
}),
this.prisma.productVariant.count({ where }),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => {
const level = row.stockLevels[0];
return {
variantId: row.id,
sku: row.sku,
variantTitle: row.title,
productName: row.product.name,
locationId: location.id,
locationName: location.name,
onHand: level?.onHand ?? 0,
reserved: level?.reserved ?? 0,
available: (level?.onHand ?? 0) - (level?.reserved ?? 0),
// Without a level row nothing has moved, so the variant's own
// timestamp is the most recent thing that is true about its stock.
updatedAt: (level?.updatedAt ?? row.updatedAt).toISOString(),
};
}),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
/**
* Applies a stock change and records it in the ledger, atomically.
*
* The ledger entry and the level update are in one transaction because the
* level is a *projection* of the ledger (ADR: inventory is append-only). If
* they could diverge, "why is this number wrong?" becomes unanswerable — and
* that question is the entire reason the ledger exists.
*
* Never lets stock go negative: a correction that would imply selling units
* that were never received is a data-entry error, and silently clamping it
* would hide the mistake.
*/
async adjust(input: AdjustStockInput, actorUserId: string): Promise<InventoryLevel> {
const variant = await this.prisma.productVariant.findFirst({
where: { id: input.variantId, deletedAt: null },
select: {
id: true,
sku: true,
title: true,
productId: true,
product: { select: { name: true } },
},
});
if (!variant) throw AppException.notFound('Variant');
const locationId = input.locationId ?? (await this.defaultLocationId());
const level = await this.prisma.$transaction(async (tx) => {
const current = await tx.stockLevel.findUnique({
where: { variantId_locationId: { variantId: variant.id, locationId } },
select: { onHand: true, reserved: true },
});
const onHand = current?.onHand ?? 0;
// A stock take states the counted total; everything else is a delta.
const delta =
input.reason === 'STOCK_TAKE'
? (input.countedQuantity ?? 0) - onHand
: (input.quantityDelta ?? 0);
const nextOnHand = onHand + delta;
if (nextOnHand < 0) {
throw AppException.badRequest(
`That adjustment would leave ${variant.sku} at ${nextOnHand}. Stock cannot go negative.`,
);
}
const updated = await tx.stockLevel.upsert({
where: { variantId_locationId: { variantId: variant.id, locationId } },
update: { onHand: nextOnHand },
create: { variantId: variant.id, locationId, onHand: nextOnHand, reserved: 0 },
select: {
onHand: true,
reserved: true,
updatedAt: true,
location: { select: { name: true } },
},
});
// Append-only: a stock take that changes nothing still records that a
// count happened, which is exactly what an auditor looks for.
await tx.stockMovement.create({
data: {
variantId: variant.id,
locationId,
quantityDelta: delta,
reason: input.reason,
note: input.note ?? null,
createdByUserId: actorUserId,
},
});
return updated;
});
// `inStock` on the product read model depends on this.
await this.recomputeProductStock(variant.productId);
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
this.logger.log(`Stock adjusted for ${variant.sku}; dropped ${dropped} catalog cache key(s)`);
this.audit.record({
actorUserId,
action: 'inventory.adjust',
resourceType: 'ProductVariant',
resourceId: variant.id,
changes: { sku: variant.sku, reason: input.reason, onHand: level.onHand },
});
return {
variantId: variant.id,
sku: variant.sku,
variantTitle: variant.title,
productName: variant.product.name,
locationId,
locationName: level.location.name,
onHand: level.onHand,
reserved: level.reserved,
available: level.onHand - level.reserved,
updatedAt: level.updatedAt.toISOString(),
};
}
async movements(variantId: string, limit = 50): Promise<StockMovementEntry[]> {
const rows = await this.prisma.stockMovement.findMany({
where: { variantId },
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
variantId: true,
quantityDelta: true,
reason: true,
note: true,
createdAt: true,
variant: { select: { sku: true } },
createdByUserId: true,
},
});
const actorIds = [
...new Set(rows.map((row) => row.createdByUserId).filter(Boolean)),
] as string[];
const actors = await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, firstName: true, lastName: true, email: true },
});
const actorById = new Map(actors.map((actor) => [actor.id, actor]));
return rows.map((row) => {
const actor = row.createdByUserId ? actorById.get(row.createdByUserId) : undefined;
const name = actor
? [actor.firstName, actor.lastName].filter(Boolean).join(' ') || actor.email
: null;
return {
id: row.id,
variantId: row.variantId,
sku: row.variant.sku,
quantityDelta: row.quantityDelta,
reason: row.reason,
note: row.note,
createdByName: name,
createdAt: row.createdAt.toISOString(),
};
});
}
/** Creates the default location on first use rather than failing the seed. */
private async defaultLocation(): Promise<{ id: string; name: string }> {
const location = await this.prisma.inventoryLocation.findFirst({
where: { isActive: true },
orderBy: { isDefault: 'desc' },
select: { id: true, name: true },
});
return (
location ??
(await this.prisma.inventoryLocation.create({
data: { code: 'MAIN', name: 'Main Warehouse', isDefault: true },
select: { id: true, name: true },
}))
);
}
private async defaultLocationId(): Promise<string> {
return (await this.defaultLocation()).id;
}
/**
* Keeps `Product.inStock` in step (ADR-0014).
*
* Cheaper than `recomputePricing`: stock changes far more often than price,
* and touching only the one boolean avoids re-reading every variant's pricing
* on each warehouse adjustment.
*/
private async recomputeProductStock(productId: string): Promise<void> {
const variants = await this.prisma.productVariant.findMany({
where: { productId, status: 'ACTIVE', deletedAt: null },
select: { stockLevels: { select: { onHand: true, reserved: true } } },
});
const inStock = variants.some((variant) =>
variant.stockLevels.some((level) => level.onHand - level.reserved > 0),
);
await this.prisma.product.update({ where: { id: productId }, data: { inStock } });
}
}
@@ -1,10 +1,7 @@
/** /**
* Public surface of InventoryModule. * Public surface of InventoryModule.
* *
* This barrel is the ONLY thing other modules may import from here. Everything * CheckoutModule will use this to reserve and release stock; nothing else
* else — repository, DTOs, internal services — is private, and the ESLint * outside this module writes to the ledger.
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { InventoryService } from '../inventory.service';
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AuthenticatedActor,
type MediaAssetSummary,
type OffsetPaginated,
type PresignedUploadTarget,
} from '@sport/types';
import {
mediaListQuerySchema,
presignUploadSchema,
registerMediaSchema,
type MediaListQuery,
type PresignUploadInput,
type RegisterMediaInput,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { MediaService } from './media.service';
@ApiTags('admin/media')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/media')
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
@Get()
@RequirePermissions(PERMISSIONS.MEDIA_READ)
@ApiOperation({ summary: 'List media assets' })
list(
@Query(new ZodValidationPipe(mediaListQuerySchema)) query: MediaListQuery,
): Promise<OffsetPaginated<MediaAssetSummary>> {
return this.mediaService.list(query);
}
/**
* Step 1 of an upload: get a short-lived URL to PUT the file to.
* The browser then uploads directly to object storage.
*/
@Post('presign')
@HttpCode(HttpStatus.OK)
@RequirePermissions(PERMISSIONS.MEDIA_UPLOAD)
@ApiOperation({ summary: 'Get a presigned upload target' })
presign(
@Body(new ZodValidationPipe(presignUploadSchema)) body: PresignUploadInput,
): Promise<PresignedUploadTarget> {
return this.mediaService.presign(body);
}
/** Step 2: record the asset once the bytes are in storage. */
@Post()
@RequirePermissions(PERMISSIONS.MEDIA_UPLOAD)
@ApiOperation({ summary: 'Register an uploaded asset' })
register(
@Body(new ZodValidationPipe(registerMediaSchema)) body: RegisterMediaInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<MediaAssetSummary> {
return this.mediaService.register(body, actor.userId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermissions(PERMISSIONS.MEDIA_DELETE)
@ApiOperation({ summary: 'Delete an unused media asset' })
delete(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.mediaService.delete(id, actor.userId);
}
}
+11 -13
View File
@@ -1,19 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
/** /**
* MediaModule — boundary declared, implementation pending. * MediaModule — owns `media_assets`.
* *
* Owns (exclusively): `media_assets` * Issues presigned upload URLs and records metadata. Bytes never pass through
* * the API and never enter PostgreSQL (ADR-0009).
* Issues presigned upload URLs and records metadata. Bytes never pass through the API and never enter PostgreSQL.
*
* Anatomy once implemented (see ../README.md):
* media.module.ts wiring only
* media.controller.ts HTTP surface, no logic
* media.service.ts business rules
* media.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
controllers: [MediaController],
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {} export class MediaModule {}
+220
View File
@@ -0,0 +1,220 @@
import { Injectable } from '@nestjs/common';
import {
API_ERROR_CODES,
type MediaAssetSummary,
type MediaKind,
type OffsetPaginated,
type PresignedUploadTarget,
} from '@sport/types';
import type { MediaListQuery, PresignUploadInput, RegisterMediaInput } from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { MediaUrlService } from '@/common/media/media-url.service';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { StorageService } from '@/infrastructure/storage/storage.service';
/** Enforced again at registration; the presign check alone is bypassable. */
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/avif',
'video/mp4',
'video/webm',
'application/pdf',
]);
const MAX_SIZE_BYTES = 25 * 1024 * 1024;
@Injectable()
export class MediaService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly mediaUrl: MediaUrlService,
private readonly audit: AuditService,
) {}
/**
* Issues a short-lived URL the browser PUTs directly to.
*
* Bytes never pass through the API (ADR-0009): no memory pressure, no request
* timeout on a 20 MB file, and no need to scale the API for bandwidth.
*/
async presign(input: PresignUploadInput): Promise<PresignedUploadTarget> {
this.assertAcceptable(input.mimeType, input.sizeBytes);
return this.storage.presignUpload({
prefix: input.prefix,
filename: input.filename,
mimeType: input.mimeType,
maxSizeBytes: input.sizeBytes,
});
}
/**
* Records an asset after the browser has uploaded it.
*
* The MIME type and size are re-validated here rather than trusted from the
* presign step: a client can call this endpoint directly with any values, so
* treating the earlier check as sufficient would make the allow-list
* decorative.
*
* Idempotent by storage key — a retried upload updates rather than duplicates.
*/
async register(input: RegisterMediaInput, actorUserId: string): Promise<MediaAssetSummary> {
this.assertAcceptable(input.mimeType, input.sizeBytes);
const asset = await this.prisma.mediaAsset.upsert({
where: { storageKey: input.storageKey },
update: {
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
width: input.width ?? null,
height: input.height ?? null,
altText: input.altText ?? null,
blurDataUrl: input.blurDataUrl ?? null,
},
create: {
kind: kindFor(input.mimeType),
storageKey: input.storageKey,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
width: input.width ?? null,
height: input.height ?? null,
altText: input.altText ?? null,
blurDataUrl: input.blurDataUrl ?? null,
uploadedByUserId: actorUserId,
},
});
this.audit.record({
actorUserId,
action: 'media.upload',
resourceType: 'MediaAsset',
resourceId: asset.id,
changes: { storageKey: asset.storageKey, sizeBytes: asset.sizeBytes },
});
return this.toSummary(asset);
}
async list(query: MediaListQuery): Promise<OffsetPaginated<MediaAssetSummary>> {
const where = query.q
? {
OR: [
{ altText: { contains: query.q, mode: 'insensitive' as const } },
{ storageKey: { contains: query.q } },
],
}
: {};
const [items, totalItems] = await Promise.all([
this.prisma.mediaAsset.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.perPage,
take: query.perPage,
}),
this.prisma.mediaAsset.count({ where }),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: items.map((item) => this.toSummary(item)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
/**
* Deletes the row, then the object.
*
* That order matters: if the object delete fails we are left with an orphaned
* file, which a reconciliation job can clean up. The reverse order can leave a
* row pointing at bytes that no longer exist, which renders as a broken image
* on a live product page.
*/
async delete(id: string, actorUserId: string): Promise<void> {
const asset = await this.prisma.mediaAsset.findUnique({
where: { id },
select: { id: true, storageKey: true, _count: { select: { productImages: true } } },
});
if (!asset) throw AppException.notFound('Media asset');
if (asset._count.productImages > 0) {
throw AppException.conflict(
`This image is used by ${asset._count.productImages} product(s). Remove it from them first.`,
);
}
await this.prisma.mediaAsset.delete({ where: { id } });
await this.storage.delete(asset.storageKey).catch(() => undefined);
this.audit.record({
actorUserId,
action: 'media.delete',
resourceType: 'MediaAsset',
resourceId: id,
changes: { storageKey: asset.storageKey },
});
}
private assertAcceptable(mimeType: string, sizeBytes: number): void {
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
throw new AppException({
code: API_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE,
message: `Files of type ${mimeType} are not accepted.`,
status: 415,
});
}
if (sizeBytes > MAX_SIZE_BYTES) {
throw new AppException({
code: API_ERROR_CODES.FILE_TOO_LARGE,
message: `Files must be ${Math.floor(MAX_SIZE_BYTES / 1024 / 1024)} MB or smaller.`,
status: 413,
});
}
}
private toSummary(asset: {
id: string;
kind: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
altText: string | null;
createdAt: Date;
}): MediaAssetSummary {
return {
id: asset.id,
kind: asset.kind as MediaKind,
url: this.mediaUrl.url(asset.storageKey),
storageKey: asset.storageKey,
mimeType: asset.mimeType,
sizeBytes: asset.sizeBytes,
width: asset.width,
height: asset.height,
altText: asset.altText,
createdAt: asset.createdAt.toISOString(),
};
}
}
function kindFor(mimeType: string): 'IMAGE' | 'VIDEO' | 'DOCUMENT' {
if (mimeType.startsWith('image/')) return 'IMAGE';
if (mimeType.startsWith('video/')) return 'VIDEO';
return 'DOCUMENT';
}
+3 -6
View File
@@ -1,10 +1,7 @@
/** /**
* Public surface of MediaModule. * Public surface of MediaModule.
* *
* This barrel is the ONLY thing other modules may import from here. Everything * `ProductsModule` needs to validate that a media id exists before attaching it
* else — repository, DTOs, internal services — is private, and the ESLint * to a product; nothing else outside this module touches media.
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { MediaService } from '../media.service';
@@ -0,0 +1,120 @@
import { Body, Controller, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminProductDetail,
type AdminProductListItem,
type AuthenticatedActor,
type OffsetPaginated,
} from '@sport/types';
import {
adminProductListQuerySchema,
bulkUpdateVariantsSchema,
createProductSchema,
productImagesSchema,
updateProductSchema,
type AdminProductListQuery,
type BulkUpdateVariantsInput,
type CreateProductInput,
type ProductImagesInput,
type UpdateProductInput,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { ProductsAdminService } from './products-admin.service';
const setStatusSchema = z.object({ status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']) });
/**
* The catalog write surface.
*
* Note the permission split: `product.update` covers everyday editing, while
* `product.publish` is separate — making something visible to customers is a
* different level of trust from correcting a description.
*/
@ApiTags('admin/products')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/products')
export class ProductsAdminController {
constructor(private readonly service: ProductsAdminService) {}
@Get()
@RequirePermissions(PERMISSIONS.PRODUCT_READ)
@ApiOperation({ summary: 'List products for the admin table' })
list(
@Query(new ZodValidationPipe(adminProductListQuerySchema)) query: AdminProductListQuery,
): Promise<OffsetPaginated<AdminProductListItem>> {
return this.service.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.PRODUCT_READ)
@ApiOperation({ summary: 'Full product for the editor, in every locale' })
getById(@Param('id') id: string): Promise<AdminProductDetail> {
return this.service.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.PRODUCT_CREATE)
@ApiOperation({ summary: 'Create a product and generate its variant matrix' })
create(
@Body(new ZodValidationPipe(createProductSchema)) body: CreateProductInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminProductDetail> {
return this.service.create(body, actor.userId);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
@ApiOperation({ summary: 'Update a product; re-syncs the matrix if options changed' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateProductSchema)) body: UpdateProductInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminProductDetail> {
return this.service.update(id, body, actor.userId);
}
@Put(':id/variants')
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
@ApiOperation({ summary: 'Bulk-edit variant SKUs and prices (not stock)' })
updateVariants(
@Param('id') id: string,
@Body(new ZodValidationPipe(bulkUpdateVariantsSchema)) body: BulkUpdateVariantsInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminProductDetail> {
return this.service.updateVariants(id, body, actor.userId);
}
@Put(':id/images')
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
@ApiOperation({ summary: 'Replace the image set and their ordering' })
setImages(
@Param('id') id: string,
@Body(new ZodValidationPipe(productImagesSchema)) body: ProductImagesInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminProductDetail> {
return this.service.setImages(id, body, actor.userId);
}
@Post(':id/status')
@RequirePermissions(PERMISSIONS.PRODUCT_PUBLISH)
@ApiOperation({ summary: 'Publish, unpublish or archive' })
setStatus(
@Param('id') id: string,
@Body(new ZodValidationPipe(setStatusSchema)) body: { status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED' },
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminProductDetail> {
return this.service.setStatus(id, body.status, actor.userId);
}
}
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import type {
AdminProductDetail,
AdminProductListItem,
CurrencyCode,
GenderTarget,
Locale,
ProductStatus,
SportType,
TranslationMap,
VariantStatus,
} from '@sport/types';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { AdminProductRow } from './products.repository';
/** Prisma's `Locale` enum → the wire value. */
function toLocale(dbLocale: string): Locale {
return dbLocale === 'VI' ? 'vi' : 'en';
}
/** Collapses `[{locale, ...fields}]` into `{ vi: fields, en: fields }`. */
function byLocale<TRow extends { locale: string }, TOut>(
rows: readonly TRow[],
pick: (row: TRow) => TOut,
): TranslationMap<TOut> {
return Object.fromEntries(rows.map((row) => [toLocale(row.locale), pick(row)]));
}
@Injectable()
export class ProductsAdminMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
toListItem(row: {
id: string;
name: string;
slug: string;
status: string;
isOnSale: boolean;
publishedAt: Date | null;
updatedAt: Date;
translations: { locale: string; name: string }[];
brand: { name: string } | null;
images: { media: { storageKey: string } }[];
variants: {
currency: string;
priceAmount: number;
salePriceAmount: number | null;
stockLevels: { onHand: number; reserved: number }[];
}[];
}): AdminProductListItem {
const currency = (row.variants[0]?.currency ?? 'VND') as CurrencyCode;
const effective = row.variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount);
// Available, not on-hand: an operator planning a restock cares about what
// can actually be sold, and reserved units cannot be.
const totalStock = row.variants.reduce(
(total, variant) =>
total +
variant.stockLevels.reduce((sum, level) => sum + (level.onHand - level.reserved), 0),
0,
);
const thumbnail = row.images[0]?.media.storageKey;
return {
id: row.id,
// The admin table always shows the default-locale name so rows stay
// comparable; the editor is where other languages are visible.
name: row.translations[0]?.name ?? row.name,
slug: row.slug,
status: row.status as ProductStatus,
brandName: row.brand?.name ?? null,
thumbnailUrl: thumbnail ? this.mediaUrl.url(thumbnail) : null,
variantCount: row.variants.length,
totalStock,
priceRange:
effective.length > 0
? {
min: { amount: Math.min(...effective), currency },
max: { amount: Math.max(...effective), currency },
}
: null,
isOnSale: row.isOnSale,
publishedAt: row.publishedAt?.toISOString() ?? null,
updatedAt: row.updatedAt.toISOString(),
};
}
toDetail(row: AdminProductRow): AdminProductDetail {
return {
id: row.id,
status: row.status as ProductStatus,
publishedAt: row.publishedAt?.toISOString() ?? null,
brandId: row.brandId,
primaryCategoryId: row.primaryCategoryId,
genderTargets: row.genderTargets as GenderTarget[],
sportTypes: row.sportTypes as SportType[],
collectionIds: row.collections.map((link) => link.collectionId),
translations: byLocale(row.translations, (translation) => ({
name: translation.name,
slug: translation.slug,
shortDescription: translation.shortDescription,
description: translation.description,
metaTitle: translation.metaTitle,
metaDescription: translation.metaDescription,
})),
options: row.options.map((option) => ({
id: option.id,
key: option.key,
position: option.position,
names: byLocale(option.translations, (translation) => translation.name),
values: option.values.map((value) => ({
id: value.id,
value: value.value,
position: value.position,
swatchHex: value.swatchHex,
labels: byLocale(value.translations, (translation) => translation.label),
})),
})),
variants: row.variants.map((variant) => {
const onHand = variant.stockLevels.reduce((sum, level) => sum + level.onHand, 0);
const reserved = variant.stockLevels.reduce((sum, level) => sum + level.reserved, 0);
return {
id: variant.id,
sku: variant.sku,
barcode: variant.barcode,
title: variant.title,
optionValueIds: variant.optionValues.map((link) => link.optionValueId),
priceAmount: variant.priceAmount,
salePriceAmount: variant.salePriceAmount,
compareAtAmount: variant.compareAtAmount,
costAmount: variant.costAmount,
weightGrams: variant.weightGrams,
status: variant.status as VariantStatus,
position: variant.position,
onHand,
reserved,
// Always derived, never stored — the two numbers cannot disagree.
available: onHand - reserved,
};
}),
images: row.images.map((image) => ({
id: image.id,
mediaId: image.mediaId,
url: this.mediaUrl.url(image.media.storageKey),
altText: image.media.altText,
position: image.position,
optionValueId: image.optionValueId,
})),
attributes: row.attributes.map((attribute) => ({
id: attribute.id,
key: attribute.key,
position: attribute.position,
translations: byLocale(attribute.translations, (translation) => ({
label: translation.label,
value: translation.value,
})),
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,734 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
DEFAULT_LOCALE,
LOCALES,
type AdminProductDetail,
type AdminProductListItem,
type OffsetPaginated,
} from '@sport/types';
import type {
AdminProductListQuery,
BulkUpdateVariantsInput,
CreateProductInput,
ProductImagesInput,
ProductOptionInput,
UpdateProductInput,
} from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { ProductsAdminMapper } from './products-admin.mapper';
import { ProductsRepository } from './products.repository';
import { planVariantMatrix, slugify, type MatrixOption } from './variant-matrix';
@Injectable()
export class ProductsAdminService {
private readonly logger = new Logger(ProductsAdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly repository: ProductsRepository,
private readonly mapper: ProductsAdminMapper,
private readonly redis: RedisService,
private readonly audit: AuditService,
) {}
// ---- Reads ---------------------------------------------------------------
async list(query: AdminProductListQuery): Promise<OffsetPaginated<AdminProductListItem>> {
const where: Prisma.ProductWhereInput = {
deletedAt: null,
...(query.status ? { status: query.status } : {}),
...(query.brandId ? { brandId: query.brandId } : {}),
...(query.q
? {
OR: [
{ name: { contains: query.q, mode: 'insensitive' } },
{ translations: { some: { name: { contains: query.q, mode: 'insensitive' } } } },
{ variants: { some: { sku: { contains: query.q, mode: 'insensitive' } } } },
],
}
: {}),
};
const [rows, totalItems] = await Promise.all([
this.prisma.product.findMany({
where,
orderBy: { updatedAt: 'desc' },
skip: (query.page - 1) * query.perPage,
take: query.perPage,
select: {
id: true,
name: true,
slug: true,
status: true,
isOnSale: true,
publishedAt: true,
updatedAt: true,
translations: { where: { locale: toDbLocale(DEFAULT_LOCALE) } },
brand: { select: { name: true } },
images: {
orderBy: { position: 'asc' },
take: 1,
select: { media: { select: { storageKey: true } } },
},
variants: {
where: { status: 'ACTIVE', deletedAt: null },
select: {
currency: true,
priceAmount: true,
salePriceAmount: true,
stockLevels: { select: { onHand: true, reserved: true } },
},
},
},
}),
this.prisma.product.count({ where }),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => this.mapper.toListItem(row)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async getById(id: string): Promise<AdminProductDetail> {
const row = await this.repository.findByIdForAdmin(id);
if (!row) throw AppException.notFound('Product');
return this.mapper.toDetail(row);
}
// ---- Writes --------------------------------------------------------------
/**
* Creates a product and its full variant matrix in one transaction.
*
* All-or-nothing on purpose: a product that exists with half its variants is
* worse than one that failed outright, because the operator cannot tell which
* combinations are missing without inspecting the grid row by row.
*/
async create(input: CreateProductInput, actorUserId: string): Promise<AdminProductDetail> {
const canonical = this.canonicalTranslation(input);
const baseSlug = await this.uniqueSlug(canonical.slug ?? slugify(canonical.name));
const id = await this.prisma.$transaction(async (tx) => {
const product = await tx.product.create({
data: {
name: canonical.name,
slug: baseSlug,
shortDescription: canonical.shortDescription ?? null,
description: canonical.description ?? null,
status: 'DRAFT',
brandId: input.brandId ?? null,
primaryCategoryId: input.primaryCategoryId ?? null,
genderTargets: input.genderTargets,
sportTypes: input.sportTypes,
},
select: { id: true },
});
await this.writeTranslations(tx, product.id, input, baseSlug);
await this.writeCollections(tx, product.id, input.collectionIds ?? []);
await this.writeAttributes(tx, product.id, input.attributes ?? []);
const matrix = await this.writeOptions(tx, product.id, input.options ?? []);
await this.syncVariants(
tx,
product.id,
input.skuPrefix ?? baseSlug,
input.basePriceAmount,
matrix,
);
return product.id;
});
await this.afterWrite(actorUserId, 'product.create', id, { slug: baseSlug });
return this.getById(id);
}
async update(
id: string,
input: UpdateProductInput,
actorUserId: string,
): Promise<AdminProductDetail> {
const existing = await this.prisma.product.findFirst({
where: { id, deletedAt: null },
select: { id: true, slug: true, status: true },
});
if (!existing) throw AppException.notFound('Product');
await this.prisma.$transaction(async (tx) => {
const canonical = input.translations
? this.canonicalTranslation(input as CreateProductInput)
: null;
await tx.product.update({
where: { id },
data: {
...(canonical ? { name: canonical.name } : {}),
...(canonical?.shortDescription === undefined
? {}
: { shortDescription: canonical.shortDescription ?? null }),
...(canonical?.description === undefined
? {}
: { description: canonical.description ?? null }),
...(input.brandId === undefined ? {} : { brandId: input.brandId ?? null }),
...(input.primaryCategoryId === undefined
? {}
: { primaryCategoryId: input.primaryCategoryId ?? null }),
...(input.genderTargets ? { genderTargets: input.genderTargets } : {}),
...(input.sportTypes ? { sportTypes: input.sportTypes } : {}),
...(input.status ? this.statusPatch(input.status) : {}),
},
});
if (input.translations) {
await this.writeTranslations(tx, id, input as CreateProductInput, existing.slug);
}
if (input.collectionIds) {
await this.writeCollections(tx, id, input.collectionIds);
}
if (input.attributes) {
await this.writeAttributes(tx, id, input.attributes);
}
if (input.options) {
const matrix = await this.writeOptions(tx, id, input.options);
await this.syncVariants(
tx,
id,
input.skuPrefix ?? existing.slug,
input.basePriceAmount ?? 0,
matrix,
);
}
});
await this.afterWrite(actorUserId, 'product.update', id, { status: input.status });
return this.getById(id);
}
/**
* Per-variant edits: SKU, prices, weight, status.
*
* Stock is deliberately absent — it moves through the inventory endpoint so
* every change lands in the movement ledger. A price edit and a stock
* correction are different events with different audit requirements.
*/
async updateVariants(
productId: string,
input: BulkUpdateVariantsInput,
actorUserId: string,
): Promise<AdminProductDetail> {
const owned = await this.prisma.productVariant.findMany({
where: { productId, id: { in: input.variants.map((variant) => variant.id) } },
select: { id: true },
});
// Reject ids belonging to another product rather than silently skipping
// them: a partial save that reports success is how pricing work vanishes.
if (owned.length !== input.variants.length) {
throw AppException.badRequest('One or more variants do not belong to this product.');
}
await this.prisma.$transaction(
input.variants.map((variant) =>
this.prisma.productVariant.update({
where: { id: variant.id },
data: {
...(variant.sku === undefined ? {} : { sku: variant.sku }),
...(variant.barcode === undefined ? {} : { barcode: variant.barcode ?? null }),
...(variant.priceAmount === undefined ? {} : { priceAmount: variant.priceAmount }),
...(variant.salePriceAmount === undefined
? {}
: { salePriceAmount: variant.salePriceAmount ?? null }),
...(variant.compareAtAmount === undefined
? {}
: { compareAtAmount: variant.compareAtAmount ?? null }),
...(variant.costAmount === undefined ? {} : { costAmount: variant.costAmount ?? null }),
...(variant.weightGrams === undefined
? {}
: { weightGrams: variant.weightGrams ?? null }),
...(variant.status === undefined ? {} : { status: variant.status }),
},
}),
),
);
await this.afterWrite(actorUserId, 'product.variants.update', productId, {
count: input.variants.length,
});
return this.getById(productId);
}
async setImages(
productId: string,
input: ProductImagesInput,
actorUserId: string,
): Promise<AdminProductDetail> {
await this.prisma.$transaction(async (tx) => {
await tx.productImage.deleteMany({ where: { productId } });
if (input.images.length > 0) {
await tx.productImage.createMany({
data: input.images.map((image) => ({
productId,
mediaId: image.mediaId,
position: image.position,
optionValueId: image.optionValueId ?? null,
})),
skipDuplicates: true,
});
}
});
await this.afterWrite(actorUserId, 'product.images.update', productId, {
count: input.images.length,
});
return this.getById(productId);
}
async setStatus(
id: string,
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
actorUserId: string,
): Promise<AdminProductDetail> {
const existing = await this.prisma.product.findFirst({
where: { id, deletedAt: null },
select: { id: true, _count: { select: { variants: true } } },
});
if (!existing) throw AppException.notFound('Product');
// Publishing something with nothing to buy produces a page with a dead
// "Add to bag" button. Catch it here rather than on the storefront.
if (status === 'ACTIVE' && existing._count.variants === 0) {
throw AppException.badRequest('Add at least one variant before publishing.');
}
await this.prisma.product.update({ where: { id }, data: this.statusPatch(status) });
await this.afterWrite(actorUserId, `product.${status.toLowerCase()}`, id, { status });
return this.getById(id);
}
// ---- internals -----------------------------------------------------------
private statusPatch(status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED') {
return {
status,
// `publishedAt` is set once, on first publish, and never cleared —
// unpublishing and republishing should not reset "new arrivals" ordering.
...(status === 'ACTIVE' ? { publishedAt: new Date() } : {}),
};
}
private canonicalTranslation(input: CreateProductInput) {
const translations = input.translations;
const canonical = translations[DEFAULT_LOCALE] ?? Object.values(translations)[0];
if (!canonical) {
throw AppException.badRequest('Provide content for at least one language.');
}
return canonical;
}
/** Appends `-2`, `-3`… until the base slug is free. */
private async uniqueSlug(base: string, excludeId?: string): Promise<string> {
const candidate = base || 'product';
for (let suffix = 0; suffix < 50; suffix += 1) {
const slug = suffix === 0 ? candidate : `${candidate}-${suffix + 1}`;
const clash = await this.prisma.product.findFirst({
where: { slug, ...(excludeId ? { id: { not: excludeId } } : {}) },
select: { id: true },
});
if (!clash) return slug;
}
throw AppException.conflict('Could not generate a unique slug for this product.');
}
private async writeTranslations(
tx: Prisma.TransactionClient,
productId: string,
input: CreateProductInput,
fallbackSlug: string,
): Promise<void> {
for (const locale of LOCALES) {
const fields = input.translations[locale];
if (!fields) continue;
const slug = fields.slug ?? slugify(fields.name) ?? fallbackSlug;
await tx.productTranslation.upsert({
where: { productId_locale: { productId, locale: toDbLocale(locale) } },
update: {
name: fields.name,
slug,
shortDescription: fields.shortDescription ?? null,
description: fields.description ?? null,
metaTitle: fields.metaTitle ?? null,
metaDescription: fields.metaDescription ?? null,
},
create: {
productId,
locale: toDbLocale(locale),
name: fields.name,
slug,
shortDescription: fields.shortDescription ?? null,
description: fields.description ?? null,
metaTitle: fields.metaTitle ?? null,
metaDescription: fields.metaDescription ?? null,
},
});
}
}
private async writeCollections(
tx: Prisma.TransactionClient,
productId: string,
collectionIds: readonly string[],
): Promise<void> {
await tx.productCollection.deleteMany({ where: { productId } });
if (collectionIds.length > 0) {
await tx.productCollection.createMany({
data: collectionIds.map((collectionId) => ({ productId, collectionId })),
skipDuplicates: true,
});
}
}
private async writeAttributes(
tx: Prisma.TransactionClient,
productId: string,
attributes: CreateProductInput['attributes'],
): Promise<void> {
const keys = attributes.map((attribute) => attribute.key);
await tx.productAttribute.deleteMany({ where: { productId, key: { notIn: keys } } });
for (const attribute of attributes) {
const canonical =
attribute.translations[DEFAULT_LOCALE] ?? Object.values(attribute.translations)[0];
if (!canonical) continue;
const row = await tx.productAttribute.upsert({
where: { productId_key: { productId, key: attribute.key } },
update: { label: canonical.label, value: canonical.value, position: attribute.position },
create: {
productId,
key: attribute.key,
label: canonical.label,
value: canonical.value,
position: attribute.position,
},
select: { id: true },
});
for (const locale of LOCALES) {
const fields = attribute.translations[locale];
if (!fields) continue;
await tx.productAttributeTranslation.upsert({
where: { attributeId_locale: { attributeId: row.id, locale: toDbLocale(locale) } },
update: { label: fields.label, value: fields.value },
create: {
attributeId: row.id,
locale: toDbLocale(locale),
label: fields.label,
value: fields.value,
},
});
}
}
}
/**
* Reconciles options and their values.
*
* Values are matched by `value` (the machine key) rather than by id, so an
* editor that re-sends the whole option set does not orphan every variant
* link — which would archive the entire matrix on a cosmetic edit.
*/
private async writeOptions(
tx: Prisma.TransactionClient,
productId: string,
options: ProductOptionInput[],
): Promise<MatrixOption[]> {
const keys = options.map((option) => option.key);
await this.removeUnreferencedOptions(tx, productId, keys);
const matrix: MatrixOption[] = [];
for (const option of options) {
const canonicalName =
option.names[DEFAULT_LOCALE] ?? Object.values(option.names)[0] ?? option.key;
const optionRow = await tx.productOption.upsert({
where: { productId_key: { productId, key: option.key } },
update: { name: canonicalName, position: option.position },
create: { productId, key: option.key, name: canonicalName, position: option.position },
select: { id: true },
});
for (const locale of LOCALES) {
const name = option.names[locale];
if (!name) continue;
await tx.productOptionTranslation.upsert({
where: { optionId_locale: { optionId: optionRow.id, locale: toDbLocale(locale) } },
update: { name },
create: { optionId: optionRow.id, locale: toDbLocale(locale), name },
});
}
await this.removeUnreferencedValues(
tx,
optionRow.id,
option.values.map((value) => value.value),
);
const matrixValues: MatrixOption['values'] = [];
for (const value of option.values) {
const canonicalLabel =
value.labels[DEFAULT_LOCALE] ?? Object.values(value.labels)[0] ?? value.value;
const valueRow = await tx.productOptionValue.upsert({
where: { optionId_value: { optionId: optionRow.id, value: value.value } },
update: {
label: canonicalLabel,
position: value.position,
swatchHex: value.swatchHex ?? null,
},
create: {
optionId: optionRow.id,
value: value.value,
label: canonicalLabel,
position: value.position,
swatchHex: value.swatchHex ?? null,
},
select: { id: true },
});
for (const locale of LOCALES) {
const label = value.labels[locale];
if (!label) continue;
await tx.productOptionValueTranslation.upsert({
where: {
optionValueId_locale: { optionValueId: valueRow.id, locale: toDbLocale(locale) },
},
update: { label },
create: { optionValueId: valueRow.id, locale: toDbLocale(locale), label },
});
}
matrixValues.push({
id: valueRow.id,
value: value.value,
label: canonicalLabel,
position: value.position,
});
}
matrix.push({
key: option.key,
position: option.position,
values: matrixValues,
});
}
return matrix;
}
/**
* Deletes options the operator removed — but only when nothing references
* them.
*
* `ProductVariantOptionValue.optionValue` is `onDelete: Restrict`, and that is
* deliberate: an archived variant's link is what makes it meaningful ("this
* was Black / M") and order lines depend on it. So a value still referenced by
* any variant is *retained* rather than deleted; it simply stops appearing in
* the generated matrix, and the storefront filters it out because no active
* variant offers it.
*/
private async removeUnreferencedOptions(
tx: Prisma.TransactionClient,
productId: string,
keepKeys: string[],
): Promise<void> {
const doomed = await tx.productOption.findMany({
where: { productId, key: { notIn: keepKeys } },
select: {
id: true,
values: { select: { id: true, _count: { select: { variantLinks: true } } } },
},
});
for (const option of doomed) {
const referenced = option.values.some((value) => value._count.variantLinks > 0);
if (referenced) continue;
await tx.productOption.delete({ where: { id: option.id } });
}
}
private async removeUnreferencedValues(
tx: Prisma.TransactionClient,
optionId: string,
keepValues: string[],
): Promise<void> {
const doomed = await tx.productOptionValue.findMany({
where: { optionId, value: { notIn: keepValues } },
select: { id: true, _count: { select: { variantLinks: true } } },
});
const deletable = doomed.filter((value) => value._count.variantLinks === 0).map((v) => v.id);
if (deletable.length > 0) {
await tx.productOptionValue.deleteMany({ where: { id: { in: deletable } } });
}
}
/**
* Applies the variant matrix plan.
*
* The planning itself is a pure function (`planVariantMatrix`) with its own
* tests; this method only performs the writes it describes.
*/
private async syncVariants(
tx: Prisma.TransactionClient,
productId: string,
skuPrefix: string,
basePriceAmount: number,
options: MatrixOption[],
): Promise<void> {
const existing = await tx.productVariant.findMany({
where: { productId },
select: { id: true, sku: true, optionValues: { select: { optionValueId: true } } },
});
// Built from what the operator submitted, NOT from every row in the
// database — retained-for-history values must not regenerate variants.
const plan = planVariantMatrix({
options,
existing: existing.map((variant) => ({
id: variant.id,
sku: variant.sku,
optionValueIds: variant.optionValues.map((link) => link.optionValueId),
})),
skuPrefix,
});
const optionIdByValueId = new Map<string, string>();
const optionRows = await tx.productOption.findMany({
where: { productId },
select: { id: true, values: { select: { id: true } } },
});
for (const option of optionRows) {
for (const value of option.values) {
optionIdByValueId.set(value.id, option.id);
}
}
for (const row of plan.created) {
const variant = await tx.productVariant.create({
data: {
productId,
sku: await uniqueSku(tx, row.suggestedSku),
title: row.title,
currency: 'VND',
priceAmount: basePriceAmount,
position: row.position,
status: 'ACTIVE',
},
select: { id: true },
});
await tx.productVariantOptionValue.createMany({
data: row.optionValueIds.map((optionValueId) => ({
variantId: variant.id,
optionId: optionIdByValueId.get(optionValueId) ?? '',
optionValueId,
})),
skipDuplicates: true,
});
}
// Keep display order and titles in step with the current option ordering.
for (const row of plan.kept) {
if (!row.existingId) continue;
await tx.productVariant.update({
where: { id: row.existingId },
data: { position: row.position, title: row.title, status: 'ACTIVE' },
});
}
if (plan.archivedIds.length > 0) {
// Archived, never deleted — order lines reference these ids.
await tx.productVariant.updateMany({
where: { id: { in: plan.archivedIds } },
data: { status: 'ARCHIVED' },
});
}
}
/**
* Recomputes the price projection, drops the catalog cache, and records the
* change. Every write path ends here.
*/
private async afterWrite(
actorUserId: string,
action: string,
productId: string,
changes: Record<string, unknown>,
): Promise<void> {
await this.repository.recomputePricing(productId);
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
this.logger.log(`${action} on ${productId}; dropped ${dropped} catalog cache key(s)`);
this.audit.record({
actorUserId,
action,
resourceType: 'Product',
resourceId: productId,
changes: changes as Prisma.InputJsonValue,
});
}
}
/**
* SKUs are unique across the whole catalog, so a generated one can collide with
* a product that already used that colour/size naming. Suffix until free.
*/
async function uniqueSku(tx: Prisma.TransactionClient, base: string): Promise<string> {
for (let suffix = 0; suffix < 100; suffix += 1) {
const sku = suffix === 0 ? base : `${base}-${suffix + 1}`;
const clash = await tx.productVariant.findUnique({ where: { sku }, select: { id: true } });
if (!clash) return sku;
}
throw AppException.conflict(`Could not generate a unique SKU from "${base}".`);
}
@@ -63,9 +63,24 @@ export class ProductsMapper {
breadcrumbs: readonly Breadcrumb[], breadcrumbs: readonly Breadcrumb[],
): StorefrontProduct { ): StorefrontProduct {
const translation = pickTranslation(row.translations, locale); 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)); const variants = row.variants.map((variant) => this.toStorefrontVariant(variant, locale));
/**
* Only option values that at least one *active* variant offers.
*
* Removing a colourway retains its option value when archived variants
* still reference it (order history depends on the link). Without this
* filter the shopper would see a swatch that can never be selected —
* a permanently disabled control with no explanation.
*/
const sellableValueIds = new Set(
variants.flatMap((variant) => variant.optionValues.map((ov) => ov.optionValueId)),
);
const options = row.options
.map((option) => this.toOption(option, locale, sellableValueIds))
.filter((option) => option.values.length > 0);
return { return {
id: row.id, id: row.id,
name: coalesceRequired(translation?.name, row.name), name: coalesceRequired(translation?.name, row.name),
@@ -218,20 +233,28 @@ export class ProductsMapper {
}; };
} }
private toOption(option: ProductDetailRow['options'][number], locale: Locale): ProductOption { private toOption(
option: ProductDetailRow['options'][number],
locale: Locale,
sellableValueIds: ReadonlySet<string>,
): ProductOption {
return { return {
id: option.id, id: option.id,
name: coalesceRequired(pickTranslation(option.translations, locale)?.name, option.name), name: coalesceRequired(pickTranslation(option.translations, locale)?.name, option.name),
key: option.key, key: option.key,
position: option.position, position: option.position,
values: option.values.map((value) => ({ values: option.values
.filter((value) => sellableValueIds.has(value.id))
.map((value) => ({
id: value.id, id: value.id,
optionId: value.optionId, optionId: value.optionId,
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label), label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
value: value.value, value: value.value,
position: value.position, position: value.position,
swatchHex: value.swatchHex, swatchHex: value.swatchHex,
swatchImageUrl: value.swatchImage ? this.mediaUrl.url(value.swatchImage.storageKey) : null, swatchImageUrl: value.swatchImage
? this.mediaUrl.url(value.swatchImage.storageKey)
: null,
})), })),
}; };
} }
@@ -2,6 +2,9 @@ import { Module } from '@nestjs/common';
import { CategoriesModule } from '@/modules/categories/categories.module'; import { CategoriesModule } from '@/modules/categories/categories.module';
import { ProductsAdminController } from './products-admin.controller';
import { ProductsAdminMapper } from './products-admin.mapper';
import { ProductsAdminService } from './products-admin.service';
import { ProductsController } from './products.controller'; import { ProductsController } from './products.controller';
import { ProductsMapper } from './products.mapper'; import { ProductsMapper } from './products.mapper';
import { ProductsRepository } from './products.repository'; import { ProductsRepository } from './products.repository';
@@ -11,13 +14,21 @@ import { ProductsService } from './products.service';
* ProductsModule — owns `products`, `product_translations`, `product_options`, * ProductsModule — owns `products`, `product_translations`, `product_options`,
* `product_option_values`, `product_images` and `product_attributes`. * `product_option_values`, `product_images` and `product_attributes`.
* *
* The catalog aggregate root. Other modules reference a product by id and read * The catalog aggregate root. Read and write live in the same module but in
* through this module's public service. * separate services: they have different consumers, different payloads and very
* different caching rules, and merging them would mean the storefront query
* grows admin-only joins it must never expose.
*/ */
@Module({ @Module({
imports: [CategoriesModule], imports: [CategoriesModule],
controllers: [ProductsController], controllers: [ProductsController, ProductsAdminController],
providers: [ProductsService, ProductsRepository, ProductsMapper], providers: [
ProductsService,
ProductsAdminService,
ProductsRepository,
ProductsMapper,
ProductsAdminMapper,
],
exports: [ProductsService], exports: [ProductsService],
}) })
export class ProductsModule {} export class ProductsModule {}
@@ -413,6 +413,81 @@ export class ProductsRepository {
}); });
} }
/**
* Full admin payload: every locale, cost prices, stock, and DRAFT/ARCHIVED
* products included. Deliberately separate from the storefront query, which
* must never see cost price or unpublished rows.
*/
findByIdForAdmin(id: string) {
return this.prisma.product.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
status: true,
publishedAt: true,
brandId: true,
primaryCategoryId: true,
genderTargets: true,
sportTypes: true,
createdAt: true,
updatedAt: true,
translations: true,
collections: { select: { collectionId: true } },
options: {
orderBy: { position: 'asc' },
select: {
id: true,
key: true,
position: true,
translations: true,
values: {
orderBy: { position: 'asc' },
select: {
id: true,
value: true,
position: true,
swatchHex: true,
translations: true,
},
},
},
},
images: {
orderBy: { position: 'asc' },
select: {
id: true,
mediaId: true,
position: true,
optionValueId: true,
media: { select: { storageKey: true, altText: true } },
},
},
attributes: {
orderBy: { position: 'asc' },
select: { id: true, key: true, position: true, translations: true },
},
variants: {
orderBy: { position: 'asc' },
select: {
id: true,
sku: true,
barcode: true,
title: true,
priceAmount: true,
salePriceAmount: true,
compareAtAmount: true,
costAmount: true,
weightGrams: true,
status: true,
position: true,
optionValues: { select: { optionValueId: true } },
stockLevels: { select: { onHand: true, reserved: true } },
},
},
},
});
}
/** All translated slugs for a locale — feeds `generateStaticParams`/sitemaps. */ /** All translated slugs for a locale — feeds `generateStaticParams`/sitemaps. */
findAllSlugs(locale: Locale) { findAllSlugs(locale: Locale) {
return this.prisma.productTranslation.findMany({ return this.prisma.productTranslation.findMany({
@@ -534,3 +609,6 @@ export class ProductsRepository {
export type ProductListRow = Awaited<ReturnType<ProductsRepository['findList']>>[number]; export type ProductListRow = Awaited<ReturnType<ProductsRepository['findList']>>[number];
export type ProductDetailRow = NonNullable<Awaited<ReturnType<ProductsRepository['findBySlug']>>>; export type ProductDetailRow = NonNullable<Awaited<ReturnType<ProductsRepository['findBySlug']>>>;
export type AdminProductRow = NonNullable<
Awaited<ReturnType<ProductsRepository['findByIdForAdmin']>>
>;
@@ -0,0 +1,153 @@
import {
buildSku,
combinationSignature,
planVariantMatrix,
slugify,
type MatrixOption,
} from './variant-matrix';
/**
* The variant matrix decides what is purchasable. A bug here either deletes a
* merchandiser's pricing work or resurrects products that should be gone, so
* the behaviour is pinned rather than left to inspection.
*/
const colour: MatrixOption = {
key: 'colour',
position: 0,
values: [
{ id: 'c-black', value: 'black', label: 'Black', position: 0 },
{ id: 'c-white', value: 'white', label: 'White', position: 1 },
],
};
const size: MatrixOption = {
key: 'size',
position: 1,
values: [
{ id: 's-s', value: 's', label: 'S', position: 0 },
{ id: 's-m', value: 'm', label: 'M', position: 1 },
],
};
describe('planVariantMatrix', () => {
it('generates every combination for a fresh product', () => {
const plan = planVariantMatrix({ options: [colour, size], existing: [], skuPrefix: 'TEE' });
expect(plan.created).toHaveLength(4);
expect(plan.kept).toHaveLength(0);
expect(plan.archivedIds).toHaveLength(0);
expect(plan.created.map((row) => row.title)).toEqual([
'Black / S',
'Black / M',
'White / S',
'White / M',
]);
});
it('orders combinations with the first option varying slowest', () => {
// "all sizes of black, then all sizes of white" — not interleaved, which is
// what makes the generated grid readable.
const plan = planVariantMatrix({ options: [colour, size], existing: [], skuPrefix: 'TEE' });
expect(plan.created.map((row) => row.suggestedSku)).toEqual([
'TEE-BLACK-S',
'TEE-BLACK-M',
'TEE-WHITE-S',
'TEE-WHITE-M',
]);
});
it('keeps existing variants so their price and stock survive an edit', () => {
const existing = [
{ id: 'v1', sku: 'TEE-BLACK-S', optionValueIds: ['c-black', 's-s'] },
{ id: 'v2', sku: 'TEE-BLACK-M', optionValueIds: ['c-black', 's-m'] },
];
const plan = planVariantMatrix({ options: [colour, size], existing, skuPrefix: 'TEE' });
expect(plan.kept.map((row) => row.existingId)).toEqual(['v1', 'v2']);
expect(plan.created).toHaveLength(2);
expect(plan.archivedIds).toHaveLength(0);
});
it('matches combinations regardless of option-value order', () => {
// A reordered option list must not read as a brand-new set of variants —
// that would wipe every price and stock level on the product.
const existing = [{ id: 'v1', sku: 'X', optionValueIds: ['s-s', 'c-black'] }];
const plan = planVariantMatrix({ options: [colour, size], existing, skuPrefix: 'TEE' });
expect(plan.kept).toHaveLength(1);
expect(plan.kept[0]?.existingId).toBe('v1');
});
it('archives combinations that no longer exist rather than deleting them', () => {
// Order lines reference variant ids; deleting one breaks history.
const existing = [
{ id: 'v1', sku: 'TEE-BLACK-S', optionValueIds: ['c-black', 's-s'] },
{ id: 'gone', sku: 'TEE-RED-S', optionValueIds: ['c-red', 's-s'] },
];
const plan = planVariantMatrix({ options: [colour, size], existing, skuPrefix: 'TEE' });
expect(plan.archivedIds).toEqual(['gone']);
});
it('refuses to plan when an option has no values, instead of archiving everything', () => {
const existing = [{ id: 'v1', sku: 'X', optionValueIds: ['c-black', 's-s'] }];
const empty: MatrixOption = { key: 'size', position: 1, values: [] };
const plan = planVariantMatrix({ options: [colour, empty], existing, skuPrefix: 'TEE' });
// An empty option is a half-finished edit, not an instruction to unpublish
// every variant on the product.
expect(plan.archivedIds).toHaveLength(0);
expect(plan.created).toHaveLength(0);
});
it('scales to a third option without special-casing', () => {
const width: MatrixOption = {
key: 'width',
position: 2,
values: [
{ id: 'w-r', value: 'regular', label: 'Regular', position: 0 },
{ id: 'w-w', value: 'wide', label: 'Wide', position: 1 },
],
};
const plan = planVariantMatrix({
options: [colour, size, width],
existing: [],
skuPrefix: 'TEE',
});
expect(plan.created).toHaveLength(8);
expect(plan.created[0]?.title).toBe('Black / S / Regular');
});
});
describe('combinationSignature', () => {
it('is order-independent', () => {
expect(combinationSignature(['b', 'a'])).toBe(combinationSignature(['a', 'b']));
});
});
describe('buildSku', () => {
it('uppercases and hyphenates', () => {
expect(buildSku('vel art', ['black', 'xl'])).toBe('VEL-ART-BLACK-XL');
});
it('strips diacritics and punctuation', () => {
expect(buildSku('Áo', ['xanh neon'])).toBe('AO-XANH-NEON');
});
});
describe('slugify', () => {
it('handles Vietnamese diacritics including đ', () => {
expect(slugify('Áo Chạy Bộ Aero')).toBe('ao-chay-bo-aero');
expect(slugify('Giày Đá Bóng')).toBe('giay-da-bong');
});
it('collapses punctuation and trims separators', () => {
expect(slugify(' Tempo — Split/Short! ')).toBe('tempo-split-short');
});
});
@@ -0,0 +1,170 @@
/**
* Variant matrix generation.
*
* A pure function on purpose: this is the single most consequential piece of
* logic in the catalog, and it needs to be testable without a database, a Nest
* container or a fixture. Everything it touches is plain data.
*
* The rule it enforces (ADR-0003): a product's purchasable units are exactly
* the cartesian product of its option values. Adding a colour must not silently
* destroy the price and stock of every existing size.
*/
export interface MatrixOption {
key: string;
position: number;
values: { id: string; value: string; label: string; position: number }[];
}
export interface ExistingVariant {
id: string;
sku: string;
/** The option-value ids this variant resolves to. Order-independent. */
optionValueIds: string[];
}
export interface MatrixRow {
/** Present when this combination already exists. */
existingId: string | null;
optionValueIds: string[];
/** Stable identity for the combination, independent of option order. */
signature: string;
/** "Black / M" — from the option values, in option order. */
title: string;
suggestedSku: string;
position: number;
}
export interface MatrixPlan {
/** Combinations that already exist and should be kept as-is. */
kept: MatrixRow[];
/** Combinations that do not exist yet and must be created. */
created: MatrixRow[];
/**
* Variants whose combination no longer exists.
*
* ARCHIVED, never deleted: order lines reference variant ids, and deleting
* one would either break a historical order or cascade into it. An archived
* variant stops being sellable and stops appearing anywhere except history.
*/
archivedIds: string[];
}
/**
* Order-independent identity for a combination.
*
* Sorting the ids means `[black, M]` and `[M, black]` produce the same
* signature — without it, a reordered option list would look like an entirely
* new set of variants and wipe every price and stock level on the product.
*/
export function combinationSignature(optionValueIds: readonly string[]): string {
return [...optionValueIds].sort().join('|');
}
/**
* Builds the full matrix and diffs it against what exists.
*
* Returns a *plan* rather than performing writes, so the caller can apply it in
* one transaction and so the decision is inspectable in a test.
*/
export function planVariantMatrix(params: {
options: MatrixOption[];
existing: ExistingVariant[];
skuPrefix: string;
}): MatrixPlan {
const options = [...params.options].sort((a, b) => a.position - b.position);
// A product with no options has no combinations, so every existing variant
// would be archived. That is almost certainly a mistake in the caller rather
// than an intent to unpublish everything, so refuse instead.
if (options.length === 0 || options.some((option) => option.values.length === 0)) {
return { kept: [], created: [], archivedIds: [] };
}
const existingBySignature = new Map(
params.existing.map((variant) => [combinationSignature(variant.optionValueIds), variant]),
);
const combinations = cartesian(options.map((option) => option.values));
const kept: MatrixRow[] = [];
const created: MatrixRow[] = [];
const seen = new Set<string>();
combinations.forEach((combination, index) => {
const optionValueIds = combination.map((value) => value.id);
const signature = combinationSignature(optionValueIds);
seen.add(signature);
const row: MatrixRow = {
existingId: existingBySignature.get(signature)?.id ?? null,
optionValueIds,
signature,
title: combination.map((value) => value.label).join(' / '),
suggestedSku: buildSku(
params.skuPrefix,
combination.map((value) => value.value),
),
position: index,
};
if (row.existingId) {
kept.push(row);
} else {
created.push(row);
}
});
const archivedIds = params.existing
.filter((variant) => !seen.has(combinationSignature(variant.optionValueIds)))
.map((variant) => variant.id);
return { kept, created, archivedIds };
}
/**
* Cartesian product, preserving option order so the first option varies
* slowest — which is what makes the generated grid read as "all sizes of black,
* then all sizes of white" rather than an interleaved jumble.
*/
function cartesian<T>(groups: T[][]): T[][] {
return groups.reduce<T[][]>(
(accumulator, group) =>
accumulator.flatMap((combination) => group.map((item) => [...combination, item])),
[[]],
);
}
/**
* `VEL-ART` + `black` + `m` → `VEL-ART-BLACK-M`.
*
* A suggestion only. SKUs frequently have to match an existing ERP or
* warehouse scheme, so the editor always lets an operator override it — and the
* database, not this function, is what guarantees uniqueness.
*/
export function buildSku(prefix: string, values: readonly string[]): string {
const clean = (input: string) =>
input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^A-Za-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.toUpperCase();
return [clean(prefix), ...values.map(clean)].filter(Boolean).join('-');
}
/** `Áo Chạy Bộ Aero` → `ao-chay-bo-aero`. Vietnamese diacritics included. */
export function slugify(input: string): string {
return (
input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
// đ/Đ has no combining form, so NFD leaves it intact.
.replace(/[đĐ]/g, 'd')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 180)
);
}
+4
View File
@@ -17,10 +17,14 @@
"@sport/ui": "workspace:*", "@sport/ui": "workspace:*",
"@sport/validation": "workspace:*", "@sport/validation": "workspace:*",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"embla-carousel-react": "8.6.0",
"lucide-react": "1.31.0",
"motion": "13.1.0",
"next": "catalog:", "next": "catalog:",
"next-intl": "^4.13.6", "next-intl": "^4.13.6",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"tw-animate-css": "1.4.0",
"zod": "catalog:", "zod": "catalog:",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -2,10 +2,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer'; 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 { Link } from '@/i18n/navigation';
import { fetchNavigation } from '@/lib/catalog';
import { routes } from '@/lib/routes'; import { routes } from '@/lib/routes';
/** /**
@@ -4,7 +4,7 @@ import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing'; import { ProductListing } from '@/components/commerce/product-listing';
import { CATALOG_CACHE, getServerApi } from '@/lib/api'; import { CATALOG_CACHE, getServerApi } from '@/lib/api';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params'; import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
@@ -2,9 +2,9 @@ import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer'; import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header'; import { fetchNavigation } from '@/lib/catalog';
import { fetchNavigation } from '@/features/product/services/catalog';
/** /**
* Chrome shared by every browsing route. * Chrome shared by every browsing route.
@@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing'; import { ProductListing } from '@/components/commerce/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params'; import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = { type PageProps = {
@@ -1,11 +1,11 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { Button } from '@sport/ui';
import { ProductGrid } from '@/features/product/components/product-grid'; import { Hero } from '@/components/commerce/hero';
import { fetchProducts } from '@/features/product/services/catalog'; import { ProductGrid } from '@/components/commerce/product-grid';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { fetchProducts } from '@/lib/catalog';
import { SPORT_NAV, routes } from '@/lib/routes'; import { SPORT_NAV, routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
@@ -26,34 +26,15 @@ export default async function HomePage({ params }: PageProps) {
return ( return (
<> <>
<section className="border-ink-200 bg-ink-950 border-b text-white"> <Hero
<div className="max-w-page px-gutter mx-auto py-24 sm:py-32"> eyebrow={t('heroEyebrow')}
<p className="text-volt-500 text-xs font-semibold uppercase tracking-widest"> title={t('heroTitle')}
{t('heroEyebrow')} body={t('heroBody')}
</p> primaryCta={t('heroCta')}
<h1 className="mt-4 max-w-3xl text-5xl font-black uppercase leading-[0.95] sm:text-7xl"> primaryHref={routes.collection('new-arrivals')}
{t('heroTitle')} secondaryCta={t('heroSecondary')}
</h1> secondaryHref={routes.men()}
<p className="text-ink-300 mt-6 max-w-xl text-base">{t('heroBody')}</p> />
<div className="mt-10 flex flex-wrap gap-3">
<Link href={routes.collection('new-arrivals')}>
<Button variant="accent" size="lg">
{t('heroCta')}
</Button>
</Link>
<Link href={routes.men()}>
<Button
variant="outline"
size="lg"
className="hover:text-ink-950 border-white text-white hover:bg-white"
>
{t('heroSecondary')}
</Button>
</Link>
</div>
</div>
</section>
<section className="max-w-page px-gutter mx-auto py-16"> <section className="max-w-page px-gutter mx-auto py-16">
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest"> <h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
@@ -4,10 +4,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALES, type Locale } from '@sport/types'; import { LOCALES, type Locale } from '@sport/types';
import { ProductDetail } from '@/features/product/components/product-detail'; import { ProductDetail } from '@/components/commerce/product-detail';
import { fetchProduct } from '@/features/product/services/catalog';
import { Link, redirect } from '@/i18n/navigation'; import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api'; import { getServerApi } from '@/lib/api';
import { fetchProduct } from '@/lib/catalog';
import { routes } from '@/lib/routes'; import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> }; type PageProps = { params: Promise<{ locale: string; slug: string }> };
@@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing'; import { ProductListing } from '@/components/commerce/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params'; import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = { type PageProps = {
@@ -4,7 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing'; import { ProductListing } from '@/components/commerce/product-listing';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { SPORT_NAV, isSportSlug } from '@/lib/routes'; import { SPORT_NAV, isSportSlug } from '@/lib/routes';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params'; import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
@@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types'; import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing'; import { ProductListing } from '@/components/commerce/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params'; import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = { type PageProps = {
@@ -13,9 +13,9 @@ export default function NotFound() {
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{t('code')}</p> <p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{t('code')}</p>
<h1 className="mt-4 text-5xl font-black uppercase">{t('title')}</h1> <h1 className="mt-4 text-5xl font-black uppercase">{t('title')}</h1>
<p className="text-ink-500 mt-4 max-w-md">{t('body')}</p> <p className="text-ink-500 mt-4 max-w-md">{t('body')}</p>
<Link href={routes.home()} className="mt-8"> <Button className="mt-8" asChild>
<Button>{t('cta')}</Button> <Link href={routes.home()}>{t('cta')}</Link>
</Link> </Button>
</div> </div>
); );
} }
@@ -0,0 +1,55 @@
'use client';
import { SlidersHorizontal } from 'lucide-react';
import { useTranslations } from 'next-intl';
import type { ReactNode } from 'react';
import { Button, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@sport/ui';
/**
* Puts the filter rail behind a button on small screens.
*
* Without this the rail stacks above the grid, so on a phone every product sits
* below a full screen of filter controls — the listing appears empty until you
* scroll past the thing that is meant to help you narrow it.
*
* The rail itself is passed in as `children` and stays a Server Component: this
* wrapper only owns the open/closed state, so no filter rendering moves to the
* client. The links inside are ordinary navigations, which close the sheet by
* unmounting it.
*/
export function FilterSheet({
children,
activeCount,
}: {
children: ReactNode;
activeCount: number;
}) {
const t = useTranslations('listing');
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="w-full lg:hidden">
<SlidersHorizontal />
{t('filters')}
{activeCount > 0 ? (
<span className="bg-ink-950 grid size-5 place-items-center rounded-full text-[0.625rem] text-white">
{activeCount}
</span>
) : null}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[85vw] overflow-y-auto sm:max-w-sm">
<SheetHeader className="border-ink-200 border-b">
<SheetTitle className="text-sm font-semibold uppercase tracking-widest">
{t('filters')}
</SheetTitle>
</SheetHeader>
<div className="px-gutter py-6">{children}</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,117 @@
'use client';
import { ArrowRight } from 'lucide-react';
import { motion, useReducedMotion } from 'motion/react';
import { Button } from '@sport/ui';
import { Link } from '@/i18n/navigation';
/**
* The homepage hero.
*
* Hand-built rather than assembled from primitives: this is the first thing a
* shopper sees and it is almost entirely brand — the oversized condensed
* headline, the volt eyebrow, the stagger on entry. There is no registry
* component that would make this better, only one that would make it generic.
*
* The animation is a single staggered rise. It runs once, on mount, and it is
* short — a hero that keeps moving is a hero that delays the first click.
*/
export function Hero({
eyebrow,
title,
body,
primaryCta,
primaryHref,
secondaryCta,
secondaryHref,
}: {
eyebrow: string;
title: string;
body: string;
primaryCta: string;
primaryHref: string;
secondaryCta: string;
secondaryHref: string;
}) {
// Honour the OS setting rather than animating regardless: vestibular
// sensitivity is exactly the case a large moving headline aggravates.
const reduceMotion = useReducedMotion();
const rise = reduceMotion
? {}
: {
initial: { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
};
return (
<section className="bg-ink-950 relative overflow-hidden border-b border-white/10 text-white">
{/*
A single volt bloom, well off-centre, painted as a radial gradient.
The first version was a blurred div — `size-[32rem]` with `blur(64px)`.
It promoted a half-megapixel element to its own composited layer, and in
that state the browser dropped the paint of everything beneath it: the
headline, the copy and both CTAs rendered as empty black. A gradient
produces the same image with no filter and no extra layer.
*/}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(38rem 38rem at 88% -10%, color-mix(in oklab, var(--color-volt-500) 18%, transparent), transparent 70%)',
}}
/>
<div className="max-w-page px-gutter relative mx-auto py-24 sm:py-32">
<motion.p
{...rise}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className="text-volt-500 text-xs font-semibold uppercase tracking-widest"
>
{eyebrow}
</motion.p>
<motion.h1
{...rise}
transition={{ duration: 0.5, delay: 0.06, ease: [0.22, 1, 0.36, 1] }}
className="mt-4 max-w-3xl text-5xl font-black uppercase leading-[0.95] sm:text-7xl"
>
{title}
</motion.h1>
<motion.p
{...rise}
transition={{ duration: 0.5, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
className="text-ink-300 mt-6 max-w-xl text-base"
>
{body}
</motion.p>
<motion.div
{...rise}
transition={{ duration: 0.5, delay: 0.18, ease: [0.22, 1, 0.36, 1] }}
className="mt-10 flex flex-wrap gap-3"
>
<Button variant="accent" size="lg" asChild>
<Link href={primaryHref}>
{primaryCta}
<ArrowRight />
</Link>
</Button>
<Button
variant="outline"
size="lg"
className="hover:text-ink-950 border-white text-white hover:bg-white"
asChild
>
<Link href={secondaryHref}>{secondaryCta}</Link>
</Button>
</motion.div>
</div>
</section>
);
}
@@ -0,0 +1,117 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { isApiClientError, type ProductListQuery } from '@sport/api-client';
import type { Locale, ProductListItem } from '@sport/types';
import { Button } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
import { buildListingHref } from '@/lib/listing-href';
import { ProductCard } from './product-card';
/**
* Appends the next page of products in place.
*
* The button is a real `<a href="?cursor=…">`, and that is the whole design.
* With JavaScript the click is intercepted and the next page is appended, which
* is what a shopper browsing a category actually wants. Without it — and for a
* crawler — the link is followed and the server renders the next page normally.
* So the catalog stays reachable beyond the first 24 products without a sitemap
* and without an infinite scroll that search engines cannot walk.
*
* Only the *appended* products are client-rendered. The first page is server
* rendered by `ProductGrid` above this component, so the initial paint and its
* markup are unchanged.
*/
export function LoadMore({
locale,
query,
basePath,
initialCursor,
initialCount,
totalCount,
}: {
locale: Locale;
query: ProductListQuery;
basePath: string;
initialCursor: string;
initialCount: number;
totalCount: number;
}) {
const t = useTranslations('listing');
const [extra, setExtra] = useState<ProductListItem[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const shown = initialCount + extra.length;
async function loadNext() {
if (!cursor || loading) return;
setLoading(true);
setError(null);
try {
const result = await browserApi.catalog.listProducts(locale, { ...query, cursor });
setExtra((current) => [...current, ...result.items]);
setCursor(result.pageInfo.nextCursor);
} catch (caught) {
// Leaves the href intact, so the fallback is still a working navigation
// rather than a dead end.
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
} finally {
setLoading(false);
}
}
return (
<>
{extra.length > 0 ? (
<div className="mt-10 grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{extra.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : null}
<div className="mt-12 flex flex-col items-center gap-3">
<p className="text-ink-400 text-xs uppercase tracking-widest">
{t('showing', { shown, total: totalCount })}
</p>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{cursor ? (
<Button variant="outline" size="lg" asChild>
<Link
href={buildListingHref(basePath, query, { cursor })}
onClick={(event) => {
// Let modified clicks (new tab, download, middle click) behave
// like the ordinary link this genuinely is.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
void loadNext();
}}
aria-busy={loading}
>
{loading ? <Loader2 className="animate-spin" /> : null}
{t('loadMore')}
</Link>
</Button>
) : null}
</div>
</>
);
}
@@ -0,0 +1,132 @@
'use client';
import { ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
export interface MegaMenuColumn {
readonly heading: string;
readonly links: readonly { readonly label: string; readonly href: string }[];
}
export interface MegaMenuEntry {
readonly id: string;
readonly label: string;
readonly href: string;
readonly columns: readonly MegaMenuColumn[];
}
/**
* Desktop navigation with a full-width drop panel.
*
* Hand-built rather than Radix NavigationMenu. The panel here is a plain hover
* region with no roving focus and no collision logic to get wrong, and it is
* the most brand-visible element in the chrome — the animation, the full-bleed
* black panel, the column rhythm. Radix would supply behaviour this does not
* need and constrain the markup that carries the identity.
*
* What it still has to get right, and does:
* - Opens on hover *and* on keyboard focus, so it is reachable without a mouse.
* - Closes on Escape and on focus leaving the group.
* - The top-level item stays a real link, so a sport is one click away rather
* than requiring the panel.
*/
export function MegaMenu({
entries,
className,
}: {
entries: readonly MegaMenuEntry[];
className?: string;
}) {
const [openId, setOpenId] = useState<string | null>(null);
// Escape is bound to the document rather than to a wrapper's `onKeyDown`:
// the panel is dismissible from anywhere while it is open, and hanging key
// handlers off a non-interactive container would be lying about what that
// container is.
useEffect(() => {
if (!openId) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenId(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [openId]);
return (
<div className={cn('flex items-center gap-5', className)} onMouseLeave={() => setOpenId(null)}>
{entries.map((entry) => {
const open = openId === entry.id;
const hasPanel = entry.columns.length > 0;
return (
<div
key={entry.id}
className="static"
onMouseEnter={() => setOpenId(entry.id)}
onFocus={() => setOpenId(entry.id)}
onBlur={(event) => {
// Only close when focus leaves the whole group, otherwise tabbing
// from the trigger into the panel would shut it immediately.
if (!event.currentTarget.contains(event.relatedTarget)) setOpenId(null);
}}
>
<Link
href={entry.href}
aria-expanded={hasPanel ? open : undefined}
className={cn(
'hover:text-volt-600 focus-visible:ring-ring/50 flex items-center gap-1 py-5 text-xs font-semibold uppercase tracking-widest outline-none transition-colors focus-visible:ring-[3px]',
open && 'text-volt-600',
)}
>
{entry.label}
{hasPanel ? (
<ChevronDown
className={cn('size-3 transition-transform duration-200', open && 'rotate-180')}
/>
) : null}
</Link>
{/*
`border-y`, not `border-t`. On the homepage the panel is black
over a black hero, and without a bottom edge the two surfaces
merge — the menu reads as text floating on the hero rather than as
a panel in front of it.
*/}
{hasPanel && open ? (
<div className="bg-ink-950 absolute inset-x-0 top-full z-40 border-y border-white/15 text-white shadow-2xl">
<div className="max-w-page px-gutter mx-auto grid grid-cols-2 gap-10 py-10 md:grid-cols-4">
{entry.columns.map((column) => (
<div key={column.heading}>
<p className="text-volt-500 text-[0.625rem] font-semibold uppercase tracking-widest">
{column.heading}
</p>
<ul className="mt-4 space-y-2.5">
{column.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpenId(null)}
className="text-ink-300 hover:text-volt-500 text-sm transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import { Menu } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import type { MegaMenuEntry } from './mega-menu';
/**
* The small-screen counterpart to the mega menu.
*
* A Sheet rather than a bespoke drawer: focus trapping, scroll locking, the
* Escape handler and the `aria-modal` wiring are exactly the infrastructure
* worth taking from the registry. What is inside it is ours.
*
* Sections are flattened rather than nested behind accordions — with four
* groups the extra tap costs more than the scroll it saves.
*/
export function MobileNav({ entries }: { entries: readonly MegaMenuEntry[] }) {
const t = useTranslations('nav');
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
aria-label={t('openMenu')}
className="focus-visible:ring-ring/50 -ml-2 grid size-10 place-items-center outline-none focus-visible:ring-[3px] lg:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="w-[85vw] gap-0 overflow-y-auto sm:max-w-sm">
<SheetHeader className="border-ink-200 border-b">
<SheetTitle className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</SheetTitle>
</SheetHeader>
<nav aria-label={t('ariaMain')} className="px-gutter space-y-8 py-6">
{entries.map((entry) => (
<div key={entry.id}>
<Link
href={entry.href}
onClick={() => setOpen(false)}
className="text-sm font-black uppercase tracking-widest"
>
{entry.label}
</Link>
{entry.columns.length > 0 ? (
<ul className="mt-3 space-y-2">
{entry.columns
.flatMap((column) => column.links)
.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpen(false)}
className="text-ink-500 hover:text-ink-950 text-sm"
>
{link.label}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</nav>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,134 @@
import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { Badge, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { discountPercent, formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* The grid card.
*
* Commerce, not UI: it knows about sale badges, price ranges and colourways,
* none of which mean anything in the admin dashboard. Built by hand because the
* card *is* the catalog — its proportions, the hover crossfade and the
* typographic hierarchy are the brand, and a generic card component would erase
* all three. See docs/architecture.md §4.
*
* Stays a Server Component. The hover swap is pure CSS, so this ships no
* JavaScript at all, and a listing renders 24 of them.
*/
export function ProductCard({
product,
priority = false,
}: {
product: ProductListItem;
priority?: boolean;
}) {
const t = useTranslations('product');
const format = useFormatter();
const { priceRange, primaryImage, hoverImage } = product;
const hasRange = priceRange.min.amount !== priceRange.max.amount;
const discount =
priceRange.compareAtMax && product.isOnSale
? discountPercent(priceRange.min, priceRange.compareAtMax)
: 0;
return (
<article className="group relative">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{primaryImage ? (
<>
<Image
src={primaryImage.url}
alt={primaryImage.altText ?? product.name}
fill
// Matches the grid below so the browser never downloads a larger
// file than it renders.
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={cn(
'object-cover transition-[opacity,transform] duration-500 ease-[var(--ease-out-quint)]',
'group-hover:scale-[1.03]',
hoverImage && 'group-hover:opacity-0',
)}
placeholder={primaryImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={primaryImage.blurDataUrl ?? undefined}
// Only the first row is priority; marking everything priority is
// the same as marking nothing.
priority={priority}
/>
{hoverImage ? (
<Image
src={hoverImage.url}
alt=""
aria-hidden
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="scale-[1.03] object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
) : null}
</>
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-3 top-3">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
<div className="mt-3 space-y-1">
{product.brandName ? (
<p className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{product.brandName}
</p>
) : null}
<h3 className="text-ink-950 text-sm font-medium">
{/*
The link covers the whole card via ::after rather than wrapping it.
Wrapping put the colourway list inside the anchor, which made the
swatches part of the link's accessible name — a screen reader read
the product name followed by every colour.
*/}
<Link href={routes.product(product.slug)} className="after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<p className="flex items-baseline gap-2 text-sm">
<span className={cn('font-semibold', product.isOnSale && 'text-sale')}>
{hasRange ? `${t('from')} ` : ''}
{formatMoney(priceRange.min, format)}
</span>
{priceRange.compareAtMax && product.isOnSale ? (
<span className="text-ink-400 text-xs line-through">
{formatMoney(priceRange.compareAtMax, format)}
</span>
) : null}
</p>
</div>
{product.colorSwatches.length > 1 ? (
<ul className="mt-2 flex items-center gap-1.5">
{product.colorSwatches.slice(0, 5).map((swatch) => (
<li
key={swatch.optionValueId}
className="border-ink-200 size-3 rounded-full border"
style={{ backgroundColor: swatch.swatchHex ?? undefined }}
title={swatch.label}
/>
))}
{product.colorSwatches.length > 5 ? (
<li className="text-ink-400 text-[0.625rem]">+{product.colorSwatches.length - 5}</li>
) : null}
</ul>
) : null}
</article>
);
}
@@ -1,14 +1,16 @@
'use client'; 'use client';
import Image from 'next/image'; import { ShoppingBag } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl'; import { useFormatter, useTranslations } from 'next-intl';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types'; import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types';
import { Badge, Button, cn } from '@sport/ui'; import { Button, cn } from '@sport/ui';
import { discountPercent, formatMoney } from '@/lib/format'; import { discountPercent, formatMoney } from '@/lib/format';
import { ProductGallery } from './product-gallery';
/** /**
* The PDP interaction surface: gallery and variant selector, sharing one piece * The PDP interaction surface: gallery and variant selector, sharing one piece
* of state. * of state.
@@ -50,16 +52,26 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
return forColour.length > 0 ? forColour : product.images; return forColour.length > 0 ? forColour : product.images;
}, [product.images, selectedColourId]); }, [product.images, selectedColourId]);
const [activeImage, setActiveImage] = useState(0);
const currentImage = gallery[Math.min(activeImage, gallery.length - 1)] ?? null;
const price = selectedVariant?.effectivePrice ?? product.priceRange.min; const price = selectedVariant?.effectivePrice ?? product.priceRange.min;
const compareAt = selectedVariant?.compareAtPrice ?? product.priceRange.compareAtMax;
/**
* What to strike through, in priority order.
*
* A variant can be discounted two ways and both must show a reference price:
* `compareAtPrice` is the "was" price, and `salePrice` is a markdown off the
* variant's own `price`. Reading only `compareAtPrice` — as this did — left
* every sale set from the admin's variant grid rendering as a lone red number
* with nothing to compare it against.
*/
const compareAt =
selectedVariant?.compareAtPrice ??
(selectedVariant?.isOnSale ? selectedVariant.price : null) ??
product.priceRange.compareAtMax;
const discount = compareAt ? discountPercent(price, compareAt) : 0; const discount = compareAt ? discountPercent(price, compareAt) : 0;
function select(optionKey: string, optionValueId: string) { function select(optionKey: string, optionValueId: string) {
setSelection((current) => ({ ...current, [optionKey]: optionValueId })); setSelection((current) => ({ ...current, [optionKey]: optionValueId }));
if (optionKey === 'colour') setActiveImage(0);
} }
/** Values on this axis still reachable given the other choices. */ /** Values on this axis still reachable given the other choices. */
@@ -92,62 +104,16 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
return ( return (
<div className="grid gap-10 lg:grid-cols-2 lg:gap-16"> <div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
{/* ---- Gallery ---- */} <ProductGallery
<div className="space-y-3"> // Remounts on colourway change so Embla starts at the first photo of
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden"> // the new set rather than holding an index into the old one.
{currentImage ? ( key={selectedColourId ?? 'all'}
<Image images={gallery}
src={currentImage.url} productName={product.name}
alt={currentImage.altText ?? product.name} discount={discount}
fill saveLabel={t('save', { percent: discount })}
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
placeholder={currentImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={currentImage.blurDataUrl ?? undefined}
priority
/> />
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-4 top-4">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
{gallery.length > 1 ? (
<ul className="grid grid-cols-4 gap-3">
{gallery.map((image, index) => (
<li key={image.id}>
<button
type="button"
onClick={() => setActiveImage(index)}
aria-current={index === activeImage}
className={cn(
'bg-ink-100 relative block aspect-[4/5] w-full overflow-hidden border transition-colors',
index === activeImage
? 'border-ink-950'
: 'hover:border-ink-300 border-transparent',
)}
>
<Image
src={image.url}
alt=""
aria-hidden
fill
sizes="12vw"
className="object-cover"
/>
</button>
</li>
))}
</ul>
) : null}
</div>
{/* ---- Selector ---- */}
<div className="space-y-8 lg:pt-4"> <div className="space-y-8 lg:pt-4">
<div> <div>
{product.brand ? ( {product.brand ? (
@@ -162,13 +128,18 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
</div> </div>
<div className="flex flex-wrap items-baseline gap-3"> <div className="flex flex-wrap items-baseline gap-3">
<span className={cn('text-2xl font-semibold', selectedVariant?.isOnSale && 'text-sale')}> <span className={cn('text-2xl font-semibold', discount > 0 && 'text-sale')}>
{formatMoney(price, format)} {formatMoney(price, format)}
</span> </span>
{compareAt && discount > 0 ? ( {compareAt && discount > 0 ? (
<>
<span className="text-ink-400 text-base line-through"> <span className="text-ink-400 text-base line-through">
{formatMoney(compareAt, format)} {formatMoney(compareAt, format)}
</span> </span>
<span className="text-sale text-xs font-semibold uppercase tracking-widest">
{t('save', { percent: discount })}
</span>
</>
) : null} ) : null}
</div> </div>
@@ -201,7 +172,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
onClick={() => select(option.key, value.id)} onClick={() => select(option.key, value.id)}
title={value.label} title={value.label}
className={cn( className={cn(
'relative flex items-center justify-center border text-xs font-medium transition-colors', 'focus-visible:ring-ring/50 relative flex items-center justify-center border text-xs font-medium outline-none transition-colors focus-visible:ring-[3px]',
isColour ? 'size-10 rounded-full' : 'h-11 min-w-14 px-3 uppercase', isColour ? 'size-10 rounded-full' : 'h-11 min-w-14 px-3 uppercase',
selected selected
? 'border-ink-950 ring-ink-950 ring-1' ? 'border-ink-950 ring-ink-950 ring-1'
@@ -237,6 +208,10 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
!selectedVariant || selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK !selectedVariant || selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
} }
> >
{selectedVariant &&
selectedVariant.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK ? (
<ShoppingBag />
) : null}
{!selectedVariant {!selectedVariant
? t('selectSizePrompt') ? t('selectSizePrompt')
: selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK : selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
@@ -5,6 +5,7 @@ import type { ProductFacets } from '@sport/types';
import { cn } from '@sport/ui'; import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { buildListingHref } from '@/lib/listing-href';
/** /**
* Filter rail, rendered on the server as plain links. * Filter rail, rendered on the server as plain links.
@@ -18,10 +19,16 @@ export async function ProductFilters({
facets, facets,
basePath, basePath,
query, query,
showHeading = true,
}: { }: {
facets: ProductFacets; facets: ProductFacets;
basePath: string; basePath: string;
query: ProductListQuery; query: ProductListQuery;
/**
* Off inside the mobile sheet, which supplies its own title — otherwise the
* word "Filters" appears twice, once in the sheet header and once here.
*/
showHeading?: boolean;
}) { }) {
const t = await getTranslations('listing'); const t = await getTranslations('listing');
@@ -37,19 +44,26 @@ export async function ProductFilters({
} else { } else {
current.add(value); current.add(value);
} }
return buildHref(basePath, { ...query, [key]: [...current] }); return buildListingHref(basePath, { ...query, [key]: [...current] });
} }
return ( return (
<aside className="space-y-8"> <aside className="space-y-8">
{showHeading || hasActiveFilters ? (
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
{showHeading ? (
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('filters')}</h2> <h2 className="text-xs font-semibold uppercase tracking-widest">{t('filters')}</h2>
) : null}
{hasActiveFilters ? ( {hasActiveFilters ? (
<Link href={basePath} className="text-ink-500 text-xs underline underline-offset-4"> <Link
href={basePath}
className="text-ink-500 ml-auto text-xs underline underline-offset-4"
>
{t('clearAll')} {t('clearAll')}
</Link> </Link>
) : null} ) : null}
</div> </div>
) : null}
{facets.colors.length > 0 ? ( {facets.colors.length > 0 ? (
<section> <section>
@@ -63,7 +77,7 @@ export async function ProductFilters({
<li key={colour.value}> <li key={colour.value}>
<Link <Link
href={toggleHref('colors', colour.value)} href={toggleHref('colors', colour.value)}
aria-pressed={active} aria-current={active ? true : undefined}
title={`${colour.label} (${colour.count})`} title={`${colour.label} (${colour.count})`}
className={cn( className={cn(
'flex size-8 items-center justify-center rounded-full border transition-colors', 'flex size-8 items-center justify-center rounded-full border transition-colors',
@@ -94,7 +108,7 @@ export async function ProductFilters({
<li key={size.value}> <li key={size.value}>
<Link <Link
href={toggleHref('sizes', size.value)} href={toggleHref('sizes', size.value)}
aria-pressed={active} aria-current={active ? true : undefined}
className={cn( className={cn(
'flex h-9 min-w-11 items-center justify-center border px-2 text-xs font-medium uppercase transition-colors', 'flex h-9 min-w-11 items-center justify-center border px-2 text-xs font-medium uppercase transition-colors',
active active
@@ -123,7 +137,7 @@ export async function ProductFilters({
<li key={brand.value}> <li key={brand.value}>
<Link <Link
href={toggleHref('brandSlugs', brand.value)} href={toggleHref('brandSlugs', brand.value)}
aria-pressed={active} aria-current={active ? true : undefined}
className={cn( className={cn(
'flex items-baseline justify-between text-sm transition-colors', 'flex items-baseline justify-between text-sm transition-colors',
active ? 'text-ink-950 font-semibold' : 'text-ink-600 hover:text-ink-950', active ? 'text-ink-950 font-semibold' : 'text-ink-600 hover:text-ink-950',
@@ -141,8 +155,8 @@ export async function ProductFilters({
<section> <section>
<Link <Link
href={buildHref(basePath, { ...query, onSale: query.onSale ? undefined : true })} href={buildListingHref(basePath, { ...query, onSale: query.onSale ? undefined : true })}
aria-pressed={Boolean(query.onSale)} aria-current={query.onSale ? true : undefined}
className={cn( className={cn(
'inline-flex items-center gap-2 text-sm transition-colors', 'inline-flex items-center gap-2 text-sm transition-colors',
query.onSale ? 'text-sale font-semibold' : 'text-ink-600 hover:text-ink-950', query.onSale ? 'text-sale font-semibold' : 'text-ink-600 hover:text-ink-950',
@@ -157,25 +171,3 @@ export async function ProductFilters({
</aside> </aside>
); );
} }
/**
* Serialises a query back into a URL.
*
* Only presentation-level filters are emitted — `gender`, `sport`,
* `categorySlug` and `collectionSlug` are implied by the route itself, so
* repeating them in the query string would produce ugly, duplicate-content URLs
* like `/men?gender=MEN`.
*/
function buildHref(basePath: string, query: ProductListQuery): string {
const params = new URLSearchParams();
if (query.q) params.set('q', query.q);
if (query.colors?.length) params.set('colors', query.colors.join(','));
if (query.sizes?.length) params.set('sizes', query.sizes.join(','));
if (query.brandSlugs?.length) params.set('brandSlugs', query.brandSlugs.join(','));
if (query.onSale) params.set('onSale', 'true');
if (query.sort && query.sort !== 'newest') params.set('sort', query.sort);
const search = params.toString();
return search ? `${basePath}?${search}` : basePath;
}
@@ -0,0 +1,175 @@
'use client';
import useEmblaCarousel from 'embla-carousel-react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import type { ProductImage } from '@sport/types';
import { Badge, cn } from '@sport/ui';
/**
* The PDP gallery.
*
* Embla rather than a plain image swap, because the mobile behaviour a shopper
* expects here is a swipe, and reimplementing momentum, drag thresholds and
* snap points on top of a `useState` index is exactly the kind of work worth
* taking off the shelf.
*
* Thumbnails stay a plain grid rather than a second carousel — with four to
* eight images there is nothing to scroll, and a nested Embla would add two
* more event handlers for no gain.
*/
export function ProductGallery({
images,
productName,
discount,
saveLabel,
}: {
images: readonly ProductImage[];
productName: string;
discount: number;
saveLabel: string;
}) {
const t = useTranslations('product');
const [emblaRef, embla] = useEmblaCarousel({ loop: false, align: 'start', duration: 22 });
const [selected, setSelected] = useState(0);
const scrollTo = useCallback((index: number) => embla?.scrollTo(index), [embla]);
useEffect(() => {
if (!embla) return;
const sync = () => setSelected(embla.selectedScrollSnap());
sync();
embla.on('select', sync);
// `reInit` is what makes the colourway swap work: swapping to a colour with
// a different number of photos changes the slide count, and without this
// Embla keeps measuring the old set and refuses to scroll to the new ones.
embla.reInit();
return () => {
embla.off('select', sync);
};
}, [embla, images.length]);
if (images.length === 0) {
return <div className="bg-ink-200 aspect-[4/5] w-full" />;
}
const canPrev = selected > 0;
const canNext = selected < images.length - 1;
return (
<div className="space-y-3">
<div className="group relative">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex touch-pan-y">
{images.map((image, index) => (
<div key={image.id} className="min-w-0 flex-[0_0_100%]">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
<Image
src={image.url}
alt={image.altText ?? productName}
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
placeholder={image.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={image.blurDataUrl ?? undefined}
priority={index === 0}
/>
</div>
</div>
))}
</div>
</div>
{discount > 0 ? (
<Badge variant="sale" className="absolute left-4 top-4">
{saveLabel}
</Badge>
) : null}
{images.length > 1 ? (
<>
<GalleryArrow
side="left"
disabled={!canPrev}
label={t('previousImage')}
onClick={() => scrollTo(selected - 1)}
/>
<GalleryArrow
side="right"
disabled={!canNext}
label={t('nextImage')}
onClick={() => scrollTo(selected + 1)}
/>
</>
) : null}
</div>
{images.length > 1 ? (
<ul className="grid grid-cols-4 gap-3">
{images.map((image, index) => (
<li key={image.id}>
<button
type="button"
onClick={() => scrollTo(index)}
aria-label={t('goToImage', { index: index + 1 })}
aria-current={index === selected}
className={cn(
'bg-ink-100 focus-visible:ring-ring/50 relative block aspect-[4/5] w-full overflow-hidden border outline-none transition-colors focus-visible:ring-[3px]',
index === selected ? 'border-ink-950' : 'hover:border-ink-300 border-transparent',
)}
>
<Image
src={image.url}
alt=""
aria-hidden
fill
sizes="12vw"
className="object-cover"
/>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
function GalleryArrow({
side,
disabled,
label,
onClick,
}: {
side: 'left' | 'right';
disabled: boolean;
label: string;
onClick: () => void;
}) {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={cn(
'text-ink-950 focus-visible:ring-ring/50 absolute top-1/2 z-10 grid size-10 -translate-y-1/2 place-items-center bg-white/90 shadow-sm outline-none transition-opacity focus-visible:opacity-100 focus-visible:ring-[3px]',
// Hidden until hover on pointer devices — on touch the swipe is the
// affordance and a pair of floating arrows just covers the photograph.
'opacity-0 group-hover:opacity-100 max-lg:hidden',
'disabled:pointer-events-none disabled:opacity-0',
side === 'left' ? 'left-3' : 'right-3',
)}
>
<Icon className="size-5" />
</button>
);
}
@@ -0,0 +1,118 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { Locale } from '@sport/types';
import { fetchProducts } from '@/lib/catalog';
import { buildListingHref } from '@/lib/listing-href';
import { FilterSheet } from './filter-sheet';
import { LoadMore } from './load-more';
import { ProductFilters } from './product-filters';
import { ProductGrid } from './product-grid';
import { SortMenu } from './sort-menu';
/**
* One listing component behind /men, /women, /sports/*, /collections/* and
* /search.
*
* They are the same query with different presets, so they are the same
* component. Five near-identical page implementations is how filter behaviour
* starts drifting between routes.
*/
export async function ProductListing({
locale,
title,
eyebrow,
description,
query,
basePath,
}: {
locale: Locale;
title: string;
eyebrow?: string;
description?: string | null;
query: ProductListQuery;
basePath: string;
}) {
const t = await getTranslations('listing');
const result = await fetchProducts(locale, query);
// Drives the count badge on the mobile trigger, so the shopper can see a
// filter is active without opening the sheet to look for it.
const activeFilterCount =
(query.colors?.length ?? 0) +
(query.sizes?.length ?? 0) +
(query.brandSlugs?.length ?? 0) +
(query.onSale ? 1 : 0);
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="mb-10">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-2 text-4xl font-black uppercase sm:text-5xl">{title}</h1>
{description ? <p className="text-ink-500 mt-4 max-w-2xl text-sm">{description}</p> : null}
</header>
{/*
The count and the sort control share a row above the grid rather than
sitting in the header, so on mobile they land next to the filter trigger
instead of pushing the products another screen down.
*/}
<div className="border-ink-200 mb-6 flex items-center justify-between gap-4 border-b pb-4">
<p className="text-ink-400 text-xs uppercase tracking-widest">
{t('resultsCount', { count: result.totalCount })}
</p>
<SortMenu basePath={basePath} query={query} />
</div>
<div className="grid gap-6 lg:grid-cols-[16rem_1fr] lg:gap-10">
{/*
The same rail, mounted twice and shown at one breakpoint each. It is a
Server Component either way — `FilterSheet` only owns open/closed
state, so nothing about filtering moves to the browser.
*/}
<FilterSheet activeCount={activeFilterCount}>
<ProductFilters
facets={result.facets}
basePath={basePath}
query={query}
showHeading={false}
/>
</FilterSheet>
<div className="hidden lg:block">
<ProductFilters facets={result.facets} basePath={basePath} query={query} />
</div>
<div>
<ProductGrid products={result.items} />
{result.pageInfo.hasNextPage && result.pageInfo.nextCursor ? (
<LoadMore
/*
* Keyed on the listing identity so a sort or filter change
* remounts it.
*
* Changing the sort is a soft navigation: the server re-renders
* page 1 in the new order, but this component keeps its position
* in the React tree and therefore keeps the products it appended
* under the *old* query. The result was duplicates — the same
* product visible twice, once in each ordering.
*/
key={buildListingHref(basePath, query)}
locale={locale}
query={query}
basePath={basePath}
initialCursor={result.pageInfo.nextCursor}
initialCount={result.items.length}
totalCount={result.totalCount}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,119 @@
import { Search, ShoppingBag, User } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
import { MegaMenu, type MegaMenuEntry } from './mega-menu';
import { MobileNav } from './mobile-nav';
/**
* Site chrome. Stays a Server Component: only the mega menu, the mobile drawer
* and the language switcher are interactive, and each is mounted as its own
* small Client Component rather than turning the whole header into one.
*
* `navigation` comes from the API (categories and collections are database
* content, translated in the database). Sports come from the local message
* catalog, because they are a fixed enum in code. That split is deliberate and
* is documented in CategoriesService.getNavigation.
*/
export async function SiteHeader({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('nav');
const tSports = await getTranslations('sports');
const sportLinks = SPORT_NAV.map((sport) => ({
label: tSports(sport.slug),
href: routes.sport(sport.slug),
}));
/**
* Each top-level entry from the API gets its children as panel columns.
* Sports are appended as one entry of their own because they are a facet, not
* a category tree.
*/
const entries: MegaMenuEntry[] = [
...(navigation?.primary ?? []).map((item) => ({
id: item.id,
label: item.label,
href: item.href,
columns:
item.children && item.children.length > 0
? [
{
heading: item.label,
links: item.children.map((child) => ({ label: child.label, href: child.href })),
},
]
: [],
})),
{
id: 'sports',
label: t('sports'),
href: routes.sport(SPORT_NAV[0].slug),
columns: [{ heading: t('sports'), links: sportLinks }],
},
];
return (
<header className="border-ink-200 sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
<div className="max-w-page px-gutter mx-auto flex h-16 items-center gap-4">
<MobileNav entries={entries} />
<Link
href={routes.home()}
className="focus-visible:ring-ring/50 text-lg font-black uppercase tracking-tighter outline-none focus-visible:ring-[3px]"
>
Sport<span className="text-volt-600">.</span>
</Link>
<nav aria-label={t('ariaMain')} className="hidden lg:flex">
<MegaMenu entries={entries} />
</nav>
<div className="ml-auto flex items-center gap-1">
<LanguageSwitcher />
<HeaderAction href={routes.search()} label={t('search')}>
<Search className="size-5" />
</HeaderAction>
<HeaderAction href={routes.account()} label={t('account')} className="hidden sm:grid">
<User className="size-5" />
</HeaderAction>
<HeaderAction href={routes.cart()} label={t('cart')}>
<ShoppingBag className="size-5" />
</HeaderAction>
</div>
</div>
</header>
);
}
/**
* Icon-only, so the label moves to `aria-label` and a tooltip — the row has to
* survive a 360px viewport alongside the logo and the language switcher.
*/
function HeaderAction({
href,
label,
className,
children,
}: {
href: string;
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<Link
href={href}
aria-label={label}
title={label}
className={`hover:text-volt-600 focus-visible:ring-ring/50 grid size-10 place-items-center outline-none transition-colors focus-visible:ring-[3px] ${className ?? ''}`}
>
{children}
</Link>
);
}
@@ -0,0 +1,63 @@
'use client';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslations } from 'next-intl';
import type { ProductListQuery } from '@sport/api-client';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { PRODUCT_SORTS, buildListingHref } from '@/lib/listing-href';
/**
* Sort order, as a menu of links.
*
* Every item is a real `<a href>` to the same listing with `?sort=` set, which
* keeps sorting shareable, bookmarkable and back-button-correct — the same
* contract the filter rail already honours. The menu is a Client Component only
* because a dropdown needs open/closed state; the navigation itself is ordinary
* and works without the menu ever opening.
*
* Selecting a sort drops the cursor, because a position in one ordering means
* nothing in another.
*/
export function SortMenu({ basePath, query }: { basePath: string; query: ProductListQuery }) {
const t = useTranslations('listing');
const active = query.sort ?? 'newest';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="text-ink-400 hidden font-normal normal-case sm:inline">
{t('sortBy')}
</span>
{t(`sort.${active}`)}
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{PRODUCT_SORTS.map((sort) => (
<DropdownMenuItem key={sort} asChild>
<Link
href={buildListingHref(basePath, { ...query, sort })}
className="flex items-center justify-between gap-4"
>
{t(`sort.${sort}`)}
{/* Rendered but transparent when inactive, so the label position
does not shift as the selection moves down the list. */}
<Check className={sort === active ? 'opacity-100' : 'opacity-0'} />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,68 +0,0 @@
import { getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
/**
* Server Component. It renders no interactive state beyond the language
* switcher, so almost nothing ships to the browser — the mobile menu and cart
* badge will be small Client Components mounted inside it rather than turning
* the whole header into one.
*
* `navigation` comes from the API (categories and collections are database
* content, translated in the database). Sports come from the local message
* catalog, because they are a fixed enum in code. That split is deliberate and
* is documented in CategoriesService.getNavigation.
*/
export async function SiteHeader({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('nav');
const tSports = await getTranslations('sports');
return (
<header className="border-ink-200 sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
<div className="max-w-page px-gutter mx-auto flex h-16 items-center gap-6">
<Link href={routes.home()} className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</Link>
<nav aria-label={t('ariaMain')} className="hidden items-center gap-5 lg:flex">
{navigation?.primary.map((item) => (
<Link
key={item.id}
href={item.href}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
{item.label}
</Link>
))}
{SPORT_NAV.map((sport) => (
<Link
key={sport.slug}
href={routes.sport(sport.slug)}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
{tSports(sport.slug)}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-4 text-xs font-semibold uppercase tracking-widest">
<LanguageSwitcher />
<Link href={routes.search()} className="hover:text-volt-600">
{t('search')}
</Link>
<Link href={routes.account()} className="hover:text-volt-600 hidden sm:inline">
{t('account')}
</Link>
<Link href={routes.cart()} className="hover:text-volt-600">
{t('cart')}
</Link>
</div>
</div>
</header>
);
}
@@ -1,24 +0,0 @@
# feature: account
Profile, addresses and account settings.
## Structure
```
account/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: auth
Sign in, register, password reset, and the session store the rest of the app reads.
## Structure
```
auth/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: cart
Bag drawer and page, line-item mutations, optimistic quantity updates.
## Structure
```
cart/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: category
Category landing pages, breadcrumbs and the facet sidebar.
## Structure
```
category/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: checkout
Multi-step checkout: address, delivery, payment, review.
## Structure
```
checkout/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: collection
Campaign and editorial collection pages.
## Structure
```
collection/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: order
Order confirmation and order history views.
## Structure
```
order/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: product
PDP: gallery, variant selector, price display, add-to-bag. Owns `<ProductCard>`.
## Structure
```
product/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,121 +0,0 @@
import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { Badge, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { formatMoney, discountPercent } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* The grid card.
*
* Lives in `features/product/` rather than `@sport/ui` on purpose: it knows
* about sale badges, price ranges and colourways, none of which mean anything
* in the admin dashboard. See docs/architecture.md §4.
*/
export function ProductCard({
product,
priority = false,
}: {
product: ProductListItem;
priority?: boolean;
}) {
const t = useTranslations('product');
const format = useFormatter();
const { priceRange, primaryImage, hoverImage } = product;
const hasRange = priceRange.min.amount !== priceRange.max.amount;
const discount =
priceRange.compareAtMax && product.isOnSale
? discountPercent(priceRange.min, priceRange.compareAtMax)
: 0;
return (
<article className="group">
<Link href={routes.product(product.slug)} className="block">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{primaryImage ? (
<>
<Image
src={primaryImage.url}
alt={primaryImage.altText ?? product.name}
fill
// Three columns on desktop, two on tablet, one on mobile —
// matching the grid below so the browser never downloads a
// larger file than it renders.
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={cn(
'object-cover transition-opacity duration-500',
hoverImage && 'group-hover:opacity-0',
)}
placeholder={primaryImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={primaryImage.blurDataUrl ?? undefined}
// Only the first row is priority; marking everything priority
// is the same as marking nothing.
priority={priority}
/>
{hoverImage ? (
<Image
src={hoverImage.url}
alt=""
aria-hidden
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
) : null}
</>
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-3 top-3">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
<div className="mt-3 space-y-1">
{product.brandName ? (
<p className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{product.brandName}
</p>
) : null}
<h3 className="text-ink-950 text-sm font-medium">{product.name}</h3>
<p className="flex items-baseline gap-2 text-sm">
<span className={cn('font-semibold', product.isOnSale && 'text-sale')}>
{hasRange ? `${t('from')} ` : ''}
{formatMoney(priceRange.min, format)}
</span>
{priceRange.compareAtMax && product.isOnSale ? (
<span className="text-ink-400 text-xs line-through">
{formatMoney(priceRange.compareAtMax, format)}
</span>
) : null}
</p>
</div>
</Link>
{product.colorSwatches.length > 1 ? (
<ul className="mt-2 flex items-center gap-1.5" aria-label={t('selectColour')}>
{product.colorSwatches.slice(0, 5).map((swatch) => (
<li
key={swatch.optionValueId}
className="border-ink-200 size-3 rounded-full border"
style={{ backgroundColor: swatch.swatchHex ?? undefined }}
title={swatch.label}
/>
))}
{product.colorSwatches.length > 5 ? (
<li className="text-ink-400 text-[0.625rem]">+{product.colorSwatches.length - 5}</li>
) : null}
</ul>
) : null}
</article>
);
}
@@ -1,56 +0,0 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { Locale } from '@sport/types';
import { fetchProducts } from '@/features/product/services/catalog';
import { ProductFilters } from './product-filters';
import { ProductGrid } from './product-grid';
/**
* One listing component behind /men, /women, /sports/*, /collections/* and
* /search.
*
* They are the same query with different presets, so they are the same
* component. Five near-identical page implementations is how filter behaviour
* starts drifting between routes.
*/
export async function ProductListing({
locale,
title,
eyebrow,
description,
query,
basePath,
}: {
locale: Locale;
title: string;
eyebrow?: string;
description?: string | null;
query: ProductListQuery;
basePath: string;
}) {
const t = await getTranslations('listing');
const result = await fetchProducts(locale, query);
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="mb-10">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-2 text-4xl font-black uppercase sm:text-5xl">{title}</h1>
{description ? <p className="text-ink-500 mt-4 max-w-2xl text-sm">{description}</p> : null}
<p className="text-ink-400 mt-4 text-xs uppercase tracking-widest">
{t('resultsCount', { count: result.totalCount })}
</p>
</header>
<div className="grid gap-10 lg:grid-cols-[16rem_1fr]">
<ProductFilters facets={result.facets} basePath={basePath} query={query} />
<ProductGrid products={result.items} />
</div>
</div>
);
}
@@ -1,24 +0,0 @@
# feature: search
Search input, suggestions, results and the shared filter state.
## Structure
```
search/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: wishlist
Save-for-later toggles and the wishlist page.
## Structure
```
wishlist/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
+47
View File
@@ -0,0 +1,47 @@
import type { ProductListQuery } from '@sport/api-client';
/**
* Serialises a listing query back into a URL.
*
* Shared by the filter rail, the sort menu and the "load more" link so the
* three cannot disagree about what a listing URL looks like. They used to build
* hrefs independently, which is how sorting silently drops the filters you had
* applied.
*
* Only presentation-level filters are emitted — `gender`, `sport`,
* `categorySlug` and `collectionSlug` are implied by the route itself, so
* repeating them in the query string would produce ugly, duplicate-content URLs
* like `/men?gender=MEN`.
*
* `cursor` is deliberately opt-in. Changing a filter or the sort order
* invalidates any position in the result set, so those callers omit it and the
* listing restarts from the first page.
*/
export function buildListingHref(
basePath: string,
query: ProductListQuery,
extra?: { cursor?: string | null },
): string {
const params = new URLSearchParams();
if (query.q) params.set('q', query.q);
if (query.colors?.length) params.set('colors', query.colors.join(','));
if (query.sizes?.length) params.set('sizes', query.sizes.join(','));
if (query.brandSlugs?.length) params.set('brandSlugs', query.brandSlugs.join(','));
if (query.onSale) params.set('onSale', 'true');
if (query.sort && query.sort !== 'newest') params.set('sort', query.sort);
if (extra?.cursor) params.set('cursor', extra.cursor);
const search = params.toString();
return search ? `${basePath}?${search}` : basePath;
}
export const PRODUCT_SORTS = [
'newest',
'price_asc',
'price_desc',
'best_selling',
'relevance',
] as const;
export type ProductSort = (typeof PRODUCT_SORTS)[number];
+4
View File
@@ -21,6 +21,10 @@ export function parseListingParams(params: RawSearchParams): ProductListQuery {
categorySlug: single(params['category']), categorySlug: single(params['category']),
onSale: single(params['onSale']) === 'true' ? true : undefined, onSale: single(params['onSale']) === 'true' ? true : undefined,
sort: parseSort(single(params['sort'])), sort: parseSort(single(params['sort'])),
// Present only when someone followed the "load more" link without
// JavaScript, or a crawler did. The client path appends in place and never
// puts a cursor in the address bar.
cursor: single(params['cursor']),
}; };
} }
+7 -2
View File
@@ -58,7 +58,9 @@
"price": "Price", "price": "Price",
"onSale": "On sale only", "onSale": "On sale only",
"inStock": "In stock only" "inStock": "In stock only"
} },
"showing": "Showing {shown} of {total}",
"loadFailed": "Could not load more products."
}, },
"product": { "product": {
"selectColour": "Colour", "selectColour": "Colour",
@@ -77,7 +79,10 @@
"new": "New", "new": "New",
"from": "From", "from": "From",
"notAvailable": "This combination is not available", "notAvailable": "This combination is not available",
"comingSoon": "Add to bag arrives with the cart milestone." "comingSoon": "Add to bag arrives with the cart milestone.",
"previousImage": "Previous image",
"nextImage": "Next image",
"goToImage": "Go to image {index}"
}, },
"search": { "search": {
"title": "Search", "title": "Search",
+7 -2
View File
@@ -58,7 +58,9 @@
"price": "Giá", "price": "Giá",
"onSale": "Chỉ hàng giảm giá", "onSale": "Chỉ hàng giảm giá",
"inStock": "Chỉ hàng còn sẵn" "inStock": "Chỉ hàng còn sẵn"
} },
"showing": "Đang xem {shown} trên {total}",
"loadFailed": "Không tải thêm được sản phẩm."
}, },
"product": { "product": {
"selectColour": "Màu sắc", "selectColour": "Màu sắc",
@@ -77,7 +79,10 @@
"new": "Mới", "new": "Mới",
"from": "Từ", "from": "Từ",
"notAvailable": "Phiên bản này không có sẵn", "notAvailable": "Phiên bản này không có sẵn",
"comingSoon": "Chức năng thêm vào giỏ sẽ có ở giai đoạn giỏ hàng." "comingSoon": "Chức năng thêm vào giỏ sẽ có ở giai đoạn giỏ hàng.",
"previousImage": "Ảnh trước",
"nextImage": "Ảnh sau",
"goToImage": "Xem ảnh {index}"
}, },
"search": { "search": {
"title": "Tìm kiếm", "title": "Tìm kiếm",
+4
View File
@@ -1,5 +1,9 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@sport/config/tailwind/theme.css'; @import '@sport/config/tailwind/theme.css';
/* Supplies the enter/exit utilities Radix-driven overlays animate with
(`animate-in`, `fade-in-0`, `slide-in-from-right`). Registry components
assume these exist; without it Dialog and Sheet appear instantly. */
@import 'tw-animate-css';
/* Tailwind v4 scans the importing app by default; workspace packages must be /* Tailwind v4 scans the importing app by default; workspace packages must be
registered explicitly or their utility classes get purged. */ registered explicitly or their utility classes get purged. */
@@ -0,0 +1,71 @@
# ADR-0016: Option values are retained when variants reference them
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
A merchandiser removes the "Red" colourway from a product. What should happen to
the three Red variants and to the Red option value itself?
Three things reference them and each has a different claim:
- **Order history** — an order line points at a variant id, and reading that
order later needs "Red / M" to still mean something.
- **The variant matrix** — Red must stop generating combinations.
- **The storefront** — a shopper must not see a Red swatch that can never be
selected.
The database already takes a position: `ProductVariantOptionValue.optionValue`
is `onDelete: Restrict`, precisely so that deleting a value cannot silently
orphan or cascade into history.
## Decision
**Variants are archived, never deleted.** A removed combination sets
`status = ARCHIVED`. The row, its SKU and its option links all survive, so an
order placed last month still resolves.
**Option values are deleted only when nothing references them.** If any variant
— active or archived — still links to a value, the value is _retained_. It stops
appearing in the matrix and stops being offered, but it continues to exist so the
archived variants remain readable.
**The matrix is built from the operator's submitted option set, not from the
database.** Retained values would otherwise regenerate the very variants that
were just archived.
**The storefront filters option values to those offered by at least one active
variant.** Without this last step, a retained value renders as a permanently
disabled swatch with no explanation.
## Consequences
Order history stays intact and readable, which is the constraint that drove
everything else. Re-adding a removed colourway later reuses the retained value
and its translations rather than creating a duplicate.
The cost is that `product_option_values` accumulates rows that are invisible to
shoppers, and "delete" is not always literally a delete — a merchandiser who
inspects the database will find values they thought they removed. The schema
comment and this ADR are the mitigation; a future admin screen could surface
retained values explicitly.
This was found the hard way: the first implementation deleted values before
archiving variants and hit the `Restrict` constraint as a 500. The failure was
the schema doing its job.
## Alternatives considered
**Cascade the delete.** Removes the option value and its variant links, which
either breaks order lines or cascades into them. Rejected — this is the outcome
`Restrict` exists to prevent.
**Soft-delete option values with an `isActive` column.** Functionally similar to
retention, but adds a column and a filter to every catalog query for a case that
is already handled by "is any active variant offering this?". Rejected as
redundant state.
**Refuse to remove a value that has variants.** Simple and safe, but forces the
merchandiser to archive six variants by hand before they can drop a colourway —
pushing bookkeeping onto the person the tool exists to help.
@@ -0,0 +1,90 @@
# ADR-0017: shadcn/ui for infrastructure, hand-built for brand
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
The component layer started as four hand-rolled primitives in `@sport/ui`
(Button, Input, Badge, Skeleton). That was enough while the surface was static
text and links. It stopped being enough the moment real interaction arrived: the
mobile filter panel needs focus trapping and scroll locking, the product editor
needs tabs, row actions need a menu, and the media picker needs a modal.
Writing those by hand means writing focus management, `aria-modal` wiring,
escape handling, portal placement and collision detection. That work is
well-understood, easy to get subtly wrong, and worth nothing to this store's
customers — nobody chooses a running jacket because the dialog traps focus
correctly. They only notice when it doesn't.
The opposite is true of the product card, the header, the hero and the PDP.
Those _are_ the store. A generic card component would make them look like every
other template on the internet, which is precisely the outcome the visual
direction exists to avoid.
## Decision
**Two tiers, split on whether a component carries brand.**
`packages/ui/src/components/ui/` holds shadcn/ui components, owned as source in
this repo and shared by the storefront and the admin: Button, Input, Dialog,
Sheet, DropdownMenu, Tabs, plus Badge and Skeleton. Registry components are
copied in essentially unedited so that anything else from shadcn drops in later.
`apps/storefront/src/components/commerce/` holds everything that carries the
brand, built by hand: hero, mega menu, site header, product card, product
gallery, product detail, filter sheet. These are storefront-only and are never
promoted to `@sport/ui`, per the existing rule in architecture §4.
**A semantic token layer adapts shadcn to the palette.** shadcn writes against
role tokens (`--background`, `--primary`, `--ring`, `--radius`); the store's
palette is `ink`/`volt`. `packages/config/tailwind/theme.css` maps one onto the
other, so a registry component already looks like this store before it is
touched. Note that shadcn's `--accent` is a hover surface, not a brand accent —
it maps to `ink-100`, and volt stays applied deliberately.
**Supporting libraries, each for one job.** Lucide for icons, Motion for the
hero's entrance, Embla for the PDP gallery's swipe.
## Consequences
The dialog/sheet/menu behaviour we would otherwise have written badly is now
correct and not our problem. `asChild` removed a real defect class: `<Link>`
wrapping `<Button>` was producing `<a><button>`, which is invalid HTML and
announces as two nested controls.
Registry components import `@/lib/utils` and `@/components/ui/*`. A workspace
package cannot rely on an app's tsconfig aliases, so those imports are rewritten
to relative paths on the way in. That is a one-line edit per component and is
documented in the `@sport/ui` barrel.
Two registry defaults had to be overridden, both because they assume a light
page: `outline` shipped with `bg-background`, which rendered a white button with
white text on the black hero, and the base sizes are rounded and lowercase where
this brand is square and uppercase. Owning the source is what made those
one-line fixes rather than a fight.
The cost is a larger dependency surface — `radix-ui`, `lucide-react`, `motion`,
`embla-carousel-react`, `tw-animate-css` — and a standing obligation to keep
copied components in step with upstream by choice rather than by `npm update`.
## Alternatives considered
**Keep hand-rolling everything.** Total control, and the existing primitives were
good. Rejected once the list of things to build became "focus trap, scroll lock,
portal, collision detection" — that is a component library, and writing one is
not this project.
**Adopt a full component library (MUI, Mantine, Chakra).** Faster to start and
far harder to make look like anything but itself. The visual direction here is
the deliverable, and fighting a theme system to reach it is worse than building
the branded parts by hand.
**Use shadcn for everything, including the product card and header.** This is the
common failure mode. It produces a store that works correctly and looks like a
demo. The split above exists precisely to prevent it.
**Per-app copies of the shadcn layer instead of a shared package.** Matches
shadcn's single-app default and lets the two apps drift freely. Rejected because
Button and Input genuinely are identical in both, and two copies means two
places to fix the next `bg-background` problem.
+2
View File
@@ -24,6 +24,8 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted | | [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted | | [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
| [0015](./0015-frontends-reach-the-api-through-their-own-origin.md) | Frontends reach the API through their own origin | Accepted | | [0015](./0015-frontends-reach-the-api-through-their-own-origin.md) | Frontends reach the API through their own origin | Accepted |
| [0016](./0016-option-values-are-retained-when-variants-reference-them.md) | Option values are retained when variants reference them | Accepted |
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
## Decisions deliberately NOT recorded yet ## Decisions deliberately NOT recorded yet
+44 -4
View File
@@ -102,7 +102,9 @@ a framework, one of those consumers breaks.
- Domain types and API contracts (`@sport/types`) - Domain types and API contracts (`@sport/types`)
- Input shape/format rules (`@sport/validation`) - Input shape/format rules (`@sport/validation`)
- API access (`@sport/api-client`) - API access (`@sport/api-client`)
- Design-system primitives: Button, Input, Badge, Skeleton (`@sport/ui`) - UI infrastructure: Button, Input, Dialog, Sheet, DropdownMenu, Tabs, Badge,
Skeleton (`@sport/ui`, shadcn/ui owned as source — see
[ADR-0017](./adr/0017-shadcn-for-infrastructure-hand-built-for-brand.md))
- Build configuration (`@sport/config`, `@sport/eslint-config`) - Build configuration (`@sport/config`, `@sport/eslint-config`)
**Do not share** — things that only look shareable: **Do not share** — things that only look shareable:
@@ -110,7 +112,10 @@ a framework, one of those consumers breaks.
- **Domain components.** `<ProductCard>` knows about sale badges, price ranges and colour - **Domain components.** `<ProductCard>` knows about sale badges, price ranges and colour
swatches. It belongs to the storefront. The admin's product row needs status, stock and swatches. It belongs to the storefront. The admin's product row needs status, stock and
margin. Merging them produces a component with fourteen props and two consumers who both margin. Merging them produces a component with fourteen props and two consumers who both
fight it. fight it. These live in `apps/storefront/src/components/commerce/` — hero, mega menu, site
header, product card, product gallery, product detail, filter sheet — and are built by hand
because they _are_ the brand. The dividing line: infrastructure comes from the registry,
identity is written here.
- **Business logic.** It lives in the API. A discount calculated in a shared package is a - **Business logic.** It lives in the API. A discount calculated in a shared package is a
discount that can disagree with the invoice. discount that can disagree with the invoice.
- **App state.** Cart, auth session and filter state are app-specific. Shared stores create - **App state.** Cart, auth session and filter state are app-specific. Shared stores create
@@ -419,7 +424,7 @@ Places where the instinct to generalise should be resisted until a second real c
## 14. Architectural risks to prevent from day one ## 14. Architectural risks to prevent from day one
| Risk | Why it is fatal later | Prevention in place | | Risk | Why it is fatal later | Prevention in place |
| ------------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ---------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) | | Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) | | Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) | | Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
@@ -435,6 +440,10 @@ Places where the instinct to generalise should be resisted until a second real c
| Browser-only failures passing every check | Server rendering and curl both succeed while every click fails | Client paths must be exercised in a real browser before a milestone closes | | Browser-only failures passing every check | Server rendering and curl both succeed while every click fails | Client paths must be exercised in a real browser before a milestone closes |
| `onUnauthorized` retrying the refresh call | Unbounded refresh loop hammering the API from the browser | `skipAuthRetry` on all auth endpoints plus a single-flight refresh | | `onUnauthorized` retrying the refresh call | Unbounded refresh loop hammering the API from the browser | `skipAuthRetry` on all auth endpoints plus a single-flight refresh |
| Concurrent 401s each rotating the token | The second rotation reads as token reuse and revokes the family | One shared in-flight refresh promise | | Concurrent 401s each rotating the token | The second rotation reads as token reuse and revokes the family | One shared in-flight refresh promise |
| Editing options wiping variant pricing | A merchandiser's price and stock work vanishes on a cosmetic edit | Order-independent combination signatures; `planVariantMatrix` is pure and tested ([ADR-0016](./adr/0016-option-values-are-retained-when-variants-reference-them.md)) |
| Deleting an option value referenced by history | Breaks or cascades into past orders | `Restrict` FK plus retain-if-referenced ([ADR-0016](./adr/0016-option-values-are-retained-when-variants-reference-them.md)) |
| A catalog write not invalidating the cache | Edits appear not to work, so operators repeat them | Every write path ends in `afterWrite` → `deleteByPrefix('catalog:')` |
| Stock edited outside the ledger | "Why is this number wrong?" becomes unanswerable | Stock is not settable on the variant endpoint; it moves only through inventory |
--- ---
@@ -456,19 +465,50 @@ interactions have been clicked in a real browser**, with the console and network
Regression tests now cover the first two (`packages/api-client/src/http-client.spec.ts`); the Regression tests now cover the first two (`packages/api-client/src/http-client.spec.ts`); the
third belongs in an end-to-end test when one exists. third belongs in an end-to-end test when one exists.
Authoring the M3 editor added a second rule. Two defects survived a green pipeline and a
successful-looking click-through, and both needed the _second_ step of a flow to appear:
- The variant grid saved correctly, showed "saved", and left every row marked dirty. It compared
its draft against a `product` prop the editor shell never refreshed, so a save could not be seen
by the thing measuring whether one was needed. One save looked fine; it was saving _twice_ that
exposed it. The shell now owns the product and every tab hands its result back.
- The inventory screen listed `StockLevel`, which is a projection of the ledger. A variant that has
never moved has no row — so freshly created variants were invisible there, and since that screen
is the only route to an adjustment, they could never be given stock at all. Every seeded product
had stock already, so the whole catalog looked healthy. Only a product created from scratch
showed it.
M4's listing controls added a third variant of the same lesson. Sorting a listing _after_
loading more products showed the same product twice — once from the freshly sorted first page
and once left over from the products appended under the previous order. `LoadMore` keeps its
position in the React tree across a soft navigation, so its `useState` survived a query change
the server had already acted on. Neither action alone revealed anything; only the pair did. It
is keyed on the listing identity now.
The same pass caught a link built with a raw `<a href>` instead of the locale-aware `Link`, which
sent an English shopper following "load more" to the Vietnamese listing — invisible in the
default locale, which is exactly why it survived.
So: **exercise a flow on data you just created, not only on seeded data, perform each action
twice, and do it in a non-default locale.** Seed data has been through every code path already; new data has been through none. The
inventory case is pinned in `apps/api/src/modules/inventory/inventory-list.spec.ts`.
--- ---
## 16. Deliberate limitations ## 16. Deliberate limitations
Stated plainly so they are choices rather than oversights. Stated plainly so they are choices rather than oversights.
**Shipped (M0–M2)** **Shipped (M0–M3)**
- Catalog reads: products, variants, options, categories, collections, brands, navigation — - Catalog reads: products, variants, options, categories, collections, brands, navigation —
localised, cached, filtered and faceted. localised, cached, filtered and faceted.
- Storefront browsing and PDP in Vietnamese and English, with per-locale slugs and `hreflang`. - Storefront browsing and PDP in Vietnamese and English, with per-locale slugs and `hreflang`.
- Auth: login, refresh rotation with reuse detection, audience separation, login throttling. - Auth: login, refresh rotation with reuse detection, audience separation, login throttling.
- RBAC enforcement end to end, plus user administration and a role viewer in the admin. - RBAC enforcement end to end, plus user administration and a role viewer in the admin.
- Admin catalog authoring: product create/edit with per-locale content tabs, an option builder that
regenerates the variant matrix, per-variant SKU and pricing, imagery assigned per colourway,
media uploaded straight to storage, and stock received through the append-only ledger.
**Not built yet, and why** **Not built yet, and why**
+3
View File
@@ -2,6 +2,7 @@ import { HttpClient, type HttpClientOptions } from './http-client';
import { createAdminResource, type AdminResource } from './resources/admin'; import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth'; import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog'; import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createCatalogAdminResource, type CatalogAdminResource } from './resources/catalog-admin';
import { createHealthResource, type HealthResource } from './resources/health'; import { createHealthResource, type HealthResource } from './resources/health';
/** /**
@@ -16,6 +17,7 @@ export interface ApiClient {
readonly catalog: CatalogResource; readonly catalog: CatalogResource;
readonly auth: AuthResource; readonly auth: AuthResource;
readonly admin: AdminResource; readonly admin: AdminResource;
readonly catalogAdmin: CatalogAdminResource;
} }
export function createApiClient(options: HttpClientOptions): ApiClient { export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -27,5 +29,6 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
catalog: createCatalogResource(http), catalog: createCatalogResource(http),
auth: createAuthResource(http), auth: createAuthResource(http),
admin: createAdminResource(http), admin: createAdminResource(http),
catalogAdmin: createCatalogAdminResource(http),
}; };
} }
+6
View File
@@ -24,4 +24,10 @@ export type {
UpdateUserPayload, UpdateUserPayload,
UserListParams, UserListParams,
} from './resources/admin'; } from './resources/admin';
export type {
AdminProductListParams,
CatalogAdminResource,
StockAdjustment,
VariantPatch,
} from './resources/catalog-admin';
export type { ApiClient } from './create-client'; export type { ApiClient } from './create-client';
@@ -0,0 +1,209 @@
import type {
AdminProductDetail,
AdminProductListItem,
InventoryLevel,
MediaAssetSummary,
OffsetPaginated,
PresignedUploadTarget,
StockMovementEntry,
} from '@sport/types';
import { ApiClientError } from '../errors';
import type { HttpClient } from '../http-client';
export interface AdminProductListParams {
page?: number;
perPage?: number;
q?: string;
status?: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
brandId?: string;
}
export interface VariantPatch {
id: string;
sku?: string;
priceAmount?: number;
salePriceAmount?: number | null;
compareAtAmount?: number | null;
costAmount?: number | null;
weightGrams?: number | null;
status?: 'ACTIVE' | 'ARCHIVED';
}
export interface StockAdjustment {
variantId: string;
reason:
| 'PURCHASE_RECEIPT'
| 'RETURN'
| 'MANUAL_ADJUSTMENT'
| 'STOCK_TAKE'
| 'TRANSFER_IN'
| 'TRANSFER_OUT'
| 'DAMAGE';
quantityDelta?: number;
countedQuantity?: number;
note?: string | null;
}
export interface CatalogAdminResource {
listProducts(params?: AdminProductListParams): Promise<OffsetPaginated<AdminProductListItem>>;
getProduct(id: string): Promise<AdminProductDetail>;
createProduct(payload: unknown): Promise<AdminProductDetail>;
updateProduct(id: string, payload: unknown): Promise<AdminProductDetail>;
updateVariants(id: string, variants: VariantPatch[]): Promise<AdminProductDetail>;
setImages(
id: string,
images: { mediaId: string; position: number; optionValueId?: string | null }[],
): Promise<AdminProductDetail>;
setStatus(id: string, status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'): Promise<AdminProductDetail>;
listMedia(params?: {
page?: number;
perPage?: number;
q?: string;
}): Promise<OffsetPaginated<MediaAssetSummary>>;
deleteMedia(id: string): Promise<void>;
/** Presign → PUT to storage → register. See `uploadFile`. */
uploadFile(file: File, prefix?: string): Promise<MediaAssetSummary>;
listInventory(params?: {
page?: number;
perPage?: number;
q?: string;
lowStockOnly?: boolean;
}): Promise<OffsetPaginated<InventoryLevel>>;
adjustStock(payload: StockAdjustment): Promise<InventoryLevel>;
movements(variantId: string): Promise<StockMovementEntry[]>;
}
export function createCatalogAdminResource(http: HttpClient): CatalogAdminResource {
const uncached = { cache: 'no-store' } as const;
return {
listProducts: (params = {}) =>
http.get<OffsetPaginated<AdminProductListItem>>('/admin/products', {
...uncached,
query: { ...params },
}),
getProduct: (id) =>
http.get<AdminProductDetail>(`/admin/products/${encodeURIComponent(id)}`, uncached),
createProduct: (payload) => http.post<AdminProductDetail>('/admin/products', payload, uncached),
updateProduct: (id, payload) =>
http.patch<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}`,
payload,
uncached,
),
updateVariants: (id, variants) =>
http.put<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}/variants`,
{ variants },
uncached,
),
setImages: (id, images) =>
http.put<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}/images`,
{ images },
uncached,
),
setStatus: (id, status) =>
http.post<AdminProductDetail>(
`/admin/products/${encodeURIComponent(id)}/status`,
{ status },
uncached,
),
listMedia: (params = {}) =>
http.get<OffsetPaginated<MediaAssetSummary>>('/admin/media', {
...uncached,
query: { ...params },
}),
deleteMedia: (id) => http.delete<void>(`/admin/media/${encodeURIComponent(id)}`, uncached),
/**
* Three steps, deliberately: ask for a target, PUT the bytes straight to
* object storage, then tell the API the asset exists.
*
* The middle step is a bare `fetch` rather than an api-client call — it
* targets the storage host, not the API, so it must not carry our auth
* header, our envelope handling or our base URL.
*/
uploadFile: async (file, prefix = 'products') => {
const target = await http.post<PresignedUploadTarget>(
'/admin/media/presign',
{
filename: file.name,
mimeType: file.type,
sizeBytes: file.size,
prefix,
},
uncached,
);
const upload = await globalThis.fetch(target.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
// No cookies to the storage host — the presigned URL is the credential.
credentials: 'omit',
});
if (!upload.ok) {
throw new ApiClientError({
code: 'UPLOAD_REJECTED',
message: `Storage rejected the upload (${upload.status}).`,
status: upload.status,
});
}
const dimensions = await readImageSize(file);
return http.post<MediaAssetSummary>(
'/admin/media',
{
storageKey: target.storageKey,
mimeType: file.type,
sizeBytes: file.size,
width: dimensions?.width ?? null,
height: dimensions?.height ?? null,
altText: file.name.replace(/\.[^.]+$/, ''),
},
uncached,
);
},
listInventory: (params = {}) =>
http.get<OffsetPaginated<InventoryLevel>>('/admin/inventory', {
...uncached,
query: { ...params },
}),
adjustStock: (payload) =>
http.post<InventoryLevel>('/admin/inventory/adjust', payload, uncached),
movements: (variantId) =>
http.get<StockMovementEntry[]>(
`/admin/inventory/movements/${encodeURIComponent(variantId)}`,
uncached,
),
};
}
/**
* Reads intrinsic dimensions in the browser so the API does not have to decode
* the image. Resolves null on anything that is not a decodable image — a
* missing width is a cosmetic loss (no `<Image>` aspect ratio), never a failed
* upload.
*/
async function readImageSize(file: File): Promise<{ width: number; height: number } | null> {
if (!file.type.startsWith('image/') || typeof createImageBitmap !== 'function') {
return null;
}
try {
const bitmap = await createImageBitmap(file);
const size = { width: bitmap.width, height: bitmap.height };
bitmap.close();
return size;
} catch {
return null;
}
}
+78
View File
@@ -60,6 +60,84 @@
--animate-rise: rise 0.5s var(--ease-out-quint) both; --animate-rise: rise 0.5s var(--ease-out-quint) both;
} }
/**
* ---- shadcn/ui semantic layer --------------------------------------------
*
* shadcn components are written against role tokens (`bg-background`,
* `text-muted-foreground`, `border-input`, `ring-ring`) rather than against a
* palette. This block is the adapter: it gives those roles values drawn from
* the ink/volt scales above, so an unmodified component pasted from the
* registry already looks like this store.
*
* ONE TRAP WORTH NAMING: shadcn's `--accent` is not a brand accent. It is the
* subtle surface behind a hovered ghost button or a highlighted menu row. It
* maps to ink-100. The brand accent stays `--color-volt-*` and is applied
* deliberately — mapping volt here would turn every hover state neon.
*
* The store is committed to a light palette (`color-scheme: light`), so there
* is no `.dark` block. shadcn's `dark:` utilities simply never match, which is
* harmless and keeps the registry components paste-able unedited.
*/
:root {
--background: var(--color-white);
--foreground: var(--color-ink-950);
--card: var(--color-white);
--card-foreground: var(--color-ink-950);
--popover: var(--color-white);
--popover-foreground: var(--color-ink-950);
--primary: var(--color-ink-950);
--primary-foreground: var(--color-white);
--secondary: var(--color-ink-100);
--secondary-foreground: var(--color-ink-950);
--muted: var(--color-ink-100);
--muted-foreground: var(--color-ink-500);
/* Hover/active surface — see the trap noted above. */
--accent: var(--color-ink-100);
--accent-foreground: var(--color-ink-950);
--destructive: var(--color-danger);
--destructive-foreground: var(--color-white);
--border: var(--color-ink-200);
--input: var(--color-ink-200);
--ring: var(--color-ink-950);
/* Sport/streetwear geometry: near-square. Raising this rounds every
registry component at once, which is the point of keeping it here. */
--radius: 0.25rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 6px);
}
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
+178
View File
@@ -0,0 +1,178 @@
import type { Locale } from '../i18n/locale';
import type { Id, IsoDateTime, Money, Nullable, Slug } from '../primitives';
import type { MediaKind } from './media';
import type { GenderTarget, ProductStatus, SportType } from './product';
import type { VariantStatus } from './variant';
/**
* Admin-facing catalog shapes.
*
* Deliberately separate from the storefront types. The storefront gets one
* resolved locale and no cost price; the admin needs *every* locale at once and
* the commercially sensitive fields. Serving one shape to both would mean
* either leaking margin to shoppers or hiding it from merchandisers.
*/
/** Per-locale content for one entity, keyed by locale. */
export type TranslationMap<T> = Readonly<Partial<Record<Locale, T>>>;
export interface ProductTranslationFields {
readonly name: string;
readonly slug: Slug;
readonly shortDescription: Nullable<string>;
readonly description: Nullable<string>;
readonly metaTitle: Nullable<string>;
readonly metaDescription: Nullable<string>;
}
/** Row in the admin product table. */
export interface AdminProductListItem {
readonly id: Id;
readonly name: string;
readonly slug: Slug;
readonly status: ProductStatus;
readonly brandName: Nullable<string>;
readonly thumbnailUrl: Nullable<string>;
readonly variantCount: number;
/** Sum of available stock across every active variant. */
readonly totalStock: number;
readonly priceRange: Nullable<{ readonly min: Money; readonly max: Money }>;
readonly isOnSale: boolean;
readonly publishedAt: Nullable<IsoDateTime>;
readonly updatedAt: IsoDateTime;
}
/** Everything the product editor needs, in every locale. */
export interface AdminProductDetail {
readonly id: Id;
readonly status: ProductStatus;
readonly publishedAt: Nullable<IsoDateTime>;
readonly brandId: Nullable<Id>;
readonly primaryCategoryId: Nullable<Id>;
readonly genderTargets: readonly GenderTarget[];
readonly sportTypes: readonly SportType[];
readonly collectionIds: readonly Id[];
readonly translations: TranslationMap<ProductTranslationFields>;
readonly options: readonly AdminProductOption[];
readonly variants: readonly AdminProductVariant[];
readonly images: readonly AdminProductImage[];
readonly attributes: readonly AdminProductAttribute[];
readonly createdAt: IsoDateTime;
readonly updatedAt: IsoDateTime;
}
export interface AdminProductOption {
readonly id: Id;
readonly key: string;
readonly position: number;
readonly names: TranslationMap<string>;
readonly values: readonly AdminProductOptionValue[];
}
export interface AdminProductOptionValue {
readonly id: Id;
readonly value: string;
readonly position: number;
readonly swatchHex: Nullable<string>;
readonly labels: TranslationMap<string>;
}
/**
* One row of the variant matrix.
*
* `optionValueIds` is the identity of the row — the combination it represents.
* Everything else is editable per row, which is the entire point of the model
* (ADR-0003): stock and price are per combination, not per product.
*/
export interface AdminProductVariant {
readonly id: Id;
readonly sku: string;
readonly barcode: Nullable<string>;
readonly title: string;
readonly optionValueIds: readonly Id[];
readonly priceAmount: number;
readonly salePriceAmount: Nullable<number>;
readonly compareAtAmount: Nullable<number>;
readonly costAmount: Nullable<number>;
readonly weightGrams: Nullable<number>;
readonly status: VariantStatus;
readonly position: number;
/** Aggregated across locations; per-location detail lives in inventory. */
readonly onHand: number;
readonly reserved: number;
readonly available: number;
}
export interface AdminProductImage {
readonly id: Id;
readonly mediaId: Id;
readonly url: string;
readonly altText: Nullable<string>;
readonly position: number;
readonly optionValueId: Nullable<Id>;
}
export interface AdminProductAttribute {
readonly id: Id;
readonly key: string;
readonly position: number;
readonly translations: TranslationMap<{ readonly label: string; readonly value: string }>;
}
// ---------------------------------------------------------------------------
// Media
// ---------------------------------------------------------------------------
export interface MediaAssetSummary {
readonly id: Id;
readonly kind: MediaKind;
readonly url: string;
readonly storageKey: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly width: Nullable<number>;
readonly height: Nullable<number>;
readonly altText: Nullable<string>;
readonly createdAt: IsoDateTime;
}
/**
* The browser uploads straight to object storage with this, then calls back to
* register the asset. Bytes never pass through the API — no memory pressure, no
* request timeouts on a large file, and no need to scale the API for bandwidth.
*/
export interface PresignedUploadTarget {
readonly uploadUrl: string;
readonly storageKey: string;
readonly publicUrl: string;
readonly expiresInSeconds: number;
}
// ---------------------------------------------------------------------------
// Inventory
// ---------------------------------------------------------------------------
export interface InventoryLevel {
readonly variantId: Id;
readonly sku: string;
readonly variantTitle: string;
readonly productName: string;
readonly locationId: Id;
readonly locationName: string;
readonly onHand: number;
readonly reserved: number;
readonly available: number;
readonly updatedAt: IsoDateTime;
}
export interface StockMovementEntry {
readonly id: Id;
readonly variantId: Id;
readonly sku: string;
readonly quantityDelta: number;
readonly reason: string;
readonly note: Nullable<string>;
readonly createdByName: Nullable<string>;
readonly createdAt: IsoDateTime;
}
+1
View File
@@ -21,5 +21,6 @@ export * from './catalog/taxonomy';
export * from './catalog/media'; export * from './catalog/media';
export * from './catalog/navigation'; export * from './catalog/navigation';
export * from './catalog/facets'; export * from './catalog/facets';
export * from './catalog/admin';
export * from './i18n/locale'; export * from './i18n/locale';
export * from './inventory/stock'; export * from './inventory/stock';
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../config/tailwind/theme.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+2
View File
@@ -14,6 +14,8 @@
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "1.31.0",
"radix-ui": "1.6.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
+47
View File
@@ -0,0 +1,47 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* Status and merchandising labels.
*
* Kept on the raw ink/volt/semantic scales rather than the shadcn role tokens:
* a sale badge is a brand decision, not a surface role, and mapping it through
* `--primary` would make it change meaning the next time that token moves.
*/
const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center justify-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest ' +
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none ' +
"[&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-3",
{
variants: {
variant: {
neutral: 'bg-ink-100 text-ink-700',
solid: 'bg-ink-950 text-white',
sale: 'bg-sale text-white',
new: 'bg-volt-500 text-ink-950',
success: 'bg-success text-white',
warning: 'bg-warning text-ink-950',
outline: 'border border-ink-300 text-ink-700',
},
},
defaultVariants: { variant: 'neutral' },
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+110
View File
@@ -0,0 +1,110 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* The shadcn Button, tuned to this store's design language.
*
* Three deliberate departures from the registry default, each for a reason:
*
* - **Square, not rounded.** The sport/streetwear direction is hard-edged.
* `shape` exists so a pill can be asked for explicitly rather than becoming
* the default everywhere.
* - **Uppercase with tracking on the larger sizes.** This is the single
* strongest brand signal in the button and it belongs in the component, not
* repeated as `className` at ninety call sites.
* - **An `accent` variant on volt.** The registry has no equivalent, and volt
* is reserved for the one action that matters on a screen.
*
* Everything else — the variant names, `asChild`, the `data-slot` attributes,
* the focus ring on `--ring` — is left exactly as the registry writes it, so
* anything pasted from shadcn keeps working.
*/
const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-medium transition-all outline-none ' +
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] ' +
'disabled:pointer-events-none disabled:opacity-40 ' +
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 ' +
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
/**
* Transparent, not `bg-background`.
*
* The registry assumes an outline button sits on a light surface and
* gives it a white fill. Here they sit on the black hero as often as on
* a white page, and a white fill turned the label invisible. Letting the
* surface show through is what "outline" should mean.
*/
outline: 'border border-current bg-transparent hover:bg-foreground hover:text-background',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-foreground underline-offset-4 hover:underline',
destructive: 'bg-destructive text-white hover:bg-destructive/90',
/** Volt. One per screen — the primary conversion action. */
accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400',
},
size: {
default: 'h-11 px-6 text-sm uppercase tracking-wide has-[>svg]:px-5',
sm: 'h-9 px-4 text-xs uppercase tracking-wide has-[>svg]:px-3',
lg: 'h-14 px-8 text-sm uppercase tracking-widest has-[>svg]:px-6',
/** Body-copy sizing — for inline text actions where caps would shout. */
inline: 'h-auto p-0 text-sm normal-case tracking-normal',
icon: 'size-11',
'icon-sm': 'size-9',
},
shape: {
square: 'rounded-none',
rounded: 'rounded-md',
pill: 'rounded-pill',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
shape: 'square',
},
},
);
function Button({
className,
variant = 'default',
size = 'default',
shape = 'square',
fullWidth,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
/**
* Renders the child element instead of a `<button>`, forwarding styles and
* behaviour onto it.
*
* This is what a Button wrapping a `<Link>` must use. `<Link><Button>`
* produces `<a><button>`, which is invalid HTML and gives screen readers
* two nested controls to announce.
*/
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, shape, fullWidth, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+145
View File
@@ -0,0 +1,145 @@
'use client';
import { XIcon } from 'lucide-react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
import { Button } from './button';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg outline-none duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="rounded-xs ring-offset-background focus:ring-ring focus:outline-hidden data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,228 @@
'use client';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'origin-(--radix-dropdown-menu-content-transform-origin) bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* The shadcn Input at this store's proportions: taller (h-11, comfortable on
* touch), square, and on a solid background rather than transparent.
*
* The focus treatment is the registry's and is the reason to adopt it — the
* previous hand-rolled input set `focus:outline-none` and signalled focus with
* a border tint alone, which is close to invisible against `--border`. This
* draws a real 3px ring on `--ring`.
*/
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'border-input bg-background text-foreground h-11 w-full min-w-0 border px-4 py-1 text-sm outline-none transition-[color,box-shadow]',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
'disabled:bg-muted disabled:cursor-not-allowed disabled:opacity-60',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:ring-[3px]',
className,
)}
{...props}
/>
);
}
export { Input };
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { XIcon } from 'lucide-react';
import { Dialog as SheetPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=closed]:animate-out data-[state=open]:animate-in fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="rounded-xs ring-offset-background focus:ring-ring focus:outline-hidden data-[state=open]:bg-secondary absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

Some files were not shown because too many files have changed in this diff Show More