Wip stage M4
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+38
-63
@@ -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
|
||||||
+21
-29
@@ -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.
|
|
||||||
@@ -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];
|
||||||
@@ -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']),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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
Reference in New Issue
Block a user