diff --git a/README.md b/README.md index 9400702..a5836d8 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,9 @@ A modern sports-fashion e-commerce platform. -**Status: milestone 2 — auth and RBAC, on top of a live bilingual catalog.** -The storefront renders real products in Vietnamese and English; the admin has working sign-in -with rotating refresh tokens and permission-filtered navigation. Cart, checkout and orders are -next; see [Roadmap](#roadmap). +**Status: milestone 3 — the admin can now write to the catalog.** +Products, variants, media and stock are editable from the back office and appear on the +bilingual storefront immediately. Cart, checkout and orders are next; see [Roadmap](#roadmap). ``` Storefront (Next.js) ─┐ @@ -123,12 +122,12 @@ sport-store/ │ ├── storefront/ Next.js customer site (:3000) │ │ └── src/ │ │ ├── app/[locale]/ (shop) (checkout) (account) route groups -│ │ ├── components/ Cross-feature UI (layout, chrome) -│ │ ├── features/ auth · product · category · collection · search -│ │ │ cart · checkout · order · wishlist · account +│ │ ├── components/ +│ │ │ └── commerce/ Hand-built brand UI — hero, mega menu, header, +│ │ │ product card, gallery, PDP, filter sheet │ │ ├── i18n/ next-intl routing, request config, navigation │ │ ├── messages/ vi.json · en.json (UI strings) -│ │ ├── hooks/ lib/ services/ stores/ styles/ types/ +│ │ ├── hooks/ lib/ stores/ styles/ types/ │ │ │ ├── admin/ Next.js back office (:3001) │ │ └── src/ @@ -148,7 +147,7 @@ sport-store/ │ ├── types/ Framework-free domain + API contracts (zero deps) │ ├── validation/ Zod schemas shared by API and both frontends │ ├── 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 │ └── eslint-config/ Flat configs incl. the architectural boundary rules │ @@ -159,7 +158,7 @@ sport-store/ │ ├── docs/ │ ├── 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 ├── 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. ([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 Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`. @@ -238,23 +244,24 @@ locale-in-path would buy nothing. ## Roadmap -| Milestone | Scope | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | -| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data | -| **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management | -| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory | -| **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain | -| **M5** | Cart, checkout, orders | -| **M6** | Search + faceting | -| **M7** | Promotions, coupons, reviews, CMS | -| **M8** | Customer account | -| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | +| Milestone | Scope | +| --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | +| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data | +| **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management | +| **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, filters, sort control, load-more pagination and a mobile filter sheet | +| **M5** | Cart, checkout, orders | +| **M6** | Search + faceting | +| **M7** | Promotions, coupons, reviews, CMS | +| **M8** | Customer account | +| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | -**Recommended next step: M3 (admin catalog write path).** Reads, auth and RBAC are in place, so -the product editor and variant matrix now have everything they need — a known operator, a -permission to check, and a catalog to edit. It is also what makes the seed replaceable by real -merchandising. +**Recommended next step: M5 (cart & checkout).** M3 now closes the loop end to end: an operator +creates a product with per-locale content, defines the option axes, gets a generated variant matrix, +prices it, attaches imagery per colourway, receives stock through the ledger and publishes — and the +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: -- `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 - 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 -- **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 both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`) - Storefront: every route 200 in both locales; `/en/products/` → 307 → @@ -283,15 +292,28 @@ Everything below was run, not assumed: ### Verified in a real browser -Server-side checks and curl are not sufficient for client behaviour — three bugs proved it. -Confirmed by clicking through Chrome with the console and network panel open: +Server-side checks and curl are not sufficient for client behaviour. Confirmed by clicking +through Chrome with the console and network panel open: -- Admin sign-in issues exactly **one** `POST /auth/admin/login`, then redirects to the dashboard -- Sidebar is filtered by the signed-in operator's permissions; users table and role viewer load - real data; language switch preserves the session and the current page; sign-out returns to login +- Admin sign-in issues exactly **one** request, then redirects; sidebar is permission-filtered; + users table and role viewer load real data; language switch preserves session and page - Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes, - SKU updates, and switching language moves between translated slugs -- Filters apply (`/men?colors=black&onSale=true`), and all 16 grid images load + SKU updates, language switch moves between translated slugs; filters apply; all 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 come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are diff --git a/apps/admin/package.json b/apps/admin/package.json index 006ea45..be2662e 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -17,10 +17,12 @@ "@sport/ui": "workspace:*", "@sport/validation": "workspace:*", "@tanstack/react-query": "^5.101.4", + "lucide-react": "1.31.0", "next": "catalog:", "next-intl": "^4.13.6", "react": "catalog:", "react-dom": "catalog:", + "tw-animate-css": "1.4.0", "zod": "catalog:", "zustand": "^5.0.14" }, diff --git a/apps/admin/src/app/(dashboard)/inventory/page.tsx b/apps/admin/src/app/(dashboard)/inventory/page.tsx index 41d08ee..94d1721 100644 --- a/apps/admin/src/app/(dashboard)/inventory/page.tsx +++ b/apps/admin/src/app/(dashboard)/inventory/page.tsx @@ -1,22 +1,24 @@ import type { Metadata } from 'next'; 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 { const t = await getTranslations('pages.inventory'); return { title: t('title') }; } -export default async function InventoryPage() { +export default async function Page() { const t = await getTranslations('pages.inventory'); return ( - +
+
+

{t('title')}

+

{t('body')}

+
+ + +
); } diff --git a/apps/admin/src/app/(dashboard)/media/page.tsx b/apps/admin/src/app/(dashboard)/media/page.tsx index fcbc883..8675c2b 100644 --- a/apps/admin/src/app/(dashboard)/media/page.tsx +++ b/apps/admin/src/app/(dashboard)/media/page.tsx @@ -1,22 +1,24 @@ import type { Metadata } from 'next'; 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 { const t = await getTranslations('pages.media'); return { title: t('title') }; } -export default async function MediaPage() { +export default async function Page() { const t = await getTranslations('pages.media'); return ( - +
+
+

{t('title')}

+

{t('body')}

+
+ + +
); } diff --git a/apps/admin/src/app/(dashboard)/products/[id]/page.tsx b/apps/admin/src/app/(dashboard)/products/[id]/page.tsx new file mode 100644 index 0000000..0c015fc --- /dev/null +++ b/apps/admin/src/app/(dashboard)/products/[id]/page.tsx @@ -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 { + const t = await getTranslations('editor'); + return { title: t('editTitle') }; +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/products/new/page.tsx b/apps/admin/src/app/(dashboard)/products/new/page.tsx new file mode 100644 index 0000000..58bc7d6 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/products/new/page.tsx @@ -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 { + 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 ( +
+
+ + ← {t('backToList')} + +

{t('createTitle')}

+
+ +

{t('createHint')}

+ + +
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/products/page.tsx b/apps/admin/src/app/(dashboard)/products/page.tsx index c319caa..e1cd309 100644 --- a/apps/admin/src/app/(dashboard)/products/page.tsx +++ b/apps/admin/src/app/(dashboard)/products/page.tsx @@ -1,22 +1,24 @@ import type { Metadata } from 'next'; 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 { const t = await getTranslations('pages.products'); return { title: t('title') }; } -export default async function ProductsPage() { +export default async function Page() { const t = await getTranslations('pages.products'); return ( - +
+
+

{t('title')}

+

{t('body')}

+
+ + +
); } diff --git a/apps/admin/src/features/auth/login-form.tsx b/apps/admin/src/features/auth/login-form.tsx index eaf451b..f0544d5 100644 --- a/apps/admin/src/features/auth/login-form.tsx +++ b/apps/admin/src/features/auth/login-form.tsx @@ -71,7 +71,7 @@ export function LoginForm() { required value={email} onChange={(event) => setEmail(event.target.value)} - invalid={Boolean(error)} + aria-invalid={Boolean(error)} /> @@ -87,7 +87,7 @@ export function LoginForm() { required value={password} onChange={(event) => setPassword(event.target.value)} - invalid={Boolean(error)} + aria-invalid={Boolean(error)} /> diff --git a/apps/admin/src/features/inventory/inventory-table.tsx b/apps/admin/src/features/inventory/inventory-table.tsx new file mode 100644 index 0000000..fc179ce --- /dev/null +++ b/apps/admin/src/features/inventory/inventory-table.tsx @@ -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(null); + const [query, setQuery] = useState(''); + const [lowOnly, setLowOnly] = useState(false); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(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

{t('noPermission')}

; + } + + return ( +
+
+ setQuery(event.target.value)} + className="max-w-xs" + /> + +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {!levels ? ( +
+ + +
+ ) : ( +
+ + + + + + + + + + + + {levels.map((level) => ( + + + + + + + + + ))} + +
{t('table.sku')}{t('table.product')}{t('table.onHand')}{t('table.reserved')}{t('table.available')} +
{level.sku} +

{level.productName}

+

{level.variantTitle}

+
{level.onHand}{level.reserved} + + {level.available} + + {level.available === 0 ? ( + + {t('outOfStock')} + + ) : null} + + {!can(PERMISSIONS.INVENTORY_UPDATE) ? null : editing === level.variantId ? ( +
+ setDelta(event.target.value)} + placeholder="+10" + className="h-9 w-24" + aria-label={t('deltaLabel')} + /> + + +
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/admin/src/features/media/media-library.tsx b/apps/admin/src/features/media/media-library.tsx new file mode 100644 index 0000000..c53d780 --- /dev/null +++ b/apps/admin/src/features/media/media-library.tsx @@ -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(null); + + const [assets, setAssets] = useState(null); + const [uploads, setUploads] = useState([]); + const [error, setError] = useState(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

{t('noPermission')}

; + } + + return ( +
+ {can(PERMISSIONS.MEDIA_UPLOAD) ? ( +
+ void handleFiles(event.target.files)} + /> + +

{t('uploadHint')}

+
+ ) : null} + + {uploads.length > 0 ? ( +
    + {uploads.map((upload) => ( +
  • + {upload.name} — {upload.status === 'failed' ? upload.error : t('uploading')} +
  • + ))} +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {!assets ? ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ) : assets.length === 0 ? ( +

{t('empty')}

+ ) : ( +
    + {assets.map((asset) => ( +
  • +
    + {asset.altText +
    +
    +

    {asset.altText ?? asset.storageKey}

    +

    + {asset.width && asset.height ? `${asset.width}×${asset.height} · ` : ''} + {Math.round(asset.sizeBytes / 1024)} KB +

    +
    + {can(PERMISSIONS.MEDIA_DELETE) ? ( + + ) : null} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/admin/src/features/products/image-manager.tsx b/apps/admin/src/features/products/image-manager.tsx new file mode 100644 index 0000000..dbe1dce --- /dev/null +++ b/apps/admin/src/features/products/image-manager.tsx @@ -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(() => + product.images.map((image) => ({ + mediaId: image.mediaId, + url: image.url, + optionValueId: image.optionValueId, + })), + ); + const [library, setLibrary] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(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 ( +
+
+

{t('attached')}

+ {saved ? {t('saved')} : null} + {editable ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {slots.length === 0 ? ( +

{t('noImages')}

+ ) : ( +
    + {slots.map((slot, index) => ( +
  • +
    + + {index === 0 ? ( + + {t('primary')} + + ) : null} +
    + + {colourValues.length > 0 ? ( + + ) : null} + + {editable ? ( +
    + + + +
    + ) : null} +
  • + ))} +
+ )} + + {editable ? ( +
+

{t('library')}

+ {!library ? ( +
+ {Array.from({ length: 8 }, (_, index) => ( + + ))} +
+ ) : ( +
    + {library.map((asset) => { + const used = slots.some((slot) => slot.mediaId === asset.id); + + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/apps/admin/src/features/products/product-editor.tsx b/apps/admin/src/features/products/product-editor.tsx new file mode 100644 index 0000000..a86e27d --- /dev/null +++ b/apps/admin/src/features/products/product-editor.tsx @@ -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(null); + const [tab, setTab] = useState('details'); + const [error, setError] = useState(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 ( +

+ {error} +

+ ); + } + + if (!product) { + return ( +
+ + +
+ ); + } + + const name = product.translations.vi?.name ?? product.translations.en?.name ?? '—'; + + return ( +
+
+ + ← {t('backToList')} + +

{name}

+ {product.status} + + {can(PERMISSIONS.PRODUCT_PUBLISH) ? ( +
+ {product.status === 'ACTIVE' ? ( + + ) : ( + + )} + {product.status !== 'ARCHIVED' ? ( + + ) : null} +
+ ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + + {tab === 'details' ? : 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' ? ( + variant.id).join(',')} + product={product} + onChange={setProduct} + /> + ) : null} + {tab === 'images' ? : null} +
+ ); +} diff --git a/apps/admin/src/features/products/product-form.tsx b/apps/admin/src/features/products/product-form.tsx new file mode 100644 index 0000000..30dfb38 --- /dev/null +++ b/apps/admin/src/features/products/product-form.tsx @@ -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; +} + +interface DraftOption { + key: string; + names: Record; + values: DraftValue[]; +} + +const emptyTranslations = (): Record => + Object.fromEntries( + LOCALES.map((locale) => [locale, { name: '', shortDescription: '', description: '' }]), + ) as Record; + +/** 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(DEFAULT_LOCALE); + const [translations, setTranslations] = useState>(() => { + 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(() => [...(product?.genderTargets ?? [])]); + const [sports, setSports] = useState(() => [...(product?.sportTypes ?? [])]); + const [skuPrefix, setSkuPrefix] = useState(''); + const [basePrice, setBasePrice] = useState('0'); + + const [options, setOptions] = useState(() => { + 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(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 ( +
+ {/* ---- Content, per locale ---- */} +
+
+

{t('content')}

+
+ {LOCALES.map((key) => ( + + ))} +
+
+

{t('localeHint')}

+ + + + + +