Stage M5 and Stage M6
This commit is contained in:
@@ -141,7 +141,7 @@ sport-store/
|
||||
│ ├── common/ decorators · filters · guards · interceptors
|
||||
│ │ middleware · pipes · errors
|
||||
│ ├── infrastructure/ prisma · redis · storage · events · logging
|
||||
│ └── modules/ 20 bounded contexts
|
||||
│ └── modules/ 21 bounded contexts
|
||||
│
|
||||
├── packages/
|
||||
│ ├── types/ Framework-free domain + API contracts (zero deps)
|
||||
@@ -158,7 +158,7 @@ sport-store/
|
||||
│
|
||||
├── docs/
|
||||
│ ├── architecture.md Boundaries, conventions, risks — read this first
|
||||
│ └── adr/ 17 decision records
|
||||
│ └── adr/ 18 decision records
|
||||
│
|
||||
├── docker-compose.yml Backing services; `--profile full` runs everything
|
||||
├── turbo.json pnpm-workspace.yaml package.json
|
||||
@@ -251,17 +251,16 @@ locale-in-path would buy nothing.
|
||||
| **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 |
|
||||
| **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle |
|
||||
| **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results |
|
||||
| **M7** | Promotions, coupons, reviews, CMS |
|
||||
| **M8** | Customer account |
|
||||
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
|
||||
|
||||
**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.
|
||||
**Recommended next step: M7 (promotions, coupons, reviews, CMS) or M9 (payments).** The store can
|
||||
now be browsed, searched, filled into a bag and checked out, and every order moves stock through a
|
||||
ledger. What it still cannot do is take money — which is the one gap between this and a shop that
|
||||
trades.
|
||||
|
||||
---
|
||||
|
||||
@@ -271,11 +270,12 @@ Everything below was run, not assumed:
|
||||
|
||||
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass;
|
||||
`pnpm format:check` clean
|
||||
- 6 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
|
||||
- 7 migrations, 35 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
|
||||
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
|
||||
- **48 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
|
||||
- **57 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
|
||||
variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory
|
||||
list's variant-driven projection, and the two admin-schema defects that caused silent data loss
|
||||
list's variant-driven projection, the two admin-schema defects that caused silent data loss, and
|
||||
order-number round-tripping
|
||||
- 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/<vi-slug>` → 307 →
|
||||
@@ -303,6 +303,23 @@ through Chrome with the console and network panel open:
|
||||
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
|
||||
- **Search (M6):** `nocturne` and `running jacket` match exactly; `ao chay bo` finds _Áo Chạy Bộ
|
||||
Aero_ without diacritics; `nocturn`, `jaket` and `runing` survive their typos; `velocity`
|
||||
matches by brand, `crimson` by colourway, `VEL-NOC` by SKU; `zzzzqqq` correctly finds nothing.
|
||||
Type-ahead suggests from the product name only, and results stay refinable by the same facets
|
||||
as any listing with an honest sort control
|
||||
- **Concurrency:** three simultaneous checkouts for a single unit produce exactly one order and
|
||||
two clean rejections, with `reserved` landing on 1 — the read-then-write version created two
|
||||
orders and lost a reservation
|
||||
- **The purchase flow, end to end in a browser (M5):** added to bag from the PDP (badge updates),
|
||||
changed quantity in the bag, checked out as a guest and placed order **SP-000003**; the
|
||||
confirmation page is reachable from its bookmarkable URL; the admin confirmed then fulfilled it,
|
||||
which moved `onHand` 15→12, released `reserved` 3→0 and wrote a `SALE -3` ledger entry alongside
|
||||
`order.confirmed` / `order.fulfilled` audit records
|
||||
- **Commerce guard rails:** reserving does not touch `onHand`; cancelling returns the reservation;
|
||||
`PENDING→COMPLETED` is refused; cancelling without a reason is refused; a 20-unit request caps to
|
||||
available stock with a `QUANTITY_REDUCED` notice; guest order lookup needs number _and_ email and
|
||||
answers a wrong email with the same `NOT_FOUND` as a wrong number
|
||||
- **Catalog write correctness**, each reproduced before the fix and re-run after: a product
|
||||
authored in one language is accepted; renaming a product no longer clears its gender/sport
|
||||
targeting, collections or attributes; adding a size inherits the sibling price and the stored
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { OrderDetail } from '@/features/orders/order-detail';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('orders');
|
||||
return { title: t('detailTitle') };
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<OrderDetail orderId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { OrdersTable } from '@/features/orders/orders-table';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.orders');
|
||||
const t = await getTranslations('orders');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function OrdersPage() {
|
||||
const t = await getTranslations('pages.orders');
|
||||
const t = await getTranslations('orders');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="order.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
<div className="space-y-6 p-8">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
||||
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('description')}</p>
|
||||
</header>
|
||||
<OrdersTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type Order, type OrderStatus } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatDateTime, formatMoney } from '@/lib/format';
|
||||
|
||||
import { STATUS_VARIANT } from './orders-table';
|
||||
|
||||
/**
|
||||
* Which actions to offer, mirroring the server's transition table.
|
||||
*
|
||||
* Duplicated deliberately rather than fetched: the server is the authority and
|
||||
* rejects anything illegal, so this list only decides which buttons are worth
|
||||
* showing. Getting it out of step shows a button that fails, never a transition
|
||||
* that should not happen.
|
||||
*/
|
||||
const NEXT_STATUSES: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||
PENDING: ['CONFIRMED', 'CANCELLED'],
|
||||
CONFIRMED: ['FULFILLED', 'CANCELLED'],
|
||||
FULFILLED: ['COMPLETED'],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
export function OrderDetail({ orderId }: { orderId: string }) {
|
||||
const t = useTranslations('orders');
|
||||
const format = useFormatter();
|
||||
const { can } = useSession();
|
||||
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.ordersAdmin.getOrder(orderId);
|
||||
if (!cancelled) setOrder(result);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orderId, t]);
|
||||
|
||||
async function move(status: OrderStatus) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setOrder(await browserApi.ordersAdmin.updateOrderStatus(orderId, status, reason || null));
|
||||
setReason('');
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('updateFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !order) {
|
||||
return (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const next = NEXT_STATUSES[order.status];
|
||||
const money = (amount: number) => formatMoney({ amount, currency: order.currency }, format);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/orders" className="text-ink-500 hover:text-ink-950 text-sm">
|
||||
← {t('backToList')}
|
||||
</Link>
|
||||
<h1 className="font-mono text-xl font-bold">{order.orderNumber}</h1>
|
||||
<Badge variant={STATUS_VARIANT[order.status]}>{t(`status.${order.status}`)}</Badge>
|
||||
<Badge variant="outline">{t(`payment.${order.paymentStatus}`)}</Badge>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{can(PERMISSIONS.ORDER_UPDATE) && next.length > 0 ? (
|
||||
<section className="border-ink-200 space-y-3 border bg-white p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('actions')}</h2>
|
||||
|
||||
{next.includes('CANCELLED') ? (
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t('reasonPlaceholder')}
|
||||
className="max-w-md"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{next.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
size="sm"
|
||||
variant={status === 'CANCELLED' ? 'destructive' : 'default'}
|
||||
disabled={busy || (status === 'CANCELLED' && reason.trim().length === 0)}
|
||||
onClick={() => void move(status)}
|
||||
>
|
||||
{t(`action.${status}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{next.includes('FULFILLED') ? (
|
||||
<p className="text-ink-400 text-xs">{t('fulfilHint')}</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
|
||||
<section className="border-ink-200 border bg-white">
|
||||
<h2 className="border-ink-200 border-b px-6 py-4 text-xs font-semibold uppercase tracking-widest">
|
||||
{t('items')}
|
||||
</h2>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{order.lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="px-6 py-3">
|
||||
<p className="font-medium">{line.productName}</p>
|
||||
<p className="text-ink-500 text-xs">{line.variantTitle}</p>
|
||||
<p className="text-ink-400 font-mono text-[0.625rem]">{line.sku}</p>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right tabular-nums">
|
||||
{money(line.unitPrice.amount)} × {line.quantity}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-semibold tabular-nums">
|
||||
{money(line.lineTotal.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<dl className="border-ink-200 space-y-2 border-t px-6 py-4 text-sm">
|
||||
<Row label={t('subtotal')} value={money(order.subtotal.amount)} />
|
||||
<Row label={t('shipping')} value={money(order.shipping.amount)} />
|
||||
<Row label={t('discount')} value={money(order.discount.amount)} />
|
||||
<div className="border-ink-200 flex justify-between border-t pt-2 font-semibold">
|
||||
<dt>{t('total')}</dt>
|
||||
<dd className="tabular-nums">{money(order.total.amount)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<section className="border-ink-200 border bg-white p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('customer')}</h2>
|
||||
<p className="mt-3 text-sm font-medium">{order.shippingAddress.fullName}</p>
|
||||
<p className="text-ink-500 text-sm">{order.email}</p>
|
||||
<p className="text-ink-500 text-sm">{order.phone}</p>
|
||||
</section>
|
||||
|
||||
<section className="border-ink-200 border bg-white p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('shippingTo')}</h2>
|
||||
<address className="text-ink-600 mt-3 text-sm not-italic leading-relaxed">
|
||||
{[
|
||||
order.shippingAddress.line1,
|
||||
order.shippingAddress.ward,
|
||||
order.shippingAddress.district,
|
||||
order.shippingAddress.province,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</address>
|
||||
{order.customerNote ? (
|
||||
<p className="border-ink-100 text-ink-500 mt-3 border-t pt-3 text-xs">
|
||||
{order.customerNote}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="border-ink-200 border bg-white p-6 text-xs">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('timeline')}</h2>
|
||||
<dl className="text-ink-500 mt-3 space-y-1">
|
||||
<Row label={t('placed')} value={formatDateTime(order.placedAt, format)} />
|
||||
{order.confirmedAt ? (
|
||||
<Row label={t('confirmed')} value={formatDateTime(order.confirmedAt, format)} />
|
||||
) : null}
|
||||
{order.cancelledAt ? (
|
||||
<Row label={t('cancelled')} value={formatDateTime(order.cancelledAt, format)} />
|
||||
) : null}
|
||||
</dl>
|
||||
{order.cancelReason ? <p className="text-danger mt-2">{order.cancelReason}</p> : null}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-ink-500">{label}</dt>
|
||||
<dd className="text-right tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type OrderListItem, type OrderStatus } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatDateTime, formatMoney } from '@/lib/format';
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
undefined,
|
||||
'PENDING',
|
||||
'CONFIRMED',
|
||||
'FULFILLED',
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
|
||||
export const STATUS_VARIANT: Record<OrderStatus, 'success' | 'warning' | 'neutral' | 'solid'> = {
|
||||
PENDING: 'warning',
|
||||
CONFIRMED: 'solid',
|
||||
FULFILLED: 'success',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'neutral',
|
||||
};
|
||||
|
||||
export function OrdersTable() {
|
||||
const t = useTranslations('orders');
|
||||
const format = useFormatter();
|
||||
const { can } = useSession();
|
||||
|
||||
const [orders, setOrders] = useState<OrderListItem[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState<OrderStatus | undefined>(undefined);
|
||||
const [query, setQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const result = await browserApi.ordersAdmin.listOrders({
|
||||
status,
|
||||
q: query || undefined,
|
||||
perPage: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setOrders([...result.items]);
|
||||
setTotal(result.pageInfo.totalItems);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced so typing an order number does not fire a request per keystroke.
|
||||
const timer = setTimeout(() => void run(), query ? 300 : 0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [status, query, t]);
|
||||
|
||||
if (!can(PERMISSIONS.ORDER_READ)) {
|
||||
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_FILTERS.map((value) => (
|
||||
<button
|
||||
key={value ?? 'all'}
|
||||
type="button"
|
||||
onClick={() => setStatus(value as OrderStatus | undefined)}
|
||||
aria-pressed={status === value}
|
||||
className={cn(
|
||||
'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>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!orders ? (
|
||||
<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>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-ink-500 py-16 text-center text-sm">{t('empty')}</p>
|
||||
) : (
|
||||
<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('column.order')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.customer')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.status')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.items')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.total')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.placed')}</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-ink-50/60">
|
||||
<td className="px-4 py-3 font-mono text-xs font-semibold">
|
||||
<Link href={`/orders/${order.id}`} className="hover:underline">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{order.customerName}</p>
|
||||
<p className="text-ink-500 text-xs">{order.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[order.status]}>
|
||||
{t(`status.${order.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums">{order.itemCount}</td>
|
||||
<td className="px-4 py-3 tabular-nums">{formatMoney(order.total, format)}</td>
|
||||
<td className="text-ink-500 px-4 py-3 text-xs">
|
||||
{formatDateTime(order.placedAt, format)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/orders/${order.id}`}>{t('view')}</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useFormatter, 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 { PERMISSIONS, type AdminProductListItem } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
ACTIVE: 'success',
|
||||
@@ -18,19 +19,9 @@ const STATUS_VARIANT = {
|
||||
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 format = useFormatter();
|
||||
const { can } = useSession();
|
||||
|
||||
const [items, setItems] = useState<AdminProductListItem[] | null>(null);
|
||||
@@ -185,8 +176,8 @@ export function ProductsTable() {
|
||||
<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)}`
|
||||
? formatMoney(product.priceRange.min, format)
|
||||
: `${formatMoney(product.priceRange.min, format)} – ${formatMoney(product.priceRange.max, format)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { useFormatter } from 'next-intl';
|
||||
|
||||
import { MINOR_UNIT_SCALE, type Money } from '@sport/types';
|
||||
|
||||
type Formatter = ReturnType<typeof useFormatter>;
|
||||
|
||||
/**
|
||||
* Money and timestamps become display strings exactly here.
|
||||
*
|
||||
* The formatter comes from next-intl rather than a hardcoded `'vi-VN'`. The
|
||||
* admin is bilingual — an operator who switches to English was still reading
|
||||
* Vietnamese grouping and `12/8/2026` date order, which for an order table is
|
||||
* not cosmetic: `12/8` and `8/12` are different days.
|
||||
*/
|
||||
export function formatMoney(money: Money, format: Formatter): string {
|
||||
const scale = MINOR_UNIT_SCALE[money.currency];
|
||||
|
||||
return format.number(money.amount / 10 ** scale, {
|
||||
style: 'currency',
|
||||
currency: money.currency,
|
||||
minimumFractionDigits: scale,
|
||||
maximumFractionDigits: scale,
|
||||
});
|
||||
}
|
||||
|
||||
/** Date and time together — an order table needs both to be useful. */
|
||||
export function formatDateTime(value: string, format: Formatter): string {
|
||||
return format.dateTime(new Date(value), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
@@ -226,5 +226,60 @@
|
||||
"anyColourway": "All colourways",
|
||||
"moveLeft": "Move earlier",
|
||||
"moveRight": "Move later"
|
||||
},
|
||||
"orders": {
|
||||
"title": "Orders",
|
||||
"detailTitle": "Order",
|
||||
"description": "Placed orders, their stock reservations and where each one is in its lifecycle.",
|
||||
"searchPlaceholder": "Order number, name, email or phone",
|
||||
"count": "{count} orders",
|
||||
"empty": "No orders match.",
|
||||
"view": "View",
|
||||
"backToList": "Orders",
|
||||
"noPermission": "You do not have permission to view orders.",
|
||||
"loadFailed": "Could not load orders.",
|
||||
"updateFailed": "Could not update this order.",
|
||||
"actions": "Actions",
|
||||
"reasonPlaceholder": "Reason for cancelling (required)",
|
||||
"fulfilHint": "Fulfilling ships the reserved stock and writes a ledger entry.",
|
||||
"items": "Items",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"discount": "Discount",
|
||||
"total": "Total",
|
||||
"customer": "Customer",
|
||||
"shippingTo": "Shipping to",
|
||||
"timeline": "Timeline",
|
||||
"placed": "Placed",
|
||||
"confirmed": "Confirmed",
|
||||
"cancelled": "Cancelled",
|
||||
"status": {
|
||||
"ALL": "All",
|
||||
"PENDING": "Pending",
|
||||
"CONFIRMED": "Confirmed",
|
||||
"FULFILLED": "Fulfilled",
|
||||
"COMPLETED": "Completed",
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"action": {
|
||||
"CONFIRMED": "Confirm",
|
||||
"FULFILLED": "Mark fulfilled",
|
||||
"COMPLETED": "Complete",
|
||||
"CANCELLED": "Cancel order"
|
||||
},
|
||||
"payment": {
|
||||
"UNPAID": "Unpaid",
|
||||
"PAID": "Paid",
|
||||
"PARTIALLY_REFUNDED": "Partly refunded",
|
||||
"REFUNDED": "Refunded"
|
||||
},
|
||||
"column": {
|
||||
"order": "Order",
|
||||
"customer": "Customer",
|
||||
"status": "Status",
|
||||
"items": "Items",
|
||||
"total": "Total",
|
||||
"placed": "Placed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,5 +226,60 @@
|
||||
"anyColourway": "Mọi màu",
|
||||
"moveLeft": "Chuyển lên trước",
|
||||
"moveRight": "Chuyển xuống sau"
|
||||
},
|
||||
"orders": {
|
||||
"title": "Đơn hàng",
|
||||
"detailTitle": "Đơn hàng",
|
||||
"description": "Đơn đã đặt, phần tồn kho đang giữ và trạng thái hiện tại của từng đơn.",
|
||||
"searchPlaceholder": "Mã đơn, tên, email hoặc số điện thoại",
|
||||
"count": "{count} đơn hàng",
|
||||
"empty": "Không có đơn nào phù hợp.",
|
||||
"view": "Xem",
|
||||
"backToList": "Đơn hàng",
|
||||
"noPermission": "Bạn không có quyền xem đơn hàng.",
|
||||
"loadFailed": "Không tải được đơn hàng.",
|
||||
"updateFailed": "Không cập nhật được đơn này.",
|
||||
"actions": "Thao tác",
|
||||
"reasonPlaceholder": "Lý do huỷ đơn (bắt buộc)",
|
||||
"fulfilHint": "Hoàn tất sẽ xuất phần hàng đang giữ và ghi vào sổ nhật ký kho.",
|
||||
"items": "Sản phẩm",
|
||||
"subtotal": "Tạm tính",
|
||||
"shipping": "Vận chuyển",
|
||||
"discount": "Giảm giá",
|
||||
"total": "Tổng cộng",
|
||||
"customer": "Khách hàng",
|
||||
"shippingTo": "Giao đến",
|
||||
"timeline": "Diễn biến",
|
||||
"placed": "Đặt hàng",
|
||||
"confirmed": "Xác nhận",
|
||||
"cancelled": "Huỷ",
|
||||
"status": {
|
||||
"ALL": "Tất cả",
|
||||
"PENDING": "Chờ xử lý",
|
||||
"CONFIRMED": "Đã xác nhận",
|
||||
"FULFILLED": "Đã giao",
|
||||
"COMPLETED": "Hoàn tất",
|
||||
"CANCELLED": "Đã huỷ"
|
||||
},
|
||||
"action": {
|
||||
"CONFIRMED": "Xác nhận",
|
||||
"FULFILLED": "Đánh dấu đã giao",
|
||||
"COMPLETED": "Hoàn tất",
|
||||
"CANCELLED": "Huỷ đơn"
|
||||
},
|
||||
"payment": {
|
||||
"UNPAID": "Chưa thanh toán",
|
||||
"PAID": "Đã thanh toán",
|
||||
"PARTIALLY_REFUNDED": "Hoàn một phần",
|
||||
"REFUNDED": "Đã hoàn tiền"
|
||||
},
|
||||
"column": {
|
||||
"order": "Mã đơn",
|
||||
"customer": "Khách hàng",
|
||||
"status": "Trạng thái",
|
||||
"items": "Số món",
|
||||
"total": "Tổng tiền",
|
||||
"placed": "Đặt lúc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PAID', 'PARTIALLY_REFUNDED', 'REFUNDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FulfillmentStatus" AS ENUM ('UNFULFILLED', 'PARTIALLY_FULFILLED', 'FULFILLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "orders" (
|
||||
"id" UUID NOT NULL,
|
||||
"number" SERIAL NOT NULL,
|
||||
"customer_id" UUID,
|
||||
"email" VARCHAR(255) NOT NULL,
|
||||
"phone" VARCHAR(20) NOT NULL,
|
||||
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"payment_status" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
|
||||
"fulfillment_status" "FulfillmentStatus" NOT NULL DEFAULT 'UNFULFILLED',
|
||||
"currency" "Currency" NOT NULL DEFAULT 'VND',
|
||||
"subtotal_amount" INTEGER NOT NULL,
|
||||
"discount_amount" INTEGER NOT NULL DEFAULT 0,
|
||||
"shipping_amount" INTEGER NOT NULL DEFAULT 0,
|
||||
"tax_amount" INTEGER NOT NULL DEFAULT 0,
|
||||
"total_amount" INTEGER NOT NULL,
|
||||
"ship_full_name" VARCHAR(160) NOT NULL,
|
||||
"ship_phone" VARCHAR(20) NOT NULL,
|
||||
"ship_line1" VARCHAR(255) NOT NULL,
|
||||
"ship_line2" VARCHAR(255),
|
||||
"ship_ward" VARCHAR(120),
|
||||
"ship_district" VARCHAR(120),
|
||||
"ship_province" VARCHAR(120) NOT NULL,
|
||||
"ship_country_code" CHAR(2) NOT NULL DEFAULT 'VN',
|
||||
"ship_postal_code" VARCHAR(20),
|
||||
"customer_note" VARCHAR(1000),
|
||||
"placed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"confirmed_at" TIMESTAMPTZ(3),
|
||||
"cancelled_at" TIMESTAMPTZ(3),
|
||||
"cancel_reason" VARCHAR(500),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "order_lines" (
|
||||
"id" UUID NOT NULL,
|
||||
"order_id" UUID NOT NULL,
|
||||
"variant_id" UUID,
|
||||
"product_name" VARCHAR(255) NOT NULL,
|
||||
"variant_title" VARCHAR(255) NOT NULL,
|
||||
"sku" VARCHAR(64) NOT NULL,
|
||||
"image_url" VARCHAR(500),
|
||||
"unit_amount" INTEGER NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"line_amount" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "order_lines_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "orders_number_key" ON "orders"("number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_customer_id_idx" ON "orders"("customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_email_idx" ON "orders"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_status_placed_at_idx" ON "orders"("status", "placed_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "order_lines_order_id_idx" ON "order_lines"("order_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "order_lines_variant_id_idx" ON "order_lines"("variant_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Full-text search over the catalog, in PostgreSQL (ADR-0012).
|
||||
--
|
||||
-- Two extensions do the work that a dedicated search engine would otherwise be
|
||||
-- brought in for:
|
||||
-- unaccent — so "ao chay bo" finds "Áo Chạy Bộ". Vietnamese shoppers type
|
||||
-- without diacritics constantly; without this, most of them find
|
||||
-- nothing.
|
||||
-- pg_trgm — trigram similarity, which gives typo tolerance and partial-word
|
||||
-- matching that a tsquery alone cannot ("nocturn", "jacket run").
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- `unaccent()` is STABLE, not IMMUTABLE, because it depends on a dictionary that
|
||||
-- could in principle be changed. Postgres therefore refuses it in a generated
|
||||
-- column or an index. Pinning the dictionary by name makes the result genuinely
|
||||
-- immutable, which is the standard way around this.
|
||||
CREATE OR REPLACE FUNCTION immutable_unaccent(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
STRICT
|
||||
PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT public.unaccent('public.unaccent'::regdictionary, $1)
|
||||
$$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "search_documents" (
|
||||
"product_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"title" VARCHAR(255) NOT NULL,
|
||||
"keywords" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "search_documents_pkey" PRIMARY KEY ("product_id","locale")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "search_documents" ADD CONSTRAINT "search_documents_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- The searchable vector is GENERATED, not written by the application.
|
||||
--
|
||||
-- That is the whole point: an index maintained by hand drifts from the text it
|
||||
-- indexes the first time someone updates one and forgets the other. Here it
|
||||
-- cannot — the database recomputes it on every write to the source columns.
|
||||
--
|
||||
-- `simple` rather than `english`: the catalog is bilingual, and English
|
||||
-- stemming applied to Vietnamese produces nonsense. Weighting carries the
|
||||
-- relevance instead of stemming.
|
||||
ALTER TABLE "search_documents"
|
||||
ADD COLUMN "document" tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("title", ''))), 'A') ||
|
||||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("keywords", ''))), 'B') ||
|
||||
setweight(to_tsvector('simple', immutable_unaccent(coalesce("body", ''))), 'C')
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX "search_documents_document_idx" ON "search_documents" USING GIN ("document");
|
||||
|
||||
-- Trigram index over the high-signal text only. Including `body` would bloat it
|
||||
-- for matches nobody wants ranked by similarity anyway.
|
||||
CREATE INDEX "search_documents_trgm_idx" ON "search_documents"
|
||||
USING GIN ((immutable_unaccent("title") || ' ' || immutable_unaccent("keywords")) gin_trgm_ops);
|
||||
@@ -208,6 +208,7 @@ model Customer {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
addresses Address[]
|
||||
orders Order[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
@@ -504,6 +505,7 @@ model Product {
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
searchDocuments SearchDocument[]
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
options ProductOption[]
|
||||
@@ -601,6 +603,7 @@ model ProductVariant {
|
||||
optionValues ProductVariantOptionValue[]
|
||||
stockLevels StockLevel[]
|
||||
stockMovements StockMovement[]
|
||||
orderLines OrderLine[]
|
||||
|
||||
@@index([productId, position])
|
||||
@@index([status])
|
||||
@@ -864,3 +867,167 @@ model ProductAttributeTranslation {
|
||||
@@id([attributeId, locale])
|
||||
@@map("product_attribute_translations")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commerce — orders (M5)
|
||||
//
|
||||
// Carts are deliberately absent: a guest cart lives in Redis and holds only
|
||||
// variant ids and quantities (ADR-0010). Prices are recomputed from the catalog
|
||||
// on every read, so a cart can never carry a stale or tampered price into an
|
||||
// order.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum OrderStatus {
|
||||
/// Placed, awaiting payment. Stock is reserved from this moment.
|
||||
PENDING
|
||||
CONFIRMED
|
||||
FULFILLED
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
UNPAID
|
||||
PAID
|
||||
PARTIALLY_REFUNDED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum FulfillmentStatus {
|
||||
UNFULFILLED
|
||||
PARTIALLY_FULFILLED
|
||||
FULFILLED
|
||||
}
|
||||
|
||||
/// A placed order.
|
||||
///
|
||||
/// Every customer-facing and catalog-facing value is SNAPSHOT here rather than
|
||||
/// joined at read time. An order is a record of what was agreed, and it has to
|
||||
/// stay readable after the product is renamed, repriced, archived or the
|
||||
/// customer edits their address book. The `variantId` FK exists for reporting
|
||||
/// and returns, never for rendering the order.
|
||||
model Order {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
|
||||
/// Human-facing reference, formatted for display as "SP-000123". Kept as an
|
||||
/// integer so it is monotonic and cheap to look up; the prefix is
|
||||
/// presentation and lives in the mapper.
|
||||
number Int @unique @default(autoincrement())
|
||||
|
||||
/// Null for a guest checkout. Guests are identified by email + order number.
|
||||
customerId String? @map("customer_id") @db.Uuid
|
||||
|
||||
email String @db.VarChar(255)
|
||||
phone String @db.VarChar(20)
|
||||
|
||||
status OrderStatus @default(PENDING)
|
||||
paymentStatus PaymentStatus @default(UNPAID) @map("payment_status")
|
||||
fulfillmentStatus FulfillmentStatus @default(UNFULFILLED) @map("fulfillment_status")
|
||||
|
||||
/// ---- Money, integer minor units throughout (ADR-0011) ------------------
|
||||
currency Currency @default(VND)
|
||||
subtotalAmount Int @map("subtotal_amount")
|
||||
/// Zero until promotions land (M7); the column exists so the total is always
|
||||
/// the sum of named parts rather than an unexplained number.
|
||||
discountAmount Int @default(0) @map("discount_amount")
|
||||
shippingAmount Int @default(0) @map("shipping_amount")
|
||||
taxAmount Int @default(0) @map("tax_amount")
|
||||
totalAmount Int @map("total_amount")
|
||||
|
||||
/// ---- Shipping address, snapshot ----------------------------------------
|
||||
shipFullName String @map("ship_full_name") @db.VarChar(160)
|
||||
shipPhone String @map("ship_phone") @db.VarChar(20)
|
||||
shipLine1 String @map("ship_line1") @db.VarChar(255)
|
||||
shipLine2 String? @map("ship_line2") @db.VarChar(255)
|
||||
shipWard String? @map("ship_ward") @db.VarChar(120)
|
||||
shipDistrict String? @map("ship_district") @db.VarChar(120)
|
||||
shipProvince String @map("ship_province") @db.VarChar(120)
|
||||
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
|
||||
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
|
||||
|
||||
customerNote String? @map("customer_note") @db.VarChar(1000)
|
||||
|
||||
placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(3)
|
||||
confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(3)
|
||||
cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(3)
|
||||
cancelReason String? @map("cancel_reason") @db.VarChar(500)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
lines OrderLine[]
|
||||
|
||||
@@index([customerId])
|
||||
@@index([email])
|
||||
@@index([status, placedAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
/// One purchased variant, frozen at the moment of purchase.
|
||||
model OrderLine {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
orderId String @map("order_id") @db.Uuid
|
||||
|
||||
/// Nulled rather than cascading if a variant is ever hard-deleted — losing
|
||||
/// the reporting link is survivable, losing the order line is not.
|
||||
variantId String? @map("variant_id") @db.Uuid
|
||||
|
||||
/// ---- Snapshot ----------------------------------------------------------
|
||||
productName String @map("product_name") @db.VarChar(255)
|
||||
variantTitle String @map("variant_title") @db.VarChar(255)
|
||||
sku String @db.VarChar(64)
|
||||
imageUrl String? @map("image_url") @db.VarChar(500)
|
||||
|
||||
unitAmount Int @map("unit_amount")
|
||||
quantity Int
|
||||
lineAmount Int @map("line_amount")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([orderId])
|
||||
@@index([variantId])
|
||||
@@map("order_lines")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search (M6)
|
||||
//
|
||||
// Owned exclusively by SearchModule. It is a *projection*: every row is
|
||||
// derivable from the catalog and can be rebuilt from scratch at any time, which
|
||||
// is what lets the module be extracted later without taking catalog tables with
|
||||
// it (ADR-0012).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One searchable document per product per locale.
|
||||
///
|
||||
/// The text is split by weight rather than concatenated, because a match on a
|
||||
/// product's name should outrank a match buried in its description. The
|
||||
/// `document` column is GENERATED from these three by the database — see the
|
||||
/// migration — so an index can never drift from the text it indexes.
|
||||
model SearchDocument {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
/// Weight A — the product name.
|
||||
title String @db.VarChar(255)
|
||||
/// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal.
|
||||
keywords String
|
||||
/// Weight C — descriptions. Long, low-signal, still worth matching.
|
||||
body String
|
||||
|
||||
/// Maintained by Postgres from the columns above. Declared here only so
|
||||
/// Prisma knows it exists and leaves it alone; it is read and written with
|
||||
/// raw SQL in the repository.
|
||||
document Unsupported("tsvector")?
|
||||
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, locale])
|
||||
@@map("search_documents")
|
||||
}
|
||||
|
||||
@@ -312,6 +312,10 @@ async function seedProduct(
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
// Persisted, not just used to build the seed SKUs: adding a colourway to
|
||||
// a seeded product later has to extend the same SKU family, and without
|
||||
// this the write path falls back to the slug.
|
||||
skuPrefix: seed.skuPrefix,
|
||||
},
|
||||
create: {
|
||||
slug: seed.key,
|
||||
@@ -324,6 +328,7 @@ async function seedProduct(
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
skuPrefix: seed.skuPrefix,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* The guest cart identifier.
|
||||
*
|
||||
* An opaque random token in an httpOnly cookie. It names a Redis key and
|
||||
* nothing more — it grants no authority, carries no identity and is worthless
|
||||
* if leaked, which is exactly why a guest bag can work without an account.
|
||||
*
|
||||
* httpOnly anyway: the storefront never needs to read it, because every cart
|
||||
* operation goes through the API on the same origin (ADR-0015). Keeping it out
|
||||
* of `document.cookie` costs nothing and removes it from an XSS's reach.
|
||||
*/
|
||||
const COOKIE_NAME = 'sport_cart';
|
||||
|
||||
/** Matches the Redis TTL — a cookie that outlives its data is a phantom bag. */
|
||||
const MAX_AGE_MS = 1000 * 60 * 60 * 24 * 30;
|
||||
|
||||
export function readCartToken(request: Request): string | undefined {
|
||||
const cookies = request.cookies as Record<string, string> | undefined;
|
||||
return cookies?.[COOKIE_NAME];
|
||||
}
|
||||
|
||||
/** Reads the existing token or mints one, telling the caller which happened. */
|
||||
export function resolveCartToken(request: Request): { token: string; isNew: boolean } {
|
||||
const existing = readCartToken(request);
|
||||
return existing ? { token: existing, isNew: false } : { token: randomUUID(), isNew: true };
|
||||
}
|
||||
|
||||
export function setCartCookie(response: Response, token: string, isProduction: boolean): void {
|
||||
response.cookie(COOKIE_NAME, token, options(isProduction));
|
||||
}
|
||||
|
||||
export function clearCartCookie(response: Response, isProduction: boolean): void {
|
||||
response.clearCookie(COOKIE_NAME, { ...options(isProduction), maxAge: undefined });
|
||||
}
|
||||
|
||||
function options(isProduction: boolean): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
// Root path, unlike the refresh cookie: the cart is read on ordinary
|
||||
// catalog requests, not just on two auth endpoints.
|
||||
path: '/',
|
||||
maxAge: MAX_AGE_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type { Cart, Locale } from '@sport/types';
|
||||
import {
|
||||
addCartLineSchema,
|
||||
updateCartLineSchema,
|
||||
type AddCartLineInput,
|
||||
type UpdateCartLineInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { RequestLocale } from '@/common/i18n/locale.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
import { resolveCartToken, setCartCookie } from './cart-cookie';
|
||||
import { CartsService } from './carts.service';
|
||||
|
||||
/**
|
||||
* Cart endpoints are `@Public()`: a bag belongs to a browser, not an account.
|
||||
* Requiring sign-in to add an item is the single most reliable way to lose a
|
||||
* sale, and the cart token grants no authority beyond naming a Redis key.
|
||||
*/
|
||||
@ApiTags('cart')
|
||||
@Public()
|
||||
@Controller('cart')
|
||||
export class CartsController {
|
||||
constructor(
|
||||
private readonly service: CartsService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'The current bag, priced from the live catalog' })
|
||||
get(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
return this.service.get(this.token(request, response), locale);
|
||||
}
|
||||
|
||||
@Post('lines')
|
||||
@ApiOperation({ summary: 'Add a variant to the bag' })
|
||||
addLine(
|
||||
@Body(new ZodValidationPipe(addCartLineSchema)) body: AddCartLineInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
return this.service.addLine(this.token(request, response), body, locale);
|
||||
}
|
||||
|
||||
@Patch('lines/:variantId')
|
||||
@ApiOperation({ summary: 'Change a line quantity; zero removes it' })
|
||||
updateLine(
|
||||
@Param('variantId') variantId: string,
|
||||
@Body(new ZodValidationPipe(updateCartLineSchema)) body: UpdateCartLineInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
return this.service.updateLine(this.token(request, response), variantId, body, locale);
|
||||
}
|
||||
|
||||
@Delete('lines/:variantId')
|
||||
@ApiOperation({ summary: 'Remove a line' })
|
||||
removeLine(
|
||||
@Param('variantId') variantId: string,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
return this.service.removeLine(this.token(request, response), variantId, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the cart cookie, minting one on first contact.
|
||||
*
|
||||
* The cookie is (re)set on every request so an active bag keeps rolling its
|
||||
* thirty-day window forward rather than expiring under a shopper who has been
|
||||
* browsing all along.
|
||||
*/
|
||||
private token(request: Request, response: Response): string {
|
||||
const { token } = resolveCartToken(request);
|
||||
setCartCookie(response, token, this.config.app.isProduction);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MediaUrlModule } from '@/common/media/media.module';
|
||||
|
||||
import { CartsController } from './carts.controller';
|
||||
import { CartsService } from './carts.service';
|
||||
|
||||
/**
|
||||
* CartsModule — boundary declared, implementation pending.
|
||||
* CartsModule — owns the guest cart, which lives in Redis and holds only
|
||||
* variant ids and quantities (ADR-0010).
|
||||
*
|
||||
* Owns (exclusively): Redis (guest carts) + `carts`/`cart_items` once persisted — milestone 2
|
||||
*
|
||||
* Guest carts live in Redis keyed by an anonymous token; they are promoted to PostgreSQL on sign-in. Cart totals are always recomputed server-side from current variant prices — a client-submitted price is never trusted.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* carts.module.ts wiring only
|
||||
* carts.controller.ts HTTP surface, no logic
|
||||
* carts.service.ts business rules
|
||||
* carts.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* It owns no tables. It reads the catalog to price a bag, which is the one
|
||||
* cross-module read it needs, and exposes `resolveForCheckout` so the checkout
|
||||
* flow prices a cart through exactly the same code path the shopper saw.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [MediaUrlModule],
|
||||
controllers: [CartsController],
|
||||
providers: [CartsService],
|
||||
exports: [CartsService],
|
||||
})
|
||||
export class CartsModule {}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
CART_NOTICE_REASONS,
|
||||
type Cart,
|
||||
type CartLine,
|
||||
type CartNotice,
|
||||
type CartTotals,
|
||||
type CurrencyCode,
|
||||
type Locale,
|
||||
type Money,
|
||||
} from '@sport/types';
|
||||
import type { AddCartLineInput, UpdateCartLineInput } from '@sport/validation';
|
||||
import { MAX_LINE_QUANTITY } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { coalesceRequired, toDbLocale } from '@/common/i18n';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
/**
|
||||
* What actually lives in Redis.
|
||||
*
|
||||
* Variant ids and quantities. No prices, no names, no totals — everything a
|
||||
* shopper sees is recomputed from the catalog on every read. A cart can sit for
|
||||
* thirty days and still cannot carry a stale price into an order, and a forged
|
||||
* payload has nothing worth forging.
|
||||
*/
|
||||
interface StoredLine {
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
interface StoredCart {
|
||||
id: string;
|
||||
lines: StoredLine[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CartsService {
|
||||
private readonly logger = new Logger(CartsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly mediaUrl: MediaUrlService,
|
||||
) {}
|
||||
|
||||
async get(cartToken: string, locale: Locale): Promise<Cart> {
|
||||
return this.hydrate(cartToken, await this.read(cartToken), locale);
|
||||
}
|
||||
|
||||
async addLine(cartToken: string, input: AddCartLineInput, locale: Locale): Promise<Cart> {
|
||||
const stored = await this.read(cartToken);
|
||||
const existing = stored.lines.find((line) => line.variantId === input.variantId);
|
||||
|
||||
if (existing) {
|
||||
// Adding a variant already in the bag tops it up rather than creating a
|
||||
// second line — two "Black / M" rows is never what was meant.
|
||||
existing.quantity = Math.min(existing.quantity + input.quantity, MAX_LINE_QUANTITY);
|
||||
} else {
|
||||
stored.lines.push({
|
||||
variantId: input.variantId,
|
||||
quantity: input.quantity,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
|
||||
}
|
||||
|
||||
async updateLine(
|
||||
cartToken: string,
|
||||
variantId: string,
|
||||
input: UpdateCartLineInput,
|
||||
locale: Locale,
|
||||
): Promise<Cart> {
|
||||
const stored = await this.read(cartToken);
|
||||
const line = stored.lines.find((item) => item.variantId === variantId);
|
||||
|
||||
if (!line) throw AppException.notFound('Cart line');
|
||||
|
||||
if (input.quantity === 0) {
|
||||
stored.lines = stored.lines.filter((item) => item.variantId !== variantId);
|
||||
} else {
|
||||
line.quantity = input.quantity;
|
||||
}
|
||||
|
||||
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
|
||||
}
|
||||
|
||||
async removeLine(cartToken: string, variantId: string, locale: Locale): Promise<Cart> {
|
||||
const stored = await this.read(cartToken);
|
||||
stored.lines = stored.lines.filter((item) => item.variantId !== variantId);
|
||||
|
||||
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
|
||||
}
|
||||
|
||||
async clear(cartToken: string): Promise<void> {
|
||||
await this.redis.delete(CACHE_KEYS.guestCart(cartToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* The line set a checkout should act on, with prices the API computed.
|
||||
*
|
||||
* Checkout calls this rather than re-deriving totals, so the price a shopper
|
||||
* was shown and the price they are charged come from one code path.
|
||||
*/
|
||||
async resolveForCheckout(cartToken: string, locale: Locale): Promise<Cart> {
|
||||
const cart = await this.get(cartToken, locale);
|
||||
|
||||
if (cart.lines.length === 0) {
|
||||
throw AppException.badRequest('Your bag is empty.');
|
||||
}
|
||||
|
||||
return cart;
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
private async read(cartToken: string): Promise<StoredCart> {
|
||||
const stored = await this.redis.get<StoredCart>(CACHE_KEYS.guestCart(cartToken));
|
||||
|
||||
return stored ?? { id: cartToken, lines: [], updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
private async write(cartToken: string, cart: StoredCart): Promise<StoredCart> {
|
||||
const next: StoredCart = { ...cart, id: cartToken, updatedAt: new Date().toISOString() };
|
||||
|
||||
// Every write resets the TTL, so an active cart never expires under someone.
|
||||
await this.redis.set(CACHE_KEYS.guestCart(cartToken), next, CACHE_TTL.guestCart);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns stored ids into a priced cart, and prunes what is no longer buyable.
|
||||
*
|
||||
* The pruning is the important part. A variant can be archived, its product
|
||||
* unpublished or its stock sold out between two visits, and a cart that
|
||||
* quietly keeps the line produces a checkout that fails at the last step.
|
||||
* Instead the line is corrected here and a notice explains what changed.
|
||||
*/
|
||||
private async hydrate(cartToken: string, stored: StoredCart, locale: Locale): Promise<Cart> {
|
||||
if (stored.lines.length === 0) {
|
||||
return this.empty(cartToken, stored.updatedAt);
|
||||
}
|
||||
|
||||
const dbLocale = toDbLocale(locale);
|
||||
const variants = await this.prisma.productVariant.findMany({
|
||||
where: {
|
||||
id: { in: stored.lines.map((line) => line.variantId) },
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
product: { status: 'ACTIVE', deletedAt: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
title: true,
|
||||
currency: true,
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
optionValues: {
|
||||
select: {
|
||||
option: { select: { position: true } },
|
||||
optionValue: {
|
||||
select: {
|
||||
label: true,
|
||||
translations: { where: { locale: dbLocale }, select: { label: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
product: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
translations: { where: { locale: dbLocale }, select: { name: true, slug: true } },
|
||||
images: {
|
||||
orderBy: { position: 'asc' },
|
||||
take: 1,
|
||||
select: { media: { select: { storageKey: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const byId = new Map(variants.map((variant) => [variant.id, variant]));
|
||||
|
||||
const lines: CartLine[] = [];
|
||||
const notices: CartNotice[] = [];
|
||||
const keep: StoredLine[] = [];
|
||||
|
||||
for (const line of stored.lines) {
|
||||
const variant = byId.get(line.variantId);
|
||||
|
||||
if (!variant) {
|
||||
notices.push({
|
||||
reason: CART_NOTICE_REASONS.UNAVAILABLE,
|
||||
variantId: line.variantId,
|
||||
productName: '',
|
||||
previousQuantity: line.quantity,
|
||||
quantity: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The query filters to a single locale, so there is at most one row.
|
||||
const translation = variant.product.translations[0];
|
||||
const productName = coalesceRequired(translation?.name, variant.product.name);
|
||||
const available = variant.stockLevels.reduce(
|
||||
(total, level) => total + (level.onHand - level.reserved),
|
||||
0,
|
||||
);
|
||||
|
||||
if (available <= 0) {
|
||||
notices.push({
|
||||
reason: CART_NOTICE_REASONS.OUT_OF_STOCK,
|
||||
variantId: variant.id,
|
||||
productName,
|
||||
previousQuantity: line.quantity,
|
||||
quantity: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const quantity = Math.min(line.quantity, available, MAX_LINE_QUANTITY);
|
||||
|
||||
if (quantity < line.quantity) {
|
||||
notices.push({
|
||||
reason: CART_NOTICE_REASONS.QUANTITY_REDUCED,
|
||||
variantId: variant.id,
|
||||
productName,
|
||||
previousQuantity: line.quantity,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
const currency = variant.currency as CurrencyCode;
|
||||
const unit = variant.salePriceAmount ?? variant.priceAmount;
|
||||
|
||||
lines.push({
|
||||
variantId: variant.id,
|
||||
productName,
|
||||
productSlug: coalesceRequired(translation?.slug, variant.product.slug),
|
||||
variantTitle: variantTitle(variant),
|
||||
sku: variant.sku,
|
||||
imageUrl: variant.product.images[0]
|
||||
? this.mediaUrl.url(variant.product.images[0].media.storageKey)
|
||||
: null,
|
||||
unitPrice: { amount: unit, currency },
|
||||
compareAtPrice:
|
||||
variant.salePriceAmount !== null ? { amount: variant.priceAmount, currency } : null,
|
||||
quantity,
|
||||
lineTotal: { amount: unit * quantity, currency },
|
||||
maxQuantity: Math.min(available, MAX_LINE_QUANTITY),
|
||||
});
|
||||
|
||||
keep.push({ ...line, quantity });
|
||||
}
|
||||
|
||||
// Persist the pruning so the next read is clean and each notice is shown
|
||||
// once.
|
||||
//
|
||||
// Compared by variant, not by index: dropping a sold-out line shifts every
|
||||
// line after it, so an index-wise comparison against the pre-prune list is
|
||||
// reading a different product's quantity.
|
||||
const before = new Map(stored.lines.map((line) => [line.variantId, line.quantity]));
|
||||
const corrected =
|
||||
keep.length !== stored.lines.length ||
|
||||
keep.some((line) => before.get(line.variantId) !== line.quantity);
|
||||
|
||||
if (corrected) {
|
||||
await this.write(cartToken, { ...stored, lines: keep });
|
||||
this.logger.log(`Cart ${cartToken} corrected: ${notices.length} notice(s)`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: cartToken,
|
||||
lines,
|
||||
totals: this.totals(lines),
|
||||
notices,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private totals(lines: readonly CartLine[]): CartTotals {
|
||||
const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode;
|
||||
const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0);
|
||||
const money = (amount: number): Money => ({ amount, currency });
|
||||
|
||||
return {
|
||||
itemCount: lines.reduce((count, line) => count + line.quantity, 0),
|
||||
subtotal: money(subtotal),
|
||||
// Promotions are M7 and shipping is M9. Named zeroes rather than an
|
||||
// absent field, so the total is always the sum of its parts.
|
||||
discount: money(0),
|
||||
shipping: money(0),
|
||||
tax: money(0),
|
||||
total: money(subtotal),
|
||||
};
|
||||
}
|
||||
|
||||
private empty(cartToken: string, updatedAt: string): Cart {
|
||||
const money = (amount: number): Money => ({ amount, currency: 'VND' as CurrencyCode });
|
||||
|
||||
return {
|
||||
id: cartToken,
|
||||
lines: [],
|
||||
totals: {
|
||||
itemCount: 0,
|
||||
subtotal: money(0),
|
||||
discount: money(0),
|
||||
shipping: money(0),
|
||||
tax: money(0),
|
||||
total: money(0),
|
||||
},
|
||||
notices: [],
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The variant label a shopper should see, in their language.
|
||||
*
|
||||
* Composed from translated option values rather than read from
|
||||
* `ProductVariant.title`, which is the canonical internal label — the catalog
|
||||
* mapper already does exactly this, and a cart that skips it shows an English
|
||||
* reader "Đen / M" for the item they just added.
|
||||
*
|
||||
* Ordered by the option's position so the axes read consistently ("Black / M",
|
||||
* never "M / Black").
|
||||
*/
|
||||
function variantTitle(variant: {
|
||||
title: string;
|
||||
optionValues: {
|
||||
option: { position: number };
|
||||
optionValue: { label: string; translations: { label: string }[] };
|
||||
}[];
|
||||
}): string {
|
||||
const labels = [...variant.optionValues]
|
||||
.sort((a, b) => a.option.position - b.option.position)
|
||||
.map((link) => link.optionValue.translations[0]?.label ?? link.optionValue.label);
|
||||
|
||||
return labels.length > 0 ? labels.join(' / ') : variant.title;
|
||||
}
|
||||
@@ -7,4 +7,5 @@
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { CartsService } from '../carts.service';
|
||||
export { clearCartCookie, readCartToken, resolveCartToken, setCartCookie } from '../cart-cookie';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Body, Controller, Get, Inject, Param, Post, Query, Req, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type { Cart, Locale, Order } from '@sport/types';
|
||||
import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { RequestLocale } from '@/common/i18n/locale.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public';
|
||||
import { OrdersService } from '@/modules/orders/public';
|
||||
|
||||
import { CheckoutService } from './checkout.service';
|
||||
|
||||
/**
|
||||
* Public because guest checkout is the default. Customer accounts arrive in M8
|
||||
* and will attach an order to a customer, not gate the ability to place one.
|
||||
*/
|
||||
@ApiTags('checkout')
|
||||
@Public()
|
||||
@Controller('checkout')
|
||||
export class CheckoutController {
|
||||
constructor(
|
||||
private readonly service: CheckoutService,
|
||||
private readonly orders: OrdersService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@Get('quote')
|
||||
@ApiOperation({ summary: 'The bag as it will be charged' })
|
||||
quote(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Cart> {
|
||||
const { token } = resolveCartToken(request);
|
||||
setCartCookie(response, token, this.config.app.isProduction);
|
||||
|
||||
return this.service.quote(token, locale);
|
||||
}
|
||||
|
||||
@Post('orders')
|
||||
@ApiOperation({ summary: 'Place the order and reserve stock' })
|
||||
async placeOrder(
|
||||
@Body(new ZodValidationPipe(placeOrderSchema)) body: PlaceOrderInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<Order> {
|
||||
const { token } = resolveCartToken(request);
|
||||
const order = await this.service.placeOrder(token, body, locale);
|
||||
|
||||
// The bag is gone, so the cookie naming it should go too — otherwise the
|
||||
// next visit reads an empty cart under a stale token forever.
|
||||
clearCartCookie(response, this.config.app.isProduction);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
@Get('orders/lookup')
|
||||
@ApiOperation({ summary: 'Find a placed order by number and email' })
|
||||
lookup(@Query('orderNumber') orderNumber: string, @Query('email') email: string): Promise<Order> {
|
||||
return this.orders.lookup(orderNumber ?? '', email ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation lookup by id — a capability URL.
|
||||
*
|
||||
* The id is a UUIDv7: not sequential, not enumerable, and not derivable from
|
||||
* the order number. Holding it is the authorisation, which is what lets a
|
||||
* guest see their own order without an account.
|
||||
*
|
||||
* It exists so the confirmation page need not carry an email address in its
|
||||
* query string, where it would sit in browser history and ride along in the
|
||||
* `Referer` of every outbound request the page makes.
|
||||
*
|
||||
* Declared after `orders/lookup` so that literal path never matches here.
|
||||
*/
|
||||
@Get('orders/:id')
|
||||
@ApiOperation({ summary: 'A placed order, by its unguessable id' })
|
||||
getById(@Param('id') id: string): Promise<Order> {
|
||||
return this.orders.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CartsModule } from '@/modules/carts/carts.module';
|
||||
import { OrdersModule } from '@/modules/orders/orders.module';
|
||||
|
||||
import { CheckoutController } from './checkout.controller';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
|
||||
/**
|
||||
* CheckoutModule — boundary declared, implementation pending.
|
||||
* CheckoutModule — owns no tables.
|
||||
*
|
||||
* Owns (exclusively): Checkout sessions (Redis, short TTL)
|
||||
*
|
||||
* Orchestrates the cart → stock reservation → payment intent → order transition. The only module allowed to coordinate across contexts, and it does so through public services and events.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* checkout.module.ts wiring only
|
||||
* checkout.controller.ts HTTP surface, no logic
|
||||
* checkout.service.ts business rules
|
||||
* checkout.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* It is the coordination point between cart, catalog and inventory, and exists
|
||||
* as its own module precisely so that coordination has one home rather than
|
||||
* being smeared across the two sides. Payment providers (M9) attach here.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [CartsModule, OrdersModule],
|
||||
controllers: [CheckoutController],
|
||||
providers: [CheckoutService],
|
||||
exports: [CheckoutService],
|
||||
})
|
||||
export class CheckoutModule {}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { Cart, Locale, Order } from '@sport/types';
|
||||
import type { PlaceOrderInput } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { CartsService } from '@/modules/carts/public';
|
||||
import { OrdersService } from '@/modules/orders/public';
|
||||
|
||||
/**
|
||||
* Turns a bag into an order.
|
||||
*
|
||||
* This module owns no tables. It is the one place that coordinates three others
|
||||
* — cart, catalog and inventory — and that coordination is the reason it exists
|
||||
* as its own module rather than as a method on either side.
|
||||
*
|
||||
* The whole placement is a single transaction. A half-placed order is worse
|
||||
* than a failed one: the shopper sees an error, retries, and either pays twice
|
||||
* or holds stock nobody will ever ship.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CheckoutService {
|
||||
private readonly logger = new Logger(CheckoutService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly carts: CartsService,
|
||||
private readonly orders: OrdersService,
|
||||
) {}
|
||||
|
||||
/** What the shopper is about to agree to. Priced by the cart, never by the client. */
|
||||
quote(cartToken: string, locale: Locale): Promise<Cart> {
|
||||
return this.carts.resolveForCheckout(cartToken, locale);
|
||||
}
|
||||
|
||||
async placeOrder(cartToken: string, input: PlaceOrderInput, locale: Locale): Promise<Order> {
|
||||
const cart = await this.carts.resolveForCheckout(cartToken, locale);
|
||||
|
||||
// A cart that had to correct itself is not one to charge against — the
|
||||
// shopper is looking at a total that just changed underneath them.
|
||||
if (cart.notices.length > 0) {
|
||||
throw AppException.badRequest(
|
||||
'Your bag changed while you were checking out. Review it and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const orderId = await this.prisma.$transaction(async (tx) => {
|
||||
/**
|
||||
* Reserve with a conditional UPDATE, and treat "no rows changed" as
|
||||
* "someone else got there first".
|
||||
*
|
||||
* This must NOT be read-then-write. Reading availability and then writing
|
||||
* `reserved + n` is a lost update: two shoppers both read `reserved = 0`,
|
||||
* both write `1`, and a single unit of stock is sold twice with the
|
||||
* reservation count showing one. That is not theoretical — it was
|
||||
* reproduced with two concurrent checkouts against one unit, and both
|
||||
* orders were created.
|
||||
*
|
||||
* A single statement carrying its own guard is safe under Postgres's
|
||||
* default READ COMMITTED: the second writer blocks on the row lock, then
|
||||
* re-evaluates `on_hand - reserved >= n` against the row the first writer
|
||||
* committed, and matches nothing.
|
||||
*/
|
||||
for (const line of cart.lines) {
|
||||
const level = await tx.stockLevel.findFirst({
|
||||
where: { variantId: line.variantId },
|
||||
select: { variantId: true, locationId: true, onHand: true, reserved: true },
|
||||
});
|
||||
|
||||
// Picking *which* location to draw from stays a plain read — multi-
|
||||
// location allocation is an open question (see docs/adr/README.md).
|
||||
// What has to be atomic is the reservation itself.
|
||||
const reserved = level
|
||||
? await tx.$executeRaw`
|
||||
UPDATE stock_levels
|
||||
SET reserved = reserved + ${line.quantity}
|
||||
WHERE variant_id = ${level.variantId}::uuid
|
||||
AND location_id = ${level.locationId}::uuid
|
||||
AND on_hand - reserved >= ${line.quantity}
|
||||
`
|
||||
: 0;
|
||||
|
||||
if (reserved === 0) {
|
||||
const available = level ? Math.max(0, level.onHand - level.reserved) : 0;
|
||||
|
||||
throw AppException.conflict(
|
||||
`${line.productName} (${line.variantTitle}) only has ${available} left.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
phone: input.shippingAddress.phone,
|
||||
|
||||
subtotalAmount: cart.totals.subtotal.amount,
|
||||
discountAmount: cart.totals.discount.amount,
|
||||
shippingAmount: cart.totals.shipping.amount,
|
||||
taxAmount: cart.totals.tax.amount,
|
||||
totalAmount: cart.totals.total.amount,
|
||||
|
||||
shipFullName: input.shippingAddress.fullName,
|
||||
shipPhone: input.shippingAddress.phone,
|
||||
shipLine1: input.shippingAddress.line1,
|
||||
shipLine2: input.shippingAddress.line2 ?? null,
|
||||
shipWard: input.shippingAddress.ward ?? null,
|
||||
shipDistrict: input.shippingAddress.district ?? null,
|
||||
shipProvince: input.shippingAddress.province,
|
||||
shipCountryCode: input.shippingAddress.countryCode,
|
||||
shipPostalCode: input.shippingAddress.postalCode ?? null,
|
||||
|
||||
customerNote: input.customerNote ?? null,
|
||||
|
||||
lines: {
|
||||
create: cart.lines.map((line) => ({
|
||||
variantId: line.variantId,
|
||||
// Snapshot. The order must stay readable after the catalog moves
|
||||
// on — renamed, repriced, archived or all three.
|
||||
productName: line.productName,
|
||||
variantTitle: line.variantTitle,
|
||||
sku: line.sku,
|
||||
imageUrl: line.imageUrl,
|
||||
unitAmount: line.unitPrice.amount,
|
||||
quantity: line.quantity,
|
||||
lineAmount: line.lineTotal.amount,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: { id: true, number: true },
|
||||
});
|
||||
|
||||
return order.id;
|
||||
});
|
||||
|
||||
// Only once the order is durably committed. Clearing first would lose a
|
||||
// shopper's bag to a failed transaction.
|
||||
await this.carts.clear(cartToken);
|
||||
|
||||
this.logger.log(`Order ${orderId} placed with ${cart.lines.length} line(s)`);
|
||||
return this.orders.getById(orderId);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,4 @@
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { CheckoutService } from '../checkout.service';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ORDER_STATUSES, type OrderStatus } from '@sport/types';
|
||||
|
||||
/**
|
||||
* The transition table, asserted as a table.
|
||||
*
|
||||
* It lives in `orders.service.ts` and is duplicated in the admin UI to decide
|
||||
* which buttons to render. Two copies of a rule need the rule written down
|
||||
* somewhere that fails loudly when one of them drifts — and a wrong transition
|
||||
* is not cosmetic: `CANCELLED` releases reserved stock and `FULFILLED` ships it,
|
||||
* so a path that should not exist moves real inventory.
|
||||
*/
|
||||
const ALLOWED: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||
PENDING: [ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.CANCELLED],
|
||||
CONFIRMED: [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.CANCELLED],
|
||||
FULFILLED: [ORDER_STATUSES.COMPLETED],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
const ALL = Object.values(ORDER_STATUSES);
|
||||
|
||||
describe('order status transitions', () => {
|
||||
it('never lets a terminal order move again', () => {
|
||||
expect(ALLOWED.COMPLETED).toHaveLength(0);
|
||||
expect(ALLOWED.CANCELLED).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only reaches FULFILLED from CONFIRMED', () => {
|
||||
const sources = ALL.filter((from) => ALLOWED[from].includes(ORDER_STATUSES.FULFILLED));
|
||||
// Stock is shipped on this edge; more than one way in means more than one
|
||||
// place that has to get the ledger right.
|
||||
expect(sources).toEqual([ORDER_STATUSES.CONFIRMED]);
|
||||
});
|
||||
|
||||
it('allows cancelling only while nothing has shipped', () => {
|
||||
const sources = ALL.filter((from) => ALLOWED[from].includes(ORDER_STATUSES.CANCELLED));
|
||||
expect(sources.sort()).toEqual([ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.PENDING].sort());
|
||||
});
|
||||
|
||||
it('has no self-transitions and no cycles back to PENDING', () => {
|
||||
for (const from of ALL) {
|
||||
expect(ALLOWED[from]).not.toContain(from);
|
||||
expect(ALLOWED[from]).not.toContain(ORDER_STATUSES.PENDING);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AuthenticatedActor,
|
||||
type OffsetPaginated,
|
||||
type Order,
|
||||
type OrderListItem,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
orderListQuerySchema,
|
||||
updateOrderStatusSchema,
|
||||
type OrderListQuery,
|
||||
type UpdateOrderStatusInput,
|
||||
} 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 { OrdersService } from './orders.service';
|
||||
|
||||
@ApiTags('admin/orders')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/orders')
|
||||
export class OrdersAdminController {
|
||||
constructor(private readonly service: OrdersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.ORDER_READ)
|
||||
@ApiOperation({ summary: 'Orders, newest first' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(orderListQuerySchema)) query: OrderListQuery,
|
||||
): Promise<OffsetPaginated<OrderListItem>> {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.ORDER_READ)
|
||||
@ApiOperation({ summary: 'One order with its lines' })
|
||||
getById(@Param('id') id: string): Promise<Order> {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermissions(PERMISSIONS.ORDER_UPDATE)
|
||||
@ApiOperation({ summary: 'Move an order through its lifecycle' })
|
||||
updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateOrderStatusSchema)) body: UpdateOrderStatusInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<Order> {
|
||||
return this.service.updateStatus(id, body, actor.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { formatOrderNumber, parseOrderNumber } from './orders.mapper';
|
||||
|
||||
/**
|
||||
* The display number and the stored integer must round-trip.
|
||||
*
|
||||
* `parseOrderNumber` is what the admin search and the guest lookup both run on
|
||||
* whatever a human typed, and a lookup that silently fails to parse looks
|
||||
* exactly like "that order does not exist" — the same response the API gives
|
||||
* for a wrong email, deliberately. So the parsing has to be right, because
|
||||
* nothing downstream can tell you it was wrong.
|
||||
*/
|
||||
describe('order numbers', () => {
|
||||
it('formats with a stable prefix and width', () => {
|
||||
expect(formatOrderNumber(1)).toBe('SP-000001');
|
||||
expect(formatOrderNumber(123456)).toBe('SP-123456');
|
||||
});
|
||||
|
||||
it('keeps growing past the padding rather than truncating', () => {
|
||||
expect(formatOrderNumber(1234567)).toBe('SP-1234567');
|
||||
});
|
||||
|
||||
it('accepts every form a customer might paste back', () => {
|
||||
for (const input of ['SP-000123', 'sp-000123', 'SP123', '123', ' SP-123 ']) {
|
||||
expect(parseOrderNumber(input)).toBe(123);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects anything that is not a positive order number', () => {
|
||||
for (const input of ['', 'SP-', 'abc', '0', '-5', 'SP-abc']) {
|
||||
expect(parseOrderNumber(input)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips', () => {
|
||||
for (const value of [1, 42, 999, 1_000_000]) {
|
||||
expect(parseOrderNumber(formatOrderNumber(value))).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
CurrencyCode,
|
||||
FulfillmentStatus,
|
||||
Money,
|
||||
Order,
|
||||
OrderLine,
|
||||
OrderListItem,
|
||||
OrderStatus,
|
||||
PaymentStatus,
|
||||
} from '@sport/types';
|
||||
|
||||
import type { OrderDetailRow, OrderListRow } from './orders.repository';
|
||||
|
||||
/** Display prefix. Stored as an integer so it stays monotonic and cheap. */
|
||||
export function formatOrderNumber(value: number): string {
|
||||
return `SP-${String(value).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/** Parses "SP-000123", "sp-123" or "123" back to the stored integer. */
|
||||
export function parseOrderNumber(value: string): number | null {
|
||||
const digits = value.trim().replace(/^SP-?/i, '');
|
||||
const parsed = Number.parseInt(digits, 10);
|
||||
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrdersMapper {
|
||||
toOrder(row: OrderDetailRow): Order {
|
||||
const currency = row.currency as CurrencyCode;
|
||||
const money = (amount: number): Money => ({ amount, currency });
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
orderNumber: formatOrderNumber(row.number),
|
||||
status: row.status as OrderStatus,
|
||||
paymentStatus: row.paymentStatus as PaymentStatus,
|
||||
fulfillmentStatus: row.fulfillmentStatus as FulfillmentStatus,
|
||||
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
shippingAddress: {
|
||||
fullName: row.shipFullName,
|
||||
phone: row.shipPhone,
|
||||
line1: row.shipLine1,
|
||||
line2: row.shipLine2,
|
||||
ward: row.shipWard,
|
||||
district: row.shipDistrict,
|
||||
province: row.shipProvince,
|
||||
countryCode: row.shipCountryCode,
|
||||
postalCode: row.shipPostalCode,
|
||||
},
|
||||
customerNote: row.customerNote,
|
||||
|
||||
currency,
|
||||
subtotal: money(row.subtotalAmount),
|
||||
discount: money(row.discountAmount),
|
||||
shipping: money(row.shippingAmount),
|
||||
tax: money(row.taxAmount),
|
||||
total: money(row.totalAmount),
|
||||
|
||||
lines: row.lines.map((line) => this.toLine(line, currency)),
|
||||
|
||||
placedAt: row.placedAt.toISOString(),
|
||||
confirmedAt: row.confirmedAt?.toISOString() ?? null,
|
||||
cancelledAt: row.cancelledAt?.toISOString() ?? null,
|
||||
cancelReason: row.cancelReason,
|
||||
};
|
||||
}
|
||||
|
||||
toListItem(row: OrderListRow): OrderListItem {
|
||||
const currency = row.currency as CurrencyCode;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
orderNumber: formatOrderNumber(row.number),
|
||||
status: row.status as OrderStatus,
|
||||
paymentStatus: row.paymentStatus as PaymentStatus,
|
||||
fulfillmentStatus: row.fulfillmentStatus as FulfillmentStatus,
|
||||
email: row.email,
|
||||
customerName: row.shipFullName,
|
||||
itemCount: row.lines.reduce((count, line) => count + line.quantity, 0),
|
||||
total: { amount: row.totalAmount, currency },
|
||||
placedAt: row.placedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toLine(line: OrderDetailRow['lines'][number], currency: CurrencyCode): OrderLine {
|
||||
return {
|
||||
id: line.id,
|
||||
variantId: line.variantId,
|
||||
productName: line.productName,
|
||||
variantTitle: line.variantTitle,
|
||||
sku: line.sku,
|
||||
imageUrl: line.imageUrl,
|
||||
unitPrice: { amount: line.unitAmount, currency },
|
||||
quantity: line.quantity,
|
||||
lineTotal: { amount: line.lineAmount, currency },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrdersAdminController } from './orders.controller';
|
||||
import { OrdersMapper } from './orders.mapper';
|
||||
import { OrdersRepository } from './orders.repository';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
/**
|
||||
* OrdersModule — boundary declared, implementation pending.
|
||||
* OrdersModule — owns `orders` and `order_lines`.
|
||||
*
|
||||
* Owns (exclusively): `orders`, `order_items`, `order_status_history` — milestone 2
|
||||
* Every catalog value on an order is a snapshot, so this module reads no other
|
||||
* module's tables to render one. It writes stock levels only through the
|
||||
* lifecycle transitions that legitimately move stock (cancel releases, fulfil
|
||||
* ships), and every such write lands in the inventory ledger.
|
||||
*
|
||||
* Order lines snapshot product name, variant title and price at purchase time. Never join to the live catalog for historical orders: yesterday’s receipt must not change when a price does.
|
||||
*
|
||||
* 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):
|
||||
* orders.module.ts wiring only
|
||||
* orders.controller.ts HTTP surface, no logic
|
||||
* orders.service.ts business rules
|
||||
* orders.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* EXTRACTION CANDIDATE.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [OrdersAdminController],
|
||||
providers: [OrdersService, OrdersRepository, OrdersMapper],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const lineSelect = {
|
||||
id: true,
|
||||
variantId: true,
|
||||
productName: true,
|
||||
variantTitle: true,
|
||||
sku: true,
|
||||
imageUrl: true,
|
||||
unitAmount: true,
|
||||
quantity: true,
|
||||
lineAmount: true,
|
||||
} as const;
|
||||
|
||||
const detailSelect = {
|
||||
id: true,
|
||||
number: true,
|
||||
status: true,
|
||||
paymentStatus: true,
|
||||
fulfillmentStatus: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
currency: true,
|
||||
subtotalAmount: true,
|
||||
discountAmount: true,
|
||||
shippingAmount: true,
|
||||
taxAmount: true,
|
||||
totalAmount: true,
|
||||
shipFullName: true,
|
||||
shipPhone: true,
|
||||
shipLine1: true,
|
||||
shipLine2: true,
|
||||
shipWard: true,
|
||||
shipDistrict: true,
|
||||
shipProvince: true,
|
||||
shipCountryCode: true,
|
||||
shipPostalCode: true,
|
||||
customerNote: true,
|
||||
placedAt: true,
|
||||
confirmedAt: true,
|
||||
cancelledAt: true,
|
||||
cancelReason: true,
|
||||
lines: { orderBy: { createdAt: 'asc' }, select: lineSelect },
|
||||
} as const;
|
||||
|
||||
const listSelect = {
|
||||
id: true,
|
||||
number: true,
|
||||
status: true,
|
||||
paymentStatus: true,
|
||||
fulfillmentStatus: true,
|
||||
email: true,
|
||||
shipFullName: true,
|
||||
currency: true,
|
||||
totalAmount: true,
|
||||
placedAt: true,
|
||||
lines: { select: { quantity: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class OrdersRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.order.findUnique({ where: { id }, select: detailSelect });
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest lookup: order number AND the email it was placed with.
|
||||
*
|
||||
* Two factors on purpose. An order number alone is guessable — they are
|
||||
* sequential — and an order contains a name, a phone number and a home
|
||||
* address.
|
||||
*/
|
||||
findByNumberAndEmail(number: number, email: string) {
|
||||
return this.prisma.order.findFirst({
|
||||
where: { number, email: email.trim().toLowerCase() },
|
||||
select: detailSelect,
|
||||
});
|
||||
}
|
||||
|
||||
list(where: Prisma.OrderWhereInput, skip: number, take: number) {
|
||||
return Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: listSelect,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export type OrderDetailRow = NonNullable<Awaited<ReturnType<OrdersRepository['findById']>>>;
|
||||
export type OrderListRow = Awaited<ReturnType<OrdersRepository['list']>>[0][number];
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
ORDER_STATUSES,
|
||||
type OffsetPaginated,
|
||||
type Order,
|
||||
type OrderListItem,
|
||||
type OrderStatus,
|
||||
} from '@sport/types';
|
||||
import type { OrderListQuery, UpdateOrderStatusInput } 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 { OrdersMapper, parseOrderNumber } from './orders.mapper';
|
||||
import { OrdersRepository } from './orders.repository';
|
||||
|
||||
/**
|
||||
* Which transitions are legal.
|
||||
*
|
||||
* Written out rather than left to `if` statements at each call site: an order's
|
||||
* status drives stock, refunds and what the customer is told, and "how did this
|
||||
* order get from CANCELLED back to CONFIRMED?" is a question worth making
|
||||
* unanswerable by construction.
|
||||
*/
|
||||
const ALLOWED_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||
PENDING: [ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.CANCELLED],
|
||||
CONFIRMED: [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.CANCELLED],
|
||||
FULFILLED: [ORDER_STATUSES.COMPLETED],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: OrdersRepository,
|
||||
private readonly mapper: OrdersMapper,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async getById(id: string): Promise<Order> {
|
||||
const row = await this.repository.findById(id);
|
||||
if (!row) throw AppException.notFound('Order');
|
||||
|
||||
return this.mapper.toOrder(row);
|
||||
}
|
||||
|
||||
/** Guest order lookup — see `findByNumberAndEmail` for why both are required. */
|
||||
async lookup(orderNumber: string, email: string): Promise<Order> {
|
||||
const number = parseOrderNumber(orderNumber);
|
||||
if (number === null) throw AppException.notFound('Order');
|
||||
|
||||
const row = await this.repository.findByNumberAndEmail(number, email);
|
||||
// Deliberately the same error as a bad number: distinguishing "wrong email"
|
||||
// from "no such order" would turn this into an order-number oracle.
|
||||
if (!row) throw AppException.notFound('Order');
|
||||
|
||||
return this.mapper.toOrder(row);
|
||||
}
|
||||
|
||||
async list(query: OrderListQuery): Promise<OffsetPaginated<OrderListItem>> {
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.q
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
{ shipFullName: { contains: query.q, mode: 'insensitive' } },
|
||||
{ shipPhone: { contains: query.q } },
|
||||
...(parseOrderNumber(query.q) !== null
|
||||
? [{ number: parseOrderNumber(query.q) as number }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, totalItems] = await this.repository.list(
|
||||
where,
|
||||
(query.page - 1) * query.perPage,
|
||||
query.perPage,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an order through its lifecycle, releasing stock when it dies.
|
||||
*
|
||||
* Cancelling is the case that matters. The reservation taken at checkout is
|
||||
* held against `StockLevel.reserved`, and an order that ends without ever
|
||||
* shipping has to give those units back — otherwise every abandoned order
|
||||
* permanently shrinks sellable stock.
|
||||
*/
|
||||
async updateStatus(
|
||||
id: string,
|
||||
input: UpdateOrderStatusInput,
|
||||
actorUserId: string,
|
||||
): Promise<Order> {
|
||||
const existing = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
lines: { select: { variantId: true, quantity: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) throw AppException.notFound('Order');
|
||||
|
||||
const from = existing.status as OrderStatus;
|
||||
const to = input.status;
|
||||
|
||||
if (from === to) return this.getById(id);
|
||||
|
||||
if (!ALLOWED_TRANSITIONS[from].includes(to)) {
|
||||
throw AppException.badRequest(`An order cannot go from ${from} to ${to}.`);
|
||||
}
|
||||
|
||||
if (to === ORDER_STATUSES.CANCELLED && !input.reason?.trim()) {
|
||||
throw AppException.badRequest('Give a reason when cancelling an order.');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
/**
|
||||
* Compare-and-set on the status we validated against.
|
||||
*
|
||||
* The transition was checked from a row read outside this transaction, so
|
||||
* two operators clicking "Cancel" at once would both pass that check and
|
||||
* both release the reservation — returning twice the stock that was ever
|
||||
* held. Scoping the update to `status: from` means exactly one of them
|
||||
* matches a row.
|
||||
*/
|
||||
const changed = await tx.order.updateMany({
|
||||
where: { id, status: from },
|
||||
data: {
|
||||
status: to,
|
||||
...(to === ORDER_STATUSES.CONFIRMED ? { confirmedAt: new Date() } : {}),
|
||||
...(to === ORDER_STATUSES.CANCELLED
|
||||
? { cancelledAt: new Date(), cancelReason: input.reason?.trim() ?? null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (changed.count === 0) {
|
||||
throw AppException.conflict('This order was already updated by someone else.');
|
||||
}
|
||||
|
||||
if (to === ORDER_STATUSES.CANCELLED) {
|
||||
await this.releaseReservations(tx, existing.lines);
|
||||
}
|
||||
|
||||
if (to === ORDER_STATUSES.FULFILLED) {
|
||||
await this.commitReservations(tx, id, existing.lines, actorUserId);
|
||||
}
|
||||
});
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: `order.${to.toLowerCase()}`,
|
||||
resourceType: 'Order',
|
||||
resourceId: id,
|
||||
changes: { from, to, reason: input.reason ?? null },
|
||||
});
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hands reserved units back without touching `onHand` — nothing shipped.
|
||||
*
|
||||
* Arithmetic in SQL, not in JavaScript, for the same reason checkout reserves
|
||||
* that way: read-then-write loses concurrent updates. `GREATEST(…, 0)` keeps
|
||||
* a double-release from manufacturing stock out of nothing.
|
||||
*/
|
||||
private async releaseReservations(
|
||||
tx: Prisma.TransactionClient,
|
||||
lines: readonly { variantId: string | null; quantity: number }[],
|
||||
): Promise<void> {
|
||||
for (const line of lines) {
|
||||
if (!line.variantId) continue;
|
||||
|
||||
await tx.$executeRaw`
|
||||
UPDATE stock_levels
|
||||
SET reserved = GREATEST(reserved - ${line.quantity}, 0)
|
||||
WHERE variant_id = ${line.variantId}::uuid
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a reservation into a shipment: `onHand` falls, `reserved` falls with
|
||||
* it, and the ledger records why.
|
||||
*
|
||||
* This is the only place stock leaves the building, and it writes a
|
||||
* `StockMovement` because the level is a projection of that ledger — a
|
||||
* decrement without an entry is exactly the discrepancy the ledger exists to
|
||||
* make answerable.
|
||||
*/
|
||||
private async commitReservations(
|
||||
tx: Prisma.TransactionClient,
|
||||
orderId: string,
|
||||
lines: readonly { variantId: string | null; quantity: number }[],
|
||||
actorUserId: string,
|
||||
): Promise<void> {
|
||||
for (const line of lines) {
|
||||
if (!line.variantId) continue;
|
||||
|
||||
const level = await tx.stockLevel.findFirst({
|
||||
where: { variantId: line.variantId },
|
||||
select: { variantId: true, locationId: true },
|
||||
});
|
||||
if (!level) continue;
|
||||
|
||||
// Both counters move in one statement, computed from the row's own
|
||||
// current values rather than from ones read a moment ago.
|
||||
await tx.$executeRaw`
|
||||
UPDATE stock_levels
|
||||
SET on_hand = GREATEST(on_hand - ${line.quantity}, 0),
|
||||
reserved = GREATEST(reserved - ${line.quantity}, 0)
|
||||
WHERE variant_id = ${level.variantId}::uuid
|
||||
AND location_id = ${level.locationId}::uuid
|
||||
`;
|
||||
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
variantId: level.variantId,
|
||||
locationId: level.locationId,
|
||||
reason: 'SALE',
|
||||
quantityDelta: -line.quantity,
|
||||
referenceId: orderId,
|
||||
createdByUserId: actorUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { OrdersService } from '../orders.service';
|
||||
export { formatOrderNumber, parseOrderNumber } from '../orders.mapper';
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event';
|
||||
import { EventBusService } from '@/infrastructure/events/event-bus.service';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
@@ -38,6 +40,7 @@ export class ProductsAdminService {
|
||||
private readonly mapper: ProductsAdminMapper,
|
||||
private readonly redis: RedisService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly events: EventBusService,
|
||||
) {}
|
||||
|
||||
// ---- Reads ---------------------------------------------------------------
|
||||
@@ -807,6 +810,20 @@ export class ProductsAdminService {
|
||||
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
|
||||
this.logger.log(`${action} on ${productId}; dropped ${dropped} catalog cache key(s)`);
|
||||
|
||||
/**
|
||||
* Announce the change; search reindexes itself.
|
||||
*
|
||||
* An event rather than a direct call, because calling SearchModule from
|
||||
* here would make ProductsModule depend on it while SearchModule already
|
||||
* depends on ProductsModule — a cycle Nest cannot wire. It is also the rule
|
||||
* this codebase already states: needing an *answer* is a service call,
|
||||
* needing something to *react* is an event (see infrastructure/events).
|
||||
*
|
||||
* The consequence is honest eventual consistency: the index trails a write
|
||||
* by a tick.
|
||||
*/
|
||||
this.events.publish(DOMAIN_EVENTS.PRODUCT_UPDATED, { productId });
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action,
|
||||
|
||||
@@ -144,6 +144,8 @@ const detailSelect = {
|
||||
export interface ResolvedFilter extends ProductFilter {
|
||||
/** Materialised path of the requested category, if it resolved. */
|
||||
categoryPath?: string | null;
|
||||
/** Restricts the result set to a specific id list — used by ranked search. */
|
||||
ids?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -215,6 +217,10 @@ export class ProductsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.ids) {
|
||||
and.push({ id: { in: filter.ids } });
|
||||
}
|
||||
|
||||
if (filter.gender?.length) {
|
||||
and.push({ genderTargets: { hasSome: filter.gender } });
|
||||
}
|
||||
@@ -351,6 +357,38 @@ export class ProductsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** Same payload as `findList`, without paging — the caller already has the order. */
|
||||
findByIds(where: Prisma.ProductWhereInput, locale: Locale) {
|
||||
return this.prisma.product.findMany({
|
||||
where,
|
||||
select: {
|
||||
...listSelect,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
brand: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
where: { key: 'colour' },
|
||||
select: {
|
||||
...optionSelect,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
values: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
...optionSelect.values.select,
|
||||
translations: { where: { locale: toDbLocale(locale) } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
count(where: Prisma.ProductWhereInput) {
|
||||
return this.prisma.product.count({ where });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { CategoriesService } from '@/modules/categories/public';
|
||||
|
||||
import { ProductsMapper } from './products.mapper';
|
||||
import { ProductsRepository, type ResolvedFilter } from './products.repository';
|
||||
import {
|
||||
ProductsRepository,
|
||||
type ProductListRow,
|
||||
type ResolvedFilter,
|
||||
} from './products.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
@@ -94,6 +98,93 @@ export class ProductsService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a ranked id list as a listing.
|
||||
*
|
||||
* Search owns the ranking; the catalog owns what a product looks like. This
|
||||
* is the join between the two, and it preserves the given order unless the
|
||||
* caller explicitly asked for a different one.
|
||||
*
|
||||
* The visibility rules still apply: an id that search returns for a product
|
||||
* which has since been unpublished simply does not come back.
|
||||
*/
|
||||
async listByIds(
|
||||
ids: readonly string[],
|
||||
filter: ProductFilter,
|
||||
locale: Locale,
|
||||
): Promise<ProductListResult> {
|
||||
if (ids.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
pageInfo: { nextCursor: null, hasNextPage: false },
|
||||
totalCount: 0,
|
||||
facets: { brands: [], colors: [], sizes: [], priceRange: null },
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await this.resolveFilter(filter, locale);
|
||||
|
||||
/**
|
||||
* `q` is dropped, deliberately.
|
||||
*
|
||||
* The search provider has already applied it — with diacritic folding,
|
||||
* trigram tolerance, and matching across brand, SKU and colourway. Letting
|
||||
* the catalog re-apply it as a plain `name CONTAINS q` intersects all of
|
||||
* that away: searching "velocity" found the right products by brand and
|
||||
* then discarded every one of them, because the brand is not in the name.
|
||||
*/
|
||||
const scoped = { ...resolved, q: undefined, ids: [...ids] };
|
||||
|
||||
const where = this.repository.buildWhere(scoped, locale);
|
||||
const contextWhere = this.repository.buildContextWhere(scoped, locale);
|
||||
|
||||
const [rows, totalCount, facets] = await Promise.all([
|
||||
this.repository.findByIds(where, locale),
|
||||
this.repository.count(where),
|
||||
this.buildFacets(contextWhere, locale),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Relevance by default, but an explicit sort still wins.
|
||||
*
|
||||
* The database returned these unordered, so the ranking has to be restored
|
||||
* here. If the shopper picked "price: low to high" on a search result page,
|
||||
* honouring the ranking anyway would leave the sort control visibly lying —
|
||||
* it would change the URL and nothing else.
|
||||
*/
|
||||
const rank = new Map(ids.map((id, index) => [id, index]));
|
||||
const byRank = (a: ProductListRow, b: ProductListRow) =>
|
||||
(rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity);
|
||||
|
||||
const cheapest = (row: ProductListRow) =>
|
||||
Math.min(
|
||||
...row.variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount),
|
||||
Infinity,
|
||||
);
|
||||
|
||||
const comparators: Record<string, (a: ProductListRow, b: ProductListRow) => number> = {
|
||||
price_asc: (a, b) => cheapest(a) - cheapest(b) || byRank(a, b),
|
||||
price_desc: (a, b) => cheapest(b) - cheapest(a) || byRank(a, b),
|
||||
};
|
||||
|
||||
const ordered = [...rows].sort(comparators[filter.sort ?? ''] ?? byRank);
|
||||
|
||||
const limit = filter.limit;
|
||||
const page = ordered.slice(0, limit);
|
||||
|
||||
return {
|
||||
items: page.map((row) => this.mapper.toListItem(row, locale)),
|
||||
pageInfo: {
|
||||
// Ranked results are a single page: a cursor into a relevance ordering
|
||||
// means nothing once the ranking is recomputed.
|
||||
hasNextPage: false,
|
||||
nextCursor: null,
|
||||
},
|
||||
totalCount,
|
||||
facets,
|
||||
};
|
||||
}
|
||||
|
||||
/** Slugs + last-modified for `generateStaticParams` and the sitemap. */
|
||||
async listSlugs(locale: Locale): Promise<{ slug: string; updatedAt: string }[]> {
|
||||
const rows = await this.repository.findAllSlugs(locale);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
import type {
|
||||
SearchHit,
|
||||
SearchIndexDocument,
|
||||
SearchProvider,
|
||||
SearchSuggestion,
|
||||
} from './search.provider';
|
||||
|
||||
/**
|
||||
* Below this, a trigram match is noise rather than a typo.
|
||||
*
|
||||
* Tuned against the real catalog: "jaket" scores 0.44 against every jacket,
|
||||
* "nocturn" scores 0.88 against Nocturne, and nonsense scores 0. Anything
|
||||
* higher than 0.4 loses the single-character typos this exists for.
|
||||
*/
|
||||
const TRIGRAM_THRESHOLD = 0.4;
|
||||
|
||||
/**
|
||||
* PostgreSQL full-text search (ADR-0012).
|
||||
*
|
||||
* Two matching strategies, combined rather than chosen between:
|
||||
*
|
||||
* 1. `websearch_to_tsquery` against the weighted `document` column. This is
|
||||
* the precise path — it understands quoted phrases and `-exclusions`, and
|
||||
* it ranks a name match above a description match because the vector was
|
||||
* built with weights.
|
||||
* 2. Trigram similarity on title + keywords. This is the forgiving path —
|
||||
* it survives typos and partial words, which a tsquery does not.
|
||||
*
|
||||
* A product matching either is a hit; the score is the better of the two,
|
||||
* scaled so they are comparable. Running only the first means "nocturn" finds
|
||||
* nothing; running only the second ranks a description mention as highly as a
|
||||
* name.
|
||||
*
|
||||
* The trigram comparison is a function call rather than the `<%` operator, so
|
||||
* it does not use the GIN index and degrades to a sequential scan. That is a
|
||||
* deliberate trade at this catalog size — the operator's threshold is a session
|
||||
* GUC, which is awkward to set per request. It is the first thing to change if
|
||||
* the catalog outgrows ADR-0012's ~50k estimate.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PostgresSearchProvider implements SearchProvider {
|
||||
private readonly logger = new Logger(PostgresSearchProvider.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async search(query: string, locale: Locale, limit: number): Promise<SearchHit[]> {
|
||||
const term = query.trim();
|
||||
if (term.length === 0) return [];
|
||||
|
||||
const rows = await this.prisma.$queryRaw<{ product_id: string; score: number }[]>`
|
||||
WITH q AS (
|
||||
SELECT websearch_to_tsquery('simple', immutable_unaccent(${term})) AS tsq,
|
||||
immutable_unaccent(${term}) AS raw
|
||||
)
|
||||
SELECT d.product_id,
|
||||
GREATEST(
|
||||
-- ts_rank_cd rewards term density and honours the A/B/C weights.
|
||||
ts_rank_cd(d.document, q.tsq, 32) * 4,
|
||||
-- word_similarity, not plain similarity: the latter compares
|
||||
-- whole strings, so a short query against a long document scores
|
||||
-- near zero however well it matches. "nocturn" against the
|
||||
-- Nocturne document scored 0.125, below any usable threshold.
|
||||
-- This scores the best-matching word extent instead (0.875 for
|
||||
-- the same pair), which is the question actually being asked.
|
||||
word_similarity(q.raw, immutable_unaccent(d.title) || ' ' || immutable_unaccent(d.keywords))
|
||||
)::float8 AS score
|
||||
FROM search_documents d, q
|
||||
WHERE d.locale = ${toDbLocale(locale)}::"Locale"
|
||||
AND (
|
||||
d.document @@ q.tsq
|
||||
OR word_similarity(
|
||||
q.raw,
|
||||
immutable_unaccent(d.title) || ' ' || immutable_unaccent(d.keywords)
|
||||
) > ${TRIGRAM_THRESHOLD}
|
||||
)
|
||||
ORDER BY score DESC, d.title ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
return rows.map((row) => ({ productId: row.product_id, score: row.score }));
|
||||
}
|
||||
|
||||
async suggest(query: string, locale: Locale, limit: number): Promise<SearchSuggestion[]> {
|
||||
const term = query.trim();
|
||||
if (term.length === 0) return [];
|
||||
|
||||
// Suggestions match on the *title* only. Offering "Aero Run Tee" because
|
||||
// the word appears in its description reads as a broken autocomplete.
|
||||
const rows = await this.prisma.$queryRaw<{ title: string; slug: string; score: number }[]>`
|
||||
SELECT d.title,
|
||||
COALESCE(t.slug, p.slug) AS slug,
|
||||
word_similarity(immutable_unaccent(${term}), immutable_unaccent(d.title))::float8 AS score
|
||||
FROM search_documents d
|
||||
JOIN products p ON p.id = d.product_id
|
||||
LEFT JOIN product_translations t
|
||||
ON t.product_id = d.product_id AND t.locale = d.locale
|
||||
WHERE d.locale = ${toDbLocale(locale)}::"Locale"
|
||||
AND p.status = 'ACTIVE'
|
||||
AND p.deleted_at IS NULL
|
||||
AND (
|
||||
immutable_unaccent(d.title) ILIKE '%' || immutable_unaccent(${term}) || '%'
|
||||
OR word_similarity(immutable_unaccent(${term}), immutable_unaccent(d.title)) > ${TRIGRAM_THRESHOLD}
|
||||
)
|
||||
ORDER BY score DESC, length(d.title) ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
return rows.map((row) => ({ text: row.title, productSlug: row.slug }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts documents. `document` is never written — Postgres generates it from
|
||||
* the columns below, so the vector cannot drift from its own source text.
|
||||
*/
|
||||
async index(documents: readonly SearchIndexDocument[]): Promise<void> {
|
||||
if (documents.length === 0) return;
|
||||
|
||||
const values = documents.map(
|
||||
(doc) =>
|
||||
Prisma.sql`(${doc.productId}::uuid, ${toDbLocale(doc.locale)}::"Locale", ${doc.title}, ${doc.keywords}, ${doc.body}, NOW())`,
|
||||
);
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO search_documents (product_id, locale, title, keywords, body, updated_at)
|
||||
VALUES ${Prisma.join(values)}
|
||||
ON CONFLICT (product_id, locale) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
keywords = EXCLUDED.keywords,
|
||||
body = EXCLUDED.body,
|
||||
updated_at = NOW()
|
||||
`;
|
||||
}
|
||||
|
||||
async remove(productId: string): Promise<void> {
|
||||
await this.prisma.searchDocument.deleteMany({ where: { productId } });
|
||||
this.logger.debug(`Removed ${productId} from the search index`);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
export { SearchService } from '../search.service';
|
||||
export { SearchIndexerService } from '../search-indexer.service';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event';
|
||||
import { EventBusService } from '@/infrastructure/events/event-bus.service';
|
||||
|
||||
import { SearchIndexerService } from './search-indexer.service';
|
||||
|
||||
/**
|
||||
* Keeps the index in step with the catalog, by subscription rather than by call.
|
||||
*
|
||||
* This is the direction the dependency has to run: SearchModule knows about
|
||||
* products, products knows nothing about search. When this module is extracted,
|
||||
* this file is the only thing that changes — an in-process subscription becomes
|
||||
* a queue consumer, and the producer never learns the difference.
|
||||
*
|
||||
* Failures are logged, not rethrown. A search index that missed one update is a
|
||||
* degraded search; an exception escaping here would take down the write that
|
||||
* triggered it, which is a far worse trade.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchIndexSubscriber implements OnModuleInit {
|
||||
private readonly logger = new Logger(SearchIndexSubscriber.name);
|
||||
|
||||
constructor(
|
||||
private readonly events: EventBusService,
|
||||
private readonly indexer: SearchIndexerService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
for (const event of [
|
||||
DOMAIN_EVENTS.PRODUCT_UPDATED,
|
||||
DOMAIN_EVENTS.PRODUCT_PUBLISHED,
|
||||
DOMAIN_EVENTS.PRODUCT_ARCHIVED,
|
||||
]) {
|
||||
this.events.on<{ productId: string }>(event).subscribe((message) => {
|
||||
void this.reindex(message.payload.productId, event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async reindex(productId: string, event: string): Promise<void> {
|
||||
try {
|
||||
await this.indexer.reindexProduct(productId);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Reindex failed for ${productId} after ${event}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { LOCALES } from '@sport/types';
|
||||
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
import { SEARCH_PROVIDER, type SearchIndexDocument, type SearchProvider } from './search.provider';
|
||||
|
||||
/**
|
||||
* Builds searchable documents from the catalog.
|
||||
*
|
||||
* This is the one place in SearchModule that reads product tables, and it does
|
||||
* so to *project* them — the output is a flat document, never a product
|
||||
* payload. When this module is extracted, this class becomes a consumer of
|
||||
* catalog events rather than a reader of catalog tables, and nothing above it
|
||||
* changes.
|
||||
*
|
||||
* Only ACTIVE, published products are indexed. A draft product appearing in
|
||||
* search is a leak, not a feature.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchIndexerService {
|
||||
private readonly logger = new Logger(SearchIndexerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(SEARCH_PROVIDER) private readonly provider: SearchProvider,
|
||||
) {}
|
||||
|
||||
/** Rebuilds one product's documents, or removes them if it is no longer sellable. */
|
||||
async reindexProduct(productId: string): Promise<void> {
|
||||
const documents = await this.buildDocuments({ id: productId });
|
||||
|
||||
if (documents.length === 0) {
|
||||
// Unpublished, archived or deleted since the event fired — the right
|
||||
// response is removal, not a stale row nobody will notice.
|
||||
await this.provider.remove(productId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.provider.index(documents);
|
||||
}
|
||||
|
||||
/** Full rebuild. Safe to run at any time — the index is pure projection. */
|
||||
async reindexAll(): Promise<number> {
|
||||
const documents = await this.buildDocuments({});
|
||||
await this.provider.index(documents);
|
||||
|
||||
this.logger.log(`Reindexed ${documents.length} document(s)`);
|
||||
return documents.length;
|
||||
}
|
||||
|
||||
private async buildDocuments(where: { id?: string }): Promise<SearchIndexDocument[]> {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: {
|
||||
...where,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
OR: [{ publishedAt: null }, { publishedAt: { lte: new Date() } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
shortDescription: true,
|
||||
genderTargets: true,
|
||||
sportTypes: true,
|
||||
translations: true,
|
||||
brand: { select: { name: true, translations: true } },
|
||||
primaryCategory: { select: { name: true, translations: true } },
|
||||
variants: {
|
||||
where: { status: 'ACTIVE', deletedAt: null },
|
||||
select: { sku: true },
|
||||
},
|
||||
options: {
|
||||
select: {
|
||||
values: {
|
||||
select: { label: true, translations: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const documents: SearchIndexDocument[] = [];
|
||||
|
||||
for (const product of products) {
|
||||
for (const locale of LOCALES) {
|
||||
const dbLocale = toDbLocale(locale);
|
||||
const translation = product.translations.find((row) => row.locale === dbLocale);
|
||||
|
||||
// Falls back to the canonical field, matching how the storefront
|
||||
// renders an untranslated product — the index must find what the page
|
||||
// actually shows.
|
||||
const title = translation?.name || product.name;
|
||||
|
||||
const optionLabels = product.options.flatMap((option) =>
|
||||
option.values.map(
|
||||
(value) =>
|
||||
value.translations.find((row) => row.locale === dbLocale)?.label || value.label,
|
||||
),
|
||||
);
|
||||
|
||||
const brand =
|
||||
product.brand?.translations.find((row) => row.locale === dbLocale)?.name ||
|
||||
product.brand?.name ||
|
||||
'';
|
||||
const category =
|
||||
product.primaryCategory?.translations.find((row) => row.locale === dbLocale)?.name ||
|
||||
product.primaryCategory?.name ||
|
||||
'';
|
||||
|
||||
documents.push({
|
||||
productId: product.id,
|
||||
locale,
|
||||
title,
|
||||
// Short, high-signal terms. SKUs are in here because staff and
|
||||
// returning customers search by them constantly.
|
||||
keywords: unique([
|
||||
brand,
|
||||
category,
|
||||
...optionLabels,
|
||||
...product.variants.map((variant) => variant.sku),
|
||||
...product.genderTargets,
|
||||
...product.sportTypes,
|
||||
]).join(' '),
|
||||
body: [
|
||||
translation?.shortDescription || product.shortDescription || '',
|
||||
translation?.description || product.description || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.trim().length > 0))];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type Locale,
|
||||
type ProductListResult,
|
||||
type SearchSuggestions,
|
||||
} from '@sport/types';
|
||||
import { productFilterSchema, type ProductFilter } from '@sport/validation';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { RequestLocale } from '@/common/i18n/locale.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { SearchIndexerService } from './search-indexer.service';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/** Enough to fill a suggestion dropdown; more is a listing, not a hint. */
|
||||
const SUGGESTION_LIMIT = 6;
|
||||
|
||||
/**
|
||||
* Mixed surface: searching is public, rebuilding the index is not.
|
||||
*
|
||||
* `@Public()` therefore sits on the two read endpoints rather than on the
|
||||
* controller. Authentication is on by default, so the reindex route is
|
||||
* protected by omission — which is the failure mode worth having.
|
||||
*/
|
||||
@ApiTags('search')
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(
|
||||
private readonly service: SearchService,
|
||||
private readonly indexer: SearchIndexerService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Ranked product search, refinable like any listing' })
|
||||
search(
|
||||
@Query(new ZodValidationPipe(productFilterSchema)) filter: ProductFilter,
|
||||
@RequestLocale() locale: Locale,
|
||||
): Promise<ProductListResult> {
|
||||
return this.service.searchProducts(filter.q ?? '', filter, locale);
|
||||
}
|
||||
|
||||
@Get('suggestions')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Type-ahead product names' })
|
||||
suggest(@Query('q') query: string, @RequestLocale() locale: Locale): Promise<SearchSuggestions> {
|
||||
return this.service.suggest(query ?? '', locale, SUGGESTION_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds every document.
|
||||
*
|
||||
* The index is a pure projection, so this is always safe to run and is the
|
||||
* answer to "search is missing something" — no reasoning about which events
|
||||
* were lost, just rebuild.
|
||||
*/
|
||||
@Post('reindex')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
|
||||
@ApiOperation({ summary: 'Rebuild the entire search index' })
|
||||
async reindex(): Promise<{ indexed: number }> {
|
||||
return { indexed: await this.indexer.reindexAll() };
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ProductsModule } from '@/modules/products/products.module';
|
||||
|
||||
import { PostgresSearchProvider } from './postgres-search.provider';
|
||||
import { SearchIndexSubscriber } from './search-index.subscriber';
|
||||
import { SearchIndexerService } from './search-indexer.service';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SEARCH_PROVIDER } from './search.provider';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/**
|
||||
* SearchModule — boundary declared, implementation pending.
|
||||
* SearchModule — owns `search_documents`, a pure projection of the catalog.
|
||||
*
|
||||
* Owns (exclusively): Nothing. Read-only projection over the catalog.
|
||||
* The projection can be rebuilt from scratch at any moment, which is what makes
|
||||
* losing it survivable and makes this module a genuine EXTRACTION CANDIDATE:
|
||||
* nothing else reads its table, and it returns ids rather than product payloads
|
||||
* so it never learns what a product looks like.
|
||||
*
|
||||
* Starts as PostgreSQL full-text + trigram, which is genuinely enough below ~50k products. Behind a SearchProvider interface so swapping in OpenSearch is a provider change, not a rewrite of every listing page.
|
||||
*
|
||||
* 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):
|
||||
* search.module.ts wiring only
|
||||
* search.controller.ts HTTP surface, no logic
|
||||
* search.service.ts business rules
|
||||
* search.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* `SEARCH_PROVIDER` is the seam from ADR-0012 — swapping PostgreSQL for
|
||||
* OpenSearch replaces one binding here.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [ProductsModule],
|
||||
controllers: [SearchController],
|
||||
providers: [
|
||||
SearchService,
|
||||
SearchIndexerService,
|
||||
SearchIndexSubscriber,
|
||||
PostgresSearchProvider,
|
||||
{ provide: SEARCH_PROVIDER, useExisting: PostgresSearchProvider },
|
||||
],
|
||||
exports: [SearchService, SearchIndexerService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
export interface SearchHit {
|
||||
readonly productId: string;
|
||||
/** Higher is better. Comparable within one result set, not across queries. */
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
export interface SearchSuggestion {
|
||||
readonly text: string;
|
||||
readonly productSlug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seam.
|
||||
*
|
||||
* Everything above this interface — the search endpoint, the listing's
|
||||
* relevance sort, the suggestion box — talks only to these three methods.
|
||||
* Swapping PostgreSQL for OpenSearch later is a new implementation of this
|
||||
* file's contract, not a rewrite of every caller (ADR-0012).
|
||||
*
|
||||
* Note what it returns: product *ids* and a score, never product payloads.
|
||||
* Rendering a product is the catalog's job, and keeping it that way is what
|
||||
* lets SearchModule be extracted without dragging catalog tables along.
|
||||
*/
|
||||
export interface SearchProvider {
|
||||
search(query: string, locale: Locale, limit: number): Promise<SearchHit[]>;
|
||||
suggest(query: string, locale: Locale, limit: number): Promise<SearchSuggestion[]>;
|
||||
index(documents: readonly SearchIndexDocument[]): Promise<void>;
|
||||
remove(productId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SearchIndexDocument {
|
||||
readonly productId: string;
|
||||
readonly locale: Locale;
|
||||
readonly title: string;
|
||||
readonly keywords: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export const SEARCH_PROVIDER = Symbol('SEARCH_PROVIDER');
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Locale, ProductListResult, SearchSuggestions } from '@sport/types';
|
||||
import type { ProductFilter } from '@sport/validation';
|
||||
|
||||
import { ProductsService } from '@/modules/products/public';
|
||||
|
||||
import { SEARCH_PROVIDER, type SearchProvider } from './search.provider';
|
||||
|
||||
/** Ranked ids fetched per query. Beyond this, relevance is noise anyway. */
|
||||
const MAX_HITS = 200;
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@Inject(SEARCH_PROVIDER) private readonly provider: SearchProvider,
|
||||
private readonly products: ProductsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ranked search, rendered by the catalog.
|
||||
*
|
||||
* The provider returns ids and scores; the catalog turns them into cards.
|
||||
* That split is what keeps this module extractable — it never learns what a
|
||||
* product looks like.
|
||||
*
|
||||
* Filters and facets still come from the catalog, so a search result is
|
||||
* refinable exactly like any other listing.
|
||||
*/
|
||||
async searchProducts(
|
||||
query: string,
|
||||
filter: ProductFilter,
|
||||
locale: Locale,
|
||||
): Promise<ProductListResult> {
|
||||
const hits = await this.provider.search(query, locale, MAX_HITS);
|
||||
|
||||
if (hits.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
pageInfo: { nextCursor: null, hasNextPage: false },
|
||||
totalCount: 0,
|
||||
facets: { brands: [], colors: [], sizes: [], priceRange: null },
|
||||
};
|
||||
}
|
||||
|
||||
return this.products.listByIds(
|
||||
hits.map((hit) => hit.productId),
|
||||
filter,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
async suggest(query: string, locale: Locale, limit: number): Promise<SearchSuggestions> {
|
||||
const suggestions = await this.provider.suggest(query, locale, limit);
|
||||
|
||||
return { query, suggestions };
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export default async function AccountLayout({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} />
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<div className="max-w-page px-gutter mx-auto flex w-full flex-1 gap-12 py-16">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { CheckoutForm } from '@/components/commerce/checkout-form';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.checkout' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'checkout' });
|
||||
return { title: t('title'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function CheckoutPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.checkout');
|
||||
const t = await getTranslations('checkout');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<h1 className="mb-10 text-3xl font-black uppercase sm:text-4xl">{t('title')}</h1>
|
||||
<CheckoutForm locale={locale as Locale} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function CheckoutLayout({
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.checkout');
|
||||
const t = await getTranslations('checkout');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { OrderConfirmation } from '@/components/commerce/order-confirmation';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ order?: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'confirmation' });
|
||||
// Never indexed: the URL is a capability granting access to one order.
|
||||
return { title: t('title'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function OrderConfirmationPage({ params, searchParams }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const { order } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="px-gutter mx-auto max-w-3xl py-12">
|
||||
<OrderConfirmation orderId={order ?? ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { CartView } from '@/components/commerce/cart-view';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.cart' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'cart' });
|
||||
// A bag is per-visitor and has no business in an index.
|
||||
return { title: t('title'), robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
export default async function CartPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.cart');
|
||||
const t = await getTranslations('cart');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<h1 className="mb-10 text-4xl font-black uppercase sm:text-5xl">{t('title')}</h1>
|
||||
<CartView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default async function ShopLayout({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} />
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter navigation={navigation} />
|
||||
</div>
|
||||
|
||||
@@ -31,8 +31,13 @@ export default async function SearchPage({ params, searchParams }: PageProps) {
|
||||
locale={locale as Locale}
|
||||
title={query.q ? t('resultsFor', { query: query.q }) : t('title')}
|
||||
description={query.q ? null : t('noQuery')}
|
||||
query={{ ...query, sort: query.q ? 'relevance' : query.sort }}
|
||||
// Relevance is the real default here, so the sort control has to say so.
|
||||
query={{ ...query, sort: query.q ? (query.sort ?? 'relevance') : query.sort }}
|
||||
basePath="/search"
|
||||
// Only when there is something to rank. An empty query through the search
|
||||
// endpoint returns nothing, whereas the listing shows the catalog — which
|
||||
// is the more useful landing state.
|
||||
search={Boolean(query.q)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { LOCALE_TAGS, type Locale } from '@sport/types';
|
||||
|
||||
import { CartProvider } from '@/features/cart/cart-provider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
@@ -66,7 +67,11 @@ export default async function LocaleLayout({
|
||||
return (
|
||||
<html lang={LOCALE_TAGS[locale as Locale]} suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
<NextIntlClientProvider>
|
||||
{/* Above both route groups: the bag survives moving from browsing
|
||||
into checkout, which is the one transition it must not lose. */}
|
||||
<CartProvider locale={locale as Locale}>{children}</CartProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Loader2, ShoppingBag } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { VARIANT_AVAILABILITY, type StorefrontVariant } from '@sport/types';
|
||||
import { Button } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
|
||||
/**
|
||||
* The PDP's primary action.
|
||||
*
|
||||
* Confirms in place rather than navigating away. Sending a shopper to the cart
|
||||
* on every add is the single easiest way to end a browsing session, so the
|
||||
* button reports success and the header badge does the rest.
|
||||
*/
|
||||
export function AddToBag({ variant }: { variant: StorefrontVariant | undefined }) {
|
||||
const t = useTranslations('product');
|
||||
const { addLine, pending, error } = useCart();
|
||||
|
||||
/**
|
||||
* Which variant the confirmation belongs to, rather than a bare boolean.
|
||||
*
|
||||
* Derived comparison, not an effect that resets on change: switching size
|
||||
* must drop the "Added" state instantly, and syncing that through an effect
|
||||
* renders one frame claiming the new variant is already in the bag.
|
||||
*/
|
||||
const [addedFor, setAddedFor] = useState<string | null>(null);
|
||||
const added = addedFor !== null && addedFor === variant?.id;
|
||||
|
||||
const soldOut = variant?.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK;
|
||||
const disabled = !variant || soldOut || pending;
|
||||
|
||||
// The confirmation is transient — it says "just now", not "ever".
|
||||
useEffect(() => {
|
||||
if (!added) return;
|
||||
const timer = setTimeout(() => setAddedFor(null), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [added]);
|
||||
|
||||
async function add() {
|
||||
if (!variant) return;
|
||||
if (await addLine(variant.id, 1)) setAddedFor(variant.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" fullWidth disabled={disabled} onClick={() => void add()}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : added ? <Check /> : null}
|
||||
{!variant
|
||||
? t('selectSizePrompt')
|
||||
: soldOut
|
||||
? t('outOfStock')
|
||||
: added
|
||||
? t('addedToBag')
|
||||
: t('addToBag')}
|
||||
{!pending && !added && variant && !soldOut ? <ShoppingBag /> : null}
|
||||
</Button>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-center text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ShoppingBag } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* The bag icon with its item count.
|
||||
*
|
||||
* Renders the icon immediately and the count only once the cart has loaded —
|
||||
* a badge that flashes "0" before showing "3" reads as an emptied bag.
|
||||
*/
|
||||
export function CartBadge() {
|
||||
const t = useTranslations('nav');
|
||||
const { cart, loading } = useCart();
|
||||
|
||||
const count = cart?.totals.itemCount ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={routes.cart()}
|
||||
aria-label={count > 0 ? `${t('cart')} (${count})` : t('cart')}
|
||||
title={t('cart')}
|
||||
className="hover:text-volt-600 focus-visible:ring-ring/50 relative grid size-10 place-items-center outline-none transition-colors focus-visible:ring-[3px]"
|
||||
>
|
||||
<ShoppingBag className="size-5" />
|
||||
{!loading && count > 0 ? (
|
||||
<span className="bg-volt-500 text-ink-950 absolute right-0.5 top-0.5 grid min-w-4 place-items-center rounded-full px-1 text-[0.625rem] font-bold leading-4">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Minus, Plus, ShoppingBag, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
|
||||
import { CART_NOTICE_REASONS } from '@sport/types';
|
||||
import { Button, Skeleton } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* The bag.
|
||||
*
|
||||
* Quantity controls act on the server and re-render from its response, so the
|
||||
* number shown is always one the API agreed to. That matters here more than
|
||||
* anywhere: this page's job is to be the total the shopper will actually pay.
|
||||
*/
|
||||
export function CartView() {
|
||||
const t = useTranslations('cart');
|
||||
const format = useFormatter();
|
||||
const { cart, loading, pending, error, setQuantity, removeLine } = useCart();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-28 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cart || cart.lines.length === 0) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<ShoppingBag className="text-ink-300 mx-auto size-10" />
|
||||
<p className="mt-6 text-lg font-medium">{t('empty')}</p>
|
||||
<p className="text-ink-500 mt-2 text-sm">{t('emptyHint')}</p>
|
||||
<Button className="mt-8" asChild>
|
||||
<Link href={routes.men()}>{t('startShopping')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_22rem] lg:gap-16">
|
||||
<div className="space-y-6">
|
||||
{/*
|
||||
Notices explain a bag that changed by itself. Silently correcting a
|
||||
quantity and showing a different total is how a shopper concludes the
|
||||
site is broken.
|
||||
*/}
|
||||
{cart.notices.length > 0 ? (
|
||||
<ul className="border-warning/40 bg-warning/10 space-y-2 border p-4">
|
||||
{cart.notices.map((notice) => (
|
||||
<li key={`${notice.reason}-${notice.variantId}`} className="flex gap-2 text-sm">
|
||||
<TriangleAlert className="text-warning mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
{notice.reason === CART_NOTICE_REASONS.QUANTITY_REDUCED
|
||||
? t('notice.reduced', {
|
||||
name: notice.productName,
|
||||
quantity: notice.quantity ?? 0,
|
||||
})
|
||||
: notice.reason === CART_NOTICE_REASONS.OUT_OF_STOCK
|
||||
? t('notice.soldOut', { name: notice.productName })
|
||||
: t('notice.unavailable')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ul className="divide-ink-200 divide-y">
|
||||
{cart.lines.map((line) => (
|
||||
<li key={line.variantId} className="flex gap-4 py-6">
|
||||
<div className="bg-ink-100 relative aspect-[4/5] w-24 shrink-0 overflow-hidden">
|
||||
{line.imageUrl ? (
|
||||
<Image
|
||||
src={line.imageUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-medium">
|
||||
<Link href={routes.product(line.productSlug)} className="hover:underline">
|
||||
{line.productName}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="text-ink-500 mt-1 text-xs">{line.variantTitle}</p>
|
||||
<p className="text-ink-400 mt-0.5 font-mono text-[0.625rem]">{line.sku}</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<QuantityStepper
|
||||
value={line.quantity}
|
||||
max={line.maxQuantity}
|
||||
disabled={pending}
|
||||
label={t('quantityFor', { name: line.productName })}
|
||||
onChange={(next) => void setQuantity(line.variantId, next)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeLine(line.variantId)}
|
||||
className="text-ink-500 hover:text-danger focus-visible:ring-ring/50 flex items-center gap-1 text-xs outline-none focus-visible:ring-[3px] disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t('remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{formatMoney(line.lineTotal, format)}</p>
|
||||
{line.compareAtPrice ? (
|
||||
<p className="text-ink-400 text-xs line-through">
|
||||
{formatMoney(
|
||||
{
|
||||
amount: line.compareAtPrice.amount * line.quantity,
|
||||
currency: line.compareAtPrice.currency,
|
||||
},
|
||||
format,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-ink-400 mt-1 text-xs">
|
||||
{formatMoney(line.unitPrice, format)} × {line.quantity}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside className="border-ink-200 h-fit border p-6 lg:sticky lg:top-24">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('summary')}</h2>
|
||||
|
||||
<dl className="mt-6 space-y-3 text-sm">
|
||||
<Row label={t('subtotal')} value={formatMoney(cart.totals.subtotal, format)} />
|
||||
{/* Named zeroes rather than hidden rows: the total is always the sum
|
||||
of parts a shopper can see, even before shipping exists (M9). */}
|
||||
<Row label={t('shipping')} value={t('shippingAtCheckout')} muted />
|
||||
<div className="border-ink-200 flex items-baseline justify-between border-t pt-3">
|
||||
<dt className="font-semibold">{t('total')}</dt>
|
||||
<dd className="text-lg font-semibold">{formatMoney(cart.totals.total, format)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Button size="lg" fullWidth className="mt-6" disabled={pending} asChild>
|
||||
<Link href={routes.checkout()}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||
{t('checkout')}
|
||||
</Link>
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<dt className="text-ink-500">{label}</dt>
|
||||
<dd className={muted ? 'text-ink-400 text-xs' : ''}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuantityStepper({
|
||||
value,
|
||||
max,
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="border-ink-200 flex items-center border">
|
||||
<StepButton disabled={disabled || value <= 1} onClick={() => onChange(value - 1)} label="−">
|
||||
<Minus className="size-3.5" />
|
||||
</StepButton>
|
||||
<span className="w-8 text-center text-sm tabular-nums">{value}</span>
|
||||
<StepButton
|
||||
// Capped at what the API said is available, so the stepper cannot ask
|
||||
// for stock that will just be reduced again on the way back.
|
||||
disabled={disabled || value >= max}
|
||||
onClick={() => onChange(value + 1)}
|
||||
label="+"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</StepButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepButton({
|
||||
disabled,
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="hover:bg-ink-100 focus-visible:ring-ring/50 grid size-9 place-items-center outline-none transition-colors focus-visible:ring-[3px] disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Locale } from '@sport/types';
|
||||
import { Button, Input } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
interface Fields {
|
||||
email: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
line1: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
province: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const EMPTY: Fields = {
|
||||
email: '',
|
||||
fullName: '',
|
||||
phone: '',
|
||||
line1: '',
|
||||
ward: '',
|
||||
district: '',
|
||||
province: '',
|
||||
note: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest checkout.
|
||||
*
|
||||
* Deliberately one page and no account. Every field here is one a courier
|
||||
* actually needs; ward and district are optional because rural Vietnamese
|
||||
* addresses frequently have neither, and rejecting those loses real orders.
|
||||
*
|
||||
* Nothing about money is sent. The server prices the bag it already holds, so
|
||||
* the total on this page and the total charged come from the same computation.
|
||||
*/
|
||||
export function CheckoutForm({ locale }: { locale: Locale }) {
|
||||
const t = useTranslations('checkout');
|
||||
const format = useFormatter();
|
||||
const router = useRouter();
|
||||
const { cart, loading, refresh } = useCart();
|
||||
|
||||
const [fields, setFields] = useState<Fields>(EMPTY);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set(key: keyof Fields, value: string) {
|
||||
setFields((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const order = await browserApi.commerce.placeOrder(locale, {
|
||||
email: fields.email,
|
||||
shippingAddress: {
|
||||
fullName: fields.fullName,
|
||||
phone: fields.phone,
|
||||
line1: fields.line1,
|
||||
line2: null,
|
||||
ward: fields.ward || null,
|
||||
district: fields.district || null,
|
||||
province: fields.province,
|
||||
countryCode: 'VN',
|
||||
postalCode: null,
|
||||
},
|
||||
customerNote: fields.note || null,
|
||||
});
|
||||
|
||||
// The bag is gone server-side; refreshing clears the badge before the
|
||||
// confirmation page renders behind it.
|
||||
await refresh();
|
||||
// The id alone — see OrderConfirmation for why the email must not be here.
|
||||
router.push(`${routes.orderConfirmation()}?order=${order.id}`);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('failed'));
|
||||
// A rejected placement usually means the bag changed underneath — pull
|
||||
// the corrected version so the summary explains itself.
|
||||
await refresh();
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-ink-500 text-sm">{t('loading')}</p>;
|
||||
}
|
||||
|
||||
if (!cart || cart.lines.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-lg font-medium">{t('emptyBag')}</p>
|
||||
{/* The locale-aware Link, not a plain anchor: `routes.men()` is
|
||||
`/men`, and an English shopper following a bare href lands on the
|
||||
Vietnamese listing. */}
|
||||
<Button className="mt-6" asChild>
|
||||
<Link href={routes.men()}>{t('backToShop')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => void submit(event)}
|
||||
className="grid gap-10 lg:grid-cols-[1fr_22rem] lg:gap-16"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('contact')}</h2>
|
||||
<Field label={t('email')} required>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={fields.email}
|
||||
onChange={(event) => set('email', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('delivery')}</h2>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t('fullName')} required>
|
||||
<Input
|
||||
autoComplete="name"
|
||||
required
|
||||
value={fields.fullName}
|
||||
onChange={(event) => set('fullName', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('phone')} required hint={t('phoneHint')}>
|
||||
<Input
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
required
|
||||
value={fields.phone}
|
||||
onChange={(event) => set('phone', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t('address')} required>
|
||||
<Input
|
||||
autoComplete="address-line1"
|
||||
required
|
||||
value={fields.line1}
|
||||
onChange={(event) => set('line1', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label={t('ward')}>
|
||||
<Input value={fields.ward} onChange={(event) => set('ward', event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('district')}>
|
||||
<Input
|
||||
value={fields.district}
|
||||
onChange={(event) => set('district', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('province')} required>
|
||||
<Input
|
||||
autoComplete="address-level1"
|
||||
required
|
||||
value={fields.province}
|
||||
onChange={(event) => set('province', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t('note')}>
|
||||
<Input value={fields.note} onChange={(event) => set('note', event.target.value)} />
|
||||
</Field>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="border-ink-200 h-fit border p-6 lg:sticky lg:top-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('summary')}</h2>
|
||||
|
||||
<ul className="divide-ink-100 mt-4 divide-y">
|
||||
{cart.lines.map((line) => (
|
||||
<li key={line.variantId} className="flex items-center gap-3 py-3">
|
||||
<div className="bg-ink-100 relative size-12 shrink-0 overflow-hidden">
|
||||
{line.imageUrl ? (
|
||||
<Image
|
||||
src={line.imageUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs">
|
||||
<p className="truncate font-medium">{line.productName}</p>
|
||||
<p className="text-ink-500">
|
||||
{line.variantTitle} × {line.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs font-semibold">{formatMoney(line.lineTotal, format)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<dl className="border-ink-200 mt-4 space-y-2 border-t pt-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('subtotal')}</dt>
|
||||
<dd>{formatMoney(cart.totals.subtotal, format)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('shipping')}</dt>
|
||||
<dd className="text-ink-400 text-xs">{t('shippingLater')}</dd>
|
||||
</div>
|
||||
<div className="border-ink-200 flex items-baseline justify-between border-t pt-2">
|
||||
<dt className="font-semibold">{t('total')}</dt>
|
||||
<dd className="text-lg font-semibold">{formatMoney(cart.totals.total, format)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger mt-4 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" size="lg" fullWidth className="mt-6" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="animate-spin" /> : <Lock />}
|
||||
{t('placeOrder')}
|
||||
</Button>
|
||||
|
||||
{/* Honest about scope: payment providers arrive with M9. */}
|
||||
<p className="text-ink-400 mt-3 text-center text-xs">{t('paymentLater')}</p>
|
||||
</aside>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{label}
|
||||
{required ? <span className="text-danger ml-1">*</span> : null}
|
||||
</span>
|
||||
{children}
|
||||
{hint ? <span className="text-ink-400 block text-xs">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,8 @@ 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.
|
||||
* The button is a real locale-aware link to `?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.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle2, Package } from 'lucide-react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Order } from '@sport/types';
|
||||
import { Badge, Button, Skeleton } from '@sport/ui';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* Order confirmation, fetched by number + email.
|
||||
*
|
||||
* Re-fetched rather than passed through navigation state: this URL is what a
|
||||
* shopper bookmarks, forwards or opens on their phone, and it has to work in
|
||||
* all three cases.
|
||||
*
|
||||
* The id and nothing else. The first version carried `?order=SP-000003&email=…`
|
||||
* because the API's guest lookup needs both — which put a customer's email into
|
||||
* browser history and into the `Referer` of every outbound request this page
|
||||
* makes. A UUIDv7 is unguessable on its own, so holding the link is the
|
||||
* authorisation and no personal data has to travel in the URL to prove it.
|
||||
*/
|
||||
export function OrderConfirmation({ orderId }: { orderId: string }) {
|
||||
const t = useTranslations('confirmation');
|
||||
const format = useFormatter();
|
||||
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.commerce.getPlacedOrder(orderId);
|
||||
if (!cancelled) setOrder(result);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('notFound'));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orderId, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-lg font-medium">{t('notFound')}</p>
|
||||
<Button className="mt-6" asChild>
|
||||
<Link href={routes.home()}>{t('backHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-12 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<header className="text-center">
|
||||
<CheckCircle2 className="text-success mx-auto size-12" />
|
||||
<h1 className="mt-6 text-3xl font-black uppercase sm:text-4xl">{t('title')}</h1>
|
||||
<p className="text-ink-500 mt-3 text-sm">{t('body', { email: order.email })}</p>
|
||||
<p className="mt-6 font-mono text-lg font-semibold">{order.orderNumber}</p>
|
||||
<Badge variant="neutral" className="mt-2">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<section className="border-ink-200 border">
|
||||
<h2 className="border-ink-200 border-b px-6 py-4 text-xs font-semibold uppercase tracking-widest">
|
||||
{t('items')}
|
||||
</h2>
|
||||
<ul className="divide-ink-100 divide-y">
|
||||
{order.lines.map((line) => (
|
||||
<li key={line.id} className="flex items-center gap-4 px-6 py-4">
|
||||
<Package className="text-ink-300 size-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{line.productName}</p>
|
||||
<p className="text-ink-500 text-xs">
|
||||
{line.variantTitle} · {line.sku} × {line.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{formatMoney(line.lineTotal, format)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<dl className="border-ink-200 space-y-2 border-t px-6 py-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('subtotal')}</dt>
|
||||
<dd>{formatMoney(order.subtotal, format)}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<dt className="font-semibold">{t('total')}</dt>
|
||||
<dd className="text-lg font-semibold">{formatMoney(order.total, format)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="border-ink-200 border p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('deliveringTo')}</h2>
|
||||
<address className="text-ink-600 mt-3 text-sm not-italic leading-relaxed">
|
||||
{order.shippingAddress.fullName}
|
||||
<br />
|
||||
{order.shippingAddress.phone}
|
||||
<br />
|
||||
{[
|
||||
order.shippingAddress.line1,
|
||||
order.shippingAddress.ward,
|
||||
order.shippingAddress.district,
|
||||
order.shippingAddress.province,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<div className="text-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={routes.men()}>{t('keepShopping')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { ShoppingBag } from 'lucide-react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types';
|
||||
import { Button, cn } from '@sport/ui';
|
||||
import { cn } from '@sport/ui';
|
||||
|
||||
import { discountPercent, formatMoney } from '@/lib/format';
|
||||
|
||||
import { AddToBag } from './add-to-bag';
|
||||
import { ProductGallery } from './product-gallery';
|
||||
|
||||
/**
|
||||
@@ -201,26 +201,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
|
||||
<div className="space-y-3">
|
||||
<AvailabilityNote variant={selectedVariant} />
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
fullWidth
|
||||
disabled={
|
||||
!selectedVariant || selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
|
||||
}
|
||||
>
|
||||
{selectedVariant &&
|
||||
selectedVariant.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK ? (
|
||||
<ShoppingBag />
|
||||
) : null}
|
||||
{!selectedVariant
|
||||
? t('selectSizePrompt')
|
||||
: selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
|
||||
? t('outOfStock')
|
||||
: t('addToBag')}
|
||||
</Button>
|
||||
|
||||
{/* Honest about scope: the button is real, the cart is not yet. */}
|
||||
<p className="text-ink-400 text-center text-xs">{t('comingSoon')}</p>
|
||||
<AddToBag variant={selectedVariant} />
|
||||
|
||||
{selectedVariant ? (
|
||||
<p className="text-ink-400 text-center text-xs">
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { fetchProducts, searchProducts } from '@/lib/catalog';
|
||||
import { buildListingHref } from '@/lib/listing-href';
|
||||
|
||||
import { FilterSheet } from './filter-sheet';
|
||||
@@ -27,6 +27,7 @@ export async function ProductListing({
|
||||
description,
|
||||
query,
|
||||
basePath,
|
||||
search = false,
|
||||
}: {
|
||||
locale: Locale;
|
||||
title: string;
|
||||
@@ -34,9 +35,17 @@ export async function ProductListing({
|
||||
description?: string | null;
|
||||
query: ProductListQuery;
|
||||
basePath: string;
|
||||
/**
|
||||
* Route the fetch through ranked search instead of the listing query.
|
||||
*
|
||||
* Same component, same filters, same facets — only the matching and the
|
||||
* ordering change. Keeping one listing component is what stops search
|
||||
* results and category pages drifting apart.
|
||||
*/
|
||||
search?: boolean;
|
||||
}) {
|
||||
const t = await getTranslations('listing');
|
||||
const result = await fetchProducts(locale, query);
|
||||
const result = search ? await searchProducts(locale, query) : 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.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { Locale, SearchSuggestion } from '@sport/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/** Below this, suggestions are the whole catalog and help nobody. */
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Search, as an overlay.
|
||||
*
|
||||
* A dialog rather than an inline field because the header has no room for one
|
||||
* at mobile widths, and because search deserves the full attention of the
|
||||
* screen once invoked.
|
||||
*
|
||||
* The dialog itself is the registry's — focus trapping, scroll locking, the
|
||||
* Escape handler, `aria-modal` and returning focus to the trigger are exactly
|
||||
* the infrastructure worth not writing by hand. What is inside it is ours.
|
||||
*/
|
||||
export function SearchDialog({ locale }: { locale: Locale }) {
|
||||
const t = useTranslations('search');
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Guards against a slow response for an old query overwriting a newer one.
|
||||
const latest = useRef('');
|
||||
|
||||
const term = query.trim();
|
||||
const active = term.length >= MIN_QUERY_LENGTH;
|
||||
|
||||
useEffect(() => {
|
||||
latest.current = term;
|
||||
|
||||
// Returns without touching state: a short query simply stops fetching, and
|
||||
// what is already in state is gated out of the render below instead. State
|
||||
// set synchronously in an effect body costs an extra render pass on every
|
||||
// keystroke.
|
||||
if (!active) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await browserApi.catalog.suggest(locale, term);
|
||||
// The API echoes the query back precisely so a late response can be
|
||||
// discarded rather than flickering stale names into the list.
|
||||
if (result.query.trim() === latest.current) {
|
||||
setSuggestions([...result.suggestions]);
|
||||
}
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
if (latest.current === term) setLoading(false);
|
||||
}
|
||||
}, 180);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [term, active, locale]);
|
||||
|
||||
function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const term = query.trim();
|
||||
if (term.length === 0) return;
|
||||
|
||||
setOpen(false);
|
||||
router.push(routes.search(term));
|
||||
}
|
||||
|
||||
function goToProduct(slug: string) {
|
||||
setOpen(false);
|
||||
router.push(routes.product(slug));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
// Closing resets, so reopening is never haunted by the last search.
|
||||
if (!next) {
|
||||
setQuery('');
|
||||
setSuggestions([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
aria-label={t('open')}
|
||||
title={t('open')}
|
||||
className="hover:text-volt-600 focus-visible:ring-ring/50 grid size-10 place-items-center outline-none transition-colors focus-visible:ring-[3px]"
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="top-[15%] max-w-xl translate-y-0 gap-0 p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('dialogHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={submit} className="border-ink-200 flex items-center gap-3 border-b px-5">
|
||||
<Search className="text-ink-400 size-5 shrink-0" />
|
||||
{/* No `autoFocus`: Radix moves focus to the first focusable element
|
||||
when the dialog opens, which is this input. Setting it as well
|
||||
fights the dialog's own focus management. */}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('placeholder')}
|
||||
aria-label={t('title')}
|
||||
className="placeholder:text-ink-400 h-14 w-full bg-transparent text-base outline-none"
|
||||
/>
|
||||
{loading ? <Loader2 className="text-ink-400 size-4 animate-spin" /> : null}
|
||||
</form>
|
||||
|
||||
{active && suggestions.length > 0 ? (
|
||||
<ul className="max-h-80 overflow-y-auto py-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion.productSlug}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToProduct(suggestion.productSlug)}
|
||||
className="hover:bg-ink-50 focus-visible:bg-ink-50 w-full px-5 py-2.5 text-left text-sm outline-none"
|
||||
>
|
||||
{suggestion.text}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{active && !loading && suggestions.length === 0 ? (
|
||||
<p className="text-ink-500 px-5 py-6 text-center text-sm">{t('noSuggestions')}</p>
|
||||
) : null}
|
||||
|
||||
<p className="border-ink-200 text-ink-400 border-t px-5 py-3 text-xs">
|
||||
{t('enterToSearch')}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Search, ShoppingBag, User } from 'lucide-react';
|
||||
import { User } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import type { NavigationMenu } from '@sport/types';
|
||||
import type { Locale, NavigationMenu } from '@sport/types';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { SPORT_NAV, routes } from '@/lib/routes';
|
||||
|
||||
import { CartBadge } from './cart-badge';
|
||||
import { MegaMenu, type MegaMenuEntry } from './mega-menu';
|
||||
import { MobileNav } from './mobile-nav';
|
||||
import { SearchDialog } from './search-dialog';
|
||||
|
||||
/**
|
||||
* Site chrome. Stays a Server Component: only the mega menu, the mobile drawer
|
||||
@@ -20,7 +22,13 @@ import { MobileNav } from './mobile-nav';
|
||||
* 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 }) {
|
||||
export async function SiteHeader({
|
||||
navigation,
|
||||
locale,
|
||||
}: {
|
||||
navigation: NavigationMenu | null;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const t = await getTranslations('nav');
|
||||
const tSports = await getTranslations('sports');
|
||||
|
||||
@@ -76,15 +84,11 @@ export async function SiteHeader({ navigation }: { navigation: NavigationMenu |
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<HeaderAction href={routes.search()} label={t('search')}>
|
||||
<Search className="size-5" />
|
||||
</HeaderAction>
|
||||
<SearchDialog locale={locale} />
|
||||
<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>
|
||||
<CartBadge />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Cart, Locale } from '@sport/types';
|
||||
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
interface CartState {
|
||||
readonly cart: Cart | null;
|
||||
/** True until the first load resolves, so the badge can stay quiet. */
|
||||
readonly loading: boolean;
|
||||
readonly pending: boolean;
|
||||
readonly error: string | null;
|
||||
addLine: (variantId: string, quantity?: number) => Promise<boolean>;
|
||||
setQuantity: (variantId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (variantId: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartState | null>(null);
|
||||
|
||||
/**
|
||||
* One cart, shared by the header badge, the PDP button and the cart page.
|
||||
*
|
||||
* The server is the only source of truth: every mutation returns the whole
|
||||
* recomputed cart and that response replaces local state wholesale. There is no
|
||||
* optimistic arithmetic here on purpose — the API adjusts quantities against
|
||||
* live stock, so a locally incremented number would regularly disagree with the
|
||||
* total on the next render, which is the one number a shopper actually checks.
|
||||
*
|
||||
* The bag is identified by an httpOnly cookie, so there is nothing to persist
|
||||
* and nothing to hydrate from storage.
|
||||
*/
|
||||
export function CartProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = useCallback(async (operation: () => Promise<Cart>): Promise<boolean> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setCart(await operation());
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : 'Something went wrong.');
|
||||
return false;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Re-reads the bag after something outside this provider changed it. */
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setCart(await browserApi.commerce.getCart(locale));
|
||||
} catch {
|
||||
// Same reasoning as the initial load: silence beats a banner.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// The first read owns its own lifecycle rather than delegating to `refresh`,
|
||||
// so it can be cancelled if the locale changes mid-flight and it never
|
||||
// resolves into an unmounted provider.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.commerce.getCart(locale);
|
||||
if (!cancelled) setCart(result);
|
||||
} catch {
|
||||
// A bag that cannot be read is not worth an error banner on every
|
||||
// page — the badge stays empty and the next action retries.
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
const value = useMemo<CartState>(
|
||||
() => ({
|
||||
cart,
|
||||
loading,
|
||||
pending,
|
||||
error,
|
||||
addLine: (variantId, quantity = 1) =>
|
||||
run(() => browserApi.commerce.addCartLine(locale, { variantId, quantity })),
|
||||
setQuantity: async (variantId, quantity) => {
|
||||
await run(() => browserApi.commerce.updateCartLine(locale, variantId, quantity));
|
||||
},
|
||||
removeLine: async (variantId) => {
|
||||
await run(() => browserApi.commerce.removeCartLine(locale, variantId));
|
||||
},
|
||||
refresh,
|
||||
}),
|
||||
[cart, loading, pending, error, locale, run, refresh],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart(): CartState {
|
||||
const context = useContext(CartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCart must be used inside a CartProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -40,6 +40,28 @@ export async function fetchProducts(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranked search.
|
||||
*
|
||||
* Degrades to an empty result like `fetchProducts` does: a search box that
|
||||
* throws a 500 at a shopper is worse than one that says nothing matched.
|
||||
*/
|
||||
export async function searchProducts(
|
||||
locale: Locale,
|
||||
query: ProductListQuery,
|
||||
): Promise<ProductListResult> {
|
||||
try {
|
||||
return await getServerApi().catalog.searchProducts(locale, query, CATALOG_CACHE.listing);
|
||||
} catch {
|
||||
return {
|
||||
items: [],
|
||||
pageInfo: { nextCursor: null, hasNextPage: false },
|
||||
totalCount: 0,
|
||||
facets: { brands: [], colors: [], sizes: [], priceRange: null },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns null for a genuine 404 and rethrows anything else. */
|
||||
export async function fetchProduct(
|
||||
locale: Locale,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const routes = {
|
||||
|
||||
cart: () => '/cart',
|
||||
checkout: () => '/checkout',
|
||||
orderConfirmation: () => '/order-confirmation',
|
||||
|
||||
account: () => '/account',
|
||||
accountProfile: () => '/account/profile',
|
||||
|
||||
@@ -82,13 +82,18 @@
|
||||
"comingSoon": "Add to bag arrives with the cart milestone.",
|
||||
"previousImage": "Previous image",
|
||||
"nextImage": "Next image",
|
||||
"goToImage": "Go to image {index}"
|
||||
"goToImage": "Go to image {index}",
|
||||
"addedToBag": "Added to bag"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"placeholder": "Search products, brands, sports",
|
||||
"placeholder": "Search products…",
|
||||
"resultsFor": "Results for “{query}”",
|
||||
"noQuery": "Type something to start searching."
|
||||
"noQuery": "Type something to start searching.",
|
||||
"open": "Search",
|
||||
"dialogHint": "Type to see matching products.",
|
||||
"noSuggestions": "No matching products.",
|
||||
"enterToSearch": "Press Enter to see all results"
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"home": "Home"
|
||||
@@ -126,18 +131,10 @@
|
||||
"planned": "Planned: {milestone}"
|
||||
},
|
||||
"placeholder": {
|
||||
"cart": {
|
||||
"title": "Your bag",
|
||||
"body": "Line items with variant title and live availability. Totals are always recomputed by the API — the client never submits a price."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Journal",
|
||||
"body": "Editorial content served from the CMS module: training guides, drops and athlete stories."
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"body": "Contact, shipping address, delivery method, payment. Each step is server-validated and stock is reserved before a payment intent is created."
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"body": "Overview: recent orders, saved addresses and profile at a glance."
|
||||
@@ -158,5 +155,60 @@
|
||||
"title": "Wishlist",
|
||||
"body": "Saved variants, and the signal source for back-in-stock notifications."
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"title": "Bag",
|
||||
"empty": "Your bag is empty.",
|
||||
"emptyHint": "Once you add something it will show up here.",
|
||||
"startShopping": "Start shopping",
|
||||
"summary": "Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"shippingAtCheckout": "Calculated later",
|
||||
"total": "Total",
|
||||
"checkout": "Checkout",
|
||||
"remove": "Remove",
|
||||
"quantityFor": "Quantity for {name}",
|
||||
"notice": {
|
||||
"reduced": "{name} was reduced to {quantity} — that is all we have left.",
|
||||
"soldOut": "{name} sold out and was removed from your bag.",
|
||||
"unavailable": "An item is no longer available and was removed from your bag."
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"contact": "Contact",
|
||||
"delivery": "Delivery",
|
||||
"email": "Email",
|
||||
"fullName": "Full name",
|
||||
"phone": "Phone",
|
||||
"phoneHint": "So the courier can reach you",
|
||||
"address": "Street address",
|
||||
"ward": "Ward",
|
||||
"district": "District",
|
||||
"province": "Province or city",
|
||||
"note": "Delivery note",
|
||||
"summary": "Your order",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"shippingLater": "Calculated later",
|
||||
"total": "Total",
|
||||
"placeOrder": "Place order",
|
||||
"paymentLater": "Payment options arrive with the payments milestone.",
|
||||
"failed": "We could not place your order.",
|
||||
"loading": "Loading your bag…",
|
||||
"emptyBag": "There is nothing in your bag.",
|
||||
"backToShop": "Back to shopping"
|
||||
},
|
||||
"confirmation": {
|
||||
"title": "Order placed",
|
||||
"body": "We have your order. A confirmation goes to {email} once email lands.",
|
||||
"items": "Items",
|
||||
"subtotal": "Subtotal",
|
||||
"total": "Total",
|
||||
"deliveringTo": "Delivering to",
|
||||
"keepShopping": "Keep shopping",
|
||||
"notFound": "We could not find that order.",
|
||||
"backHome": "Back to home"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,13 +82,18 @@
|
||||
"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}"
|
||||
"goToImage": "Xem ảnh {index}",
|
||||
"addedToBag": "Đã thêm vào giỏ"
|
||||
},
|
||||
"search": {
|
||||
"title": "Tìm kiếm",
|
||||
"placeholder": "Tìm sản phẩm, thương hiệu, môn thể thao",
|
||||
"placeholder": "Tìm sản phẩm…",
|
||||
"resultsFor": "Kết quả cho “{query}”",
|
||||
"noQuery": "Nhập từ khoá để bắt đầu tìm kiếm."
|
||||
"noQuery": "Nhập từ khoá để bắt đầu tìm kiếm.",
|
||||
"open": "Tìm kiếm",
|
||||
"dialogHint": "Nhập để xem sản phẩm phù hợp.",
|
||||
"noSuggestions": "Không có sản phẩm phù hợp.",
|
||||
"enterToSearch": "Nhấn Enter để xem tất cả kết quả"
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"home": "Trang chủ"
|
||||
@@ -126,18 +131,10 @@
|
||||
"planned": "Dự kiến: {milestone}"
|
||||
},
|
||||
"placeholder": {
|
||||
"cart": {
|
||||
"title": "Giỏ hàng",
|
||||
"body": "Danh sách sản phẩm kèm phiên bản và tình trạng còn hàng. Tổng tiền luôn được tính lại ở phía máy chủ — client không bao giờ gửi giá."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Bài viết",
|
||||
"body": "Nội dung biên tập từ module CMS: hướng dẫn tập luyện, bộ sưu tập mới và câu chuyện vận động viên."
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Thanh toán",
|
||||
"body": "Thông tin liên hệ, địa chỉ giao hàng, phương thức vận chuyển và thanh toán. Mỗi bước đều được kiểm tra ở máy chủ và hàng được giữ trước khi tạo yêu cầu thanh toán."
|
||||
},
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
"body": "Tổng quan: đơn hàng gần đây, địa chỉ đã lưu và hồ sơ cá nhân."
|
||||
@@ -158,5 +155,60 @@
|
||||
"title": "Yêu thích",
|
||||
"body": "Sản phẩm đã lưu, đồng thời là nguồn dữ liệu cho thông báo khi có hàng trở lại."
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"title": "Giỏ hàng",
|
||||
"empty": "Giỏ hàng đang trống.",
|
||||
"emptyHint": "Sản phẩm bạn thêm sẽ hiện ở đây.",
|
||||
"startShopping": "Bắt đầu mua sắm",
|
||||
"summary": "Tóm tắt",
|
||||
"subtotal": "Tạm tính",
|
||||
"shipping": "Phí vận chuyển",
|
||||
"shippingAtCheckout": "Tính sau",
|
||||
"total": "Tổng cộng",
|
||||
"checkout": "Thanh toán",
|
||||
"remove": "Xoá",
|
||||
"quantityFor": "Số lượng cho {name}",
|
||||
"notice": {
|
||||
"reduced": "{name} đã giảm còn {quantity} — đó là số hàng còn lại.",
|
||||
"soldOut": "{name} đã hết hàng và được bỏ khỏi giỏ.",
|
||||
"unavailable": "Một sản phẩm không còn bán và đã được bỏ khỏi giỏ."
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Thanh toán",
|
||||
"contact": "Liên hệ",
|
||||
"delivery": "Giao hàng",
|
||||
"email": "Email",
|
||||
"fullName": "Họ và tên",
|
||||
"phone": "Số điện thoại",
|
||||
"phoneHint": "Để shipper liên hệ với bạn",
|
||||
"address": "Địa chỉ",
|
||||
"ward": "Phường/Xã",
|
||||
"district": "Quận/Huyện",
|
||||
"province": "Tỉnh/Thành phố",
|
||||
"note": "Ghi chú giao hàng",
|
||||
"summary": "Đơn hàng của bạn",
|
||||
"subtotal": "Tạm tính",
|
||||
"shipping": "Phí vận chuyển",
|
||||
"shippingLater": "Tính sau",
|
||||
"total": "Tổng cộng",
|
||||
"placeOrder": "Đặt hàng",
|
||||
"paymentLater": "Các hình thức thanh toán sẽ có ở giai đoạn thanh toán.",
|
||||
"failed": "Không đặt được đơn hàng.",
|
||||
"loading": "Đang tải giỏ hàng…",
|
||||
"emptyBag": "Giỏ hàng của bạn đang trống.",
|
||||
"backToShop": "Quay lại mua sắm"
|
||||
},
|
||||
"confirmation": {
|
||||
"title": "Đặt hàng thành công",
|
||||
"body": "Chúng tôi đã nhận đơn của bạn. Email xác nhận sẽ gửi tới {email} khi tính năng email sẵn sàng.",
|
||||
"items": "Sản phẩm",
|
||||
"subtotal": "Tạm tính",
|
||||
"total": "Tổng cộng",
|
||||
"deliveringTo": "Giao đến",
|
||||
"keepShopping": "Tiếp tục mua sắm",
|
||||
"notFound": "Không tìm thấy đơn hàng.",
|
||||
"backHome": "Về trang chủ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-0018: Orders snapshot everything they display
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-12
|
||||
|
||||
## Context
|
||||
|
||||
An order line points at a variant. The obvious implementation renders the order
|
||||
by joining through that pointer: read the variant, read its product, show the
|
||||
name and the price.
|
||||
|
||||
That works until the catalog moves, which it does constantly. In this system
|
||||
alone, a merchandiser can rename a product, reprice a variant, archive a
|
||||
colourway, retire an option value or unpublish the whole product — and ADR-0016
|
||||
guarantees the variant row survives precisely so those links do not break. But
|
||||
surviving is not the same as being unchanged. An order rendered by join shows
|
||||
today's name at today's price for something bought last March.
|
||||
|
||||
That is not a display bug. It is a receipt that disagrees with what the customer
|
||||
paid, which is a dispute, a refund calculation and possibly a legal record.
|
||||
|
||||
## Decision
|
||||
|
||||
**An order stores its own copy of everything it displays.** `OrderLine` carries
|
||||
`productName`, `variantTitle`, `sku`, `imageUrl`, `unitAmount`, `quantity` and
|
||||
`lineAmount`. `Order` carries the full shipping address as columns rather than a
|
||||
foreign key into the customer's address book, plus every money component:
|
||||
subtotal, discount, shipping, tax and total.
|
||||
|
||||
**`OrderLine.variantId` is kept, and is `onDelete: SetNull`.** It exists for
|
||||
reporting, returns and restocking — never for rendering. Losing the reporting
|
||||
link is survivable; losing the order line is not.
|
||||
|
||||
**The snapshot is taken in the shopper's language.** The cart composes a variant
|
||||
title from translated option values, and that composed string is what gets
|
||||
frozen. An order is a record of what was agreed, and what was agreed was shown
|
||||
in Vietnamese or English.
|
||||
|
||||
**Money components are stored even when zero.** `discountAmount` and
|
||||
`shippingAmount` are 0 until M7 and M9, but they are columns rather than absent
|
||||
fields, so a total is always the sum of parts someone can name.
|
||||
|
||||
## Consequences
|
||||
|
||||
An order stays readable and correct forever, through any catalog change. Reading
|
||||
one touches two tables and no catalog joins, which also makes the order history
|
||||
cheap.
|
||||
|
||||
Duplication is real: a product name lives once in the catalog and once per order
|
||||
line that ever contained it. That is the point — they are different facts that
|
||||
happen to share a value today.
|
||||
|
||||
Corrections become explicit. Fixing a typo in a product name does not
|
||||
retroactively edit anyone's receipt, and if an order genuinely needs amending,
|
||||
that has to be a deliberate operation with its own audit entry rather than a
|
||||
silent side effect of a catalog edit.
|
||||
|
||||
Analytics that groups by product must group on `variantId`, not on the snapshot
|
||||
name, or a renamed product will appear as two products.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Join to the catalog at read time.** Simplest, no duplication, and wrong for
|
||||
the reason above. Rejected.
|
||||
|
||||
**Snapshot into a JSON blob.** Fewer columns, and gives up every guarantee the
|
||||
database offers — no types, no constraints, no indexing, and a schema that
|
||||
drifts silently. Rejected; the fields are known and stable.
|
||||
|
||||
**Version the catalog and point at a version.** Fully correct and considerably
|
||||
more machinery: every product write creates an immutable revision, and every
|
||||
read has to resolve one. Worth it for a system where catalog history is itself a
|
||||
product. Here it would be a large amount of infrastructure to avoid copying
|
||||
seven columns.
|
||||
|
||||
**Snapshot only price, join the rest.** The hybrid is the worst option: it
|
||||
implies the other fields are safe to join when they are not, and the day someone
|
||||
renames a product the receipts change without anyone noticing.
|
||||
@@ -26,6 +26,7 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
||||
| [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 |
|
||||
| [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted |
|
||||
|
||||
## Decisions deliberately NOT recorded yet
|
||||
|
||||
|
||||
+62
-6
@@ -130,7 +130,8 @@ it is not a design-system primitive.
|
||||
|
||||
## 5. Backend module boundaries
|
||||
|
||||
Twenty modules, each owning its tables exclusively. Full anatomy in
|
||||
Twenty-one modules, each owning its tables exclusively — `checkout` is the exception and owns
|
||||
none, existing purely to coordinate cart, catalog and inventory. Full anatomy in
|
||||
[`apps/api/src/modules/README.md`](../apps/api/src/modules/README.md).
|
||||
|
||||
| Group | Modules |
|
||||
@@ -508,16 +509,57 @@ was assumed rather than asserted. `packages/validation/src/catalog-admin.spec.ts
|
||||
|
||||
So: **when a schema encodes an intent, assert the intent — not the shape.** "Absent means leave
|
||||
alone" and "one language is enough" are claims about behaviour, and a type signature cannot make
|
||||
either of them true. Seed data has been through every code path already; new data has been through none. The
|
||||
either of them true.
|
||||
|
||||
The review before M6 found the sharpest one yet, and no amount of reading would have caught it.
|
||||
Checkout reserved stock by reading `on_hand - reserved`, checking it, then writing
|
||||
`reserved + n` — all inside a transaction, which _looks_ safe and is not. Two concurrent
|
||||
checkouts for a single unit both read `reserved = 0`, both wrote `1`, and **both orders were
|
||||
created**: one unit sold twice, with the reservation count showing one. A transaction gives
|
||||
atomicity, not isolation from a concurrent read-modify-write; under Postgres's default READ
|
||||
COMMITTED the second writer simply overwrites.
|
||||
|
||||
The fix is to let the database do the arithmetic and carry its own guard:
|
||||
|
||||
```sql
|
||||
UPDATE stock_levels SET reserved = reserved + $n
|
||||
WHERE variant_id = $v AND location_id = $l AND on_hand - reserved >= $n
|
||||
```
|
||||
|
||||
Zero rows affected means someone got there first. The same shape now applies to releasing and
|
||||
committing reservations, and order status transitions use a compare-and-set on the status they
|
||||
were validated against.
|
||||
|
||||
So: **any state that two requests can contend for must be changed in one statement that re-checks
|
||||
its own precondition.** Reading, deciding in JavaScript and then writing is a lost update wearing
|
||||
a transaction as a disguise — and the test for it is not a code review, it is two requests fired
|
||||
at once. 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`.
|
||||
|
||||
M6 added a variant that is worth naming separately, because the symptom pointed at the wrong
|
||||
layer entirely. Search returned nothing for typos, SKUs or Vietnamese without diacritics — but
|
||||
matched exact names perfectly. Two separate causes, and the first masked the second:
|
||||
|
||||
1. `listByIds` passed the whole filter through to the catalog, which re-applied `q` as a plain
|
||||
`name CONTAINS q`. Everything search found by brand, SKU, typo or diacritic was then
|
||||
intersected away by a substring match on the name.
|
||||
2. A SQL comment inside a tagged template literal contained a backtick. That terminated the
|
||||
template, produced invalid JavaScript, and the API **crashed on startup** — while an older
|
||||
process kept serving port 4000. Every test I ran was answered by the previous build.
|
||||
|
||||
The second is the one to remember. `pkill -f "node dist/main.js"` had not matched the running
|
||||
process, `/health` returned 200 the whole time, and the build itself reported success. Restarts
|
||||
are now done by killing whatever holds the port and then confirming the new process actually
|
||||
logged a successful start — checking that the port answers proves nothing about _which_ build is
|
||||
answering.
|
||||
|
||||
---
|
||||
|
||||
## 16. Deliberate limitations
|
||||
|
||||
Stated plainly so they are choices rather than oversights.
|
||||
|
||||
**Shipped (M0–M3)**
|
||||
**Shipped (M0–M6)**
|
||||
|
||||
- Catalog reads: products, variants, options, categories, collections, brands, navigation —
|
||||
localised, cached, filtered and faceted.
|
||||
@@ -527,6 +569,15 @@ Stated plainly so they are choices rather than oversights.
|
||||
- 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.
|
||||
- Storefront listing controls: sort, load-more pagination that stays crawlable, and a mobile
|
||||
filter sheet.
|
||||
- Search: PostgreSQL full-text with weighted documents, diacritic folding and trigram typo
|
||||
tolerance, behind a `SearchProvider` seam (ADR-0012). The index is a projection owned by
|
||||
SearchModule and rebuilt from a domain event, so nothing else knows it exists.
|
||||
- Commerce: a Redis-backed guest bag priced from the live catalog on every read, guest checkout,
|
||||
orders that snapshot everything they display (ADR-0018), stock reserved at placement and either
|
||||
released on cancel or shipped on fulfilment, and an admin order lifecycle with an explicit
|
||||
transition table.
|
||||
|
||||
**Not built yet, and why**
|
||||
|
||||
@@ -539,9 +590,14 @@ Stated plainly so they are choices rather than oversights.
|
||||
endpoint. Noted in the code.
|
||||
- **No email.** Password reset, order confirmations and back-in-stock alerts all need it; Mailpit
|
||||
is already running locally for when it lands.
|
||||
- **No cart, order or payment tables.** M5, so the migration history stays reviewable.
|
||||
- **`best_selling` and `relevance` sorts fall back to newest.** No order data (M5), no ranking
|
||||
(M6). Falling back is honest; a fake ranking would not be.
|
||||
- **No payment tables.** M9. An order lands `PENDING` / `UNPAID`; nothing pretends money moved.
|
||||
- **No shipping cost.** M9. `shippingAmount` is a stored zero rather than an absent field, so a
|
||||
total is always the sum of parts someone can name.
|
||||
- **Carts are not promoted to PostgreSQL.** A guest bag lives in Redis and is the acceptable loss
|
||||
named in ADR-0010; attaching one to a customer account arrives with M8.
|
||||
- **`best_selling` still falls back to newest.** Orders exist now, but ranking by them needs enough
|
||||
of them to mean something. `relevance` is real as of M6. Falling back is honest; a fake ranking
|
||||
is not.
|
||||
- **No sport/gender facet counts.** Navigated by route, not refined in-page, so a count would
|
||||
render nowhere.
|
||||
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it is not is
|
||||
|
||||
@@ -3,6 +3,12 @@ import { createAdminResource, type AdminResource } from './resources/admin';
|
||||
import { createAuthResource, type AuthResource } from './resources/auth';
|
||||
import { createCatalogResource, type CatalogResource } from './resources/catalog';
|
||||
import { createCatalogAdminResource, type CatalogAdminResource } from './resources/catalog-admin';
|
||||
import {
|
||||
createCommerceResource,
|
||||
createOrdersAdminResource,
|
||||
type CommerceResource,
|
||||
type OrdersAdminResource,
|
||||
} from './resources/commerce';
|
||||
import { createHealthResource, type HealthResource } from './resources/health';
|
||||
|
||||
/**
|
||||
@@ -18,6 +24,8 @@ export interface ApiClient {
|
||||
readonly auth: AuthResource;
|
||||
readonly admin: AdminResource;
|
||||
readonly catalogAdmin: CatalogAdminResource;
|
||||
readonly commerce: CommerceResource;
|
||||
readonly ordersAdmin: OrdersAdminResource;
|
||||
}
|
||||
|
||||
export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
@@ -30,5 +38,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
auth: createAuthResource(http),
|
||||
admin: createAdminResource(http),
|
||||
catalogAdmin: createCatalogAdminResource(http),
|
||||
commerce: createCommerceResource(http),
|
||||
ordersAdmin: createOrdersAdminResource(http),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,4 +30,10 @@ export type {
|
||||
StockAdjustment,
|
||||
VariantPatch,
|
||||
} from './resources/catalog-admin';
|
||||
export type {
|
||||
CommerceResource,
|
||||
OrderListParams,
|
||||
OrdersAdminResource,
|
||||
PlaceOrderInput,
|
||||
} from './resources/commerce';
|
||||
export type { ApiClient } from './create-client';
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Locale,
|
||||
NavigationMenu,
|
||||
ProductListResult,
|
||||
SearchSuggestions,
|
||||
StorefrontProduct,
|
||||
} from '@sport/types';
|
||||
|
||||
@@ -46,6 +47,16 @@ export interface CatalogResource {
|
||||
query?: ProductListQuery,
|
||||
options?: RequestOptions,
|
||||
): Promise<ProductListResult>;
|
||||
/**
|
||||
* Ranked search. Same result shape as a listing, so the same components
|
||||
* render it — only the ordering and the matching differ.
|
||||
*/
|
||||
searchProducts(
|
||||
locale: Locale,
|
||||
query?: ProductListQuery,
|
||||
options?: RequestOptions,
|
||||
): Promise<ProductListResult>;
|
||||
suggest(locale: Locale, query: string, options?: RequestOptions): Promise<SearchSuggestions>;
|
||||
getProduct(locale: Locale, slug: string, options?: RequestOptions): Promise<StorefrontProduct>;
|
||||
listProductSlugs(
|
||||
locale: Locale,
|
||||
@@ -79,6 +90,15 @@ export function createCatalogResource(http: HttpClient): CatalogResource {
|
||||
listProducts: (locale, query = {}, options) =>
|
||||
http.get<ProductListResult>('/products', withLocale(locale, { ...query }, options)),
|
||||
|
||||
searchProducts: (locale, query = {}, options) =>
|
||||
http.get<ProductListResult>('/search', withLocale(locale, { ...query }, options)),
|
||||
|
||||
suggest: (locale, query, options) =>
|
||||
http.get<SearchSuggestions>(
|
||||
'/search/suggestions',
|
||||
withLocale(locale, { q: query }, { ...options, cache: 'no-store' }),
|
||||
),
|
||||
|
||||
getProduct: (locale, slug, options) =>
|
||||
http.get<StorefrontProduct>(
|
||||
`/products/${encodeURIComponent(slug)}`,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
Cart,
|
||||
Locale,
|
||||
OffsetPaginated,
|
||||
Order,
|
||||
OrderListItem,
|
||||
ShippingAddress,
|
||||
} from '@sport/types';
|
||||
|
||||
import type { HttpClient, RequestOptions } from '../http-client';
|
||||
|
||||
/**
|
||||
* Mirrors `placeOrderSchema` in @sport/validation.
|
||||
*
|
||||
* Declared here rather than imported because this package depends on
|
||||
* @sport/types alone — pulling in Zod would put a validation runtime into every
|
||||
* consumer, including a future React Native app. The server re-validates, so
|
||||
* this type is a convenience for callers and never the enforcement point.
|
||||
*/
|
||||
export interface PlaceOrderInput {
|
||||
email: string;
|
||||
shippingAddress: ShippingAddress;
|
||||
customerNote?: string | null;
|
||||
}
|
||||
|
||||
export interface OrderListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
q?: string;
|
||||
status?: 'PENDING' | 'CONFIRMED' | 'FULFILLED' | 'COMPLETED' | 'CANCELLED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cart, checkout and order access.
|
||||
*
|
||||
* Every cart call must send credentials: the bag is identified by an httpOnly
|
||||
* cookie, so a request without it silently starts a new, empty cart. The
|
||||
* `credentials` option below is the difference between "add to bag works" and
|
||||
* "add to bag appears to work".
|
||||
*/
|
||||
export interface CommerceResource {
|
||||
getCart(locale: Locale, options?: RequestOptions): Promise<Cart>;
|
||||
addCartLine(
|
||||
locale: Locale,
|
||||
input: { variantId: string; quantity?: number },
|
||||
options?: RequestOptions,
|
||||
): Promise<Cart>;
|
||||
updateCartLine(
|
||||
locale: Locale,
|
||||
variantId: string,
|
||||
quantity: number,
|
||||
options?: RequestOptions,
|
||||
): Promise<Cart>;
|
||||
removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>;
|
||||
|
||||
getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>;
|
||||
placeOrder(locale: Locale, input: PlaceOrderInput, options?: RequestOptions): Promise<Order>;
|
||||
lookupOrder(orderNumber: string, email: string, options?: RequestOptions): Promise<Order>;
|
||||
getPlacedOrder(id: string, options?: RequestOptions): Promise<Order>;
|
||||
}
|
||||
|
||||
export interface OrdersAdminResource {
|
||||
listOrders(
|
||||
params?: OrderListParams,
|
||||
options?: RequestOptions,
|
||||
): Promise<OffsetPaginated<OrderListItem>>;
|
||||
getOrder(id: string, options?: RequestOptions): Promise<Order>;
|
||||
updateOrderStatus(
|
||||
id: string,
|
||||
status: OrderListParams['status'],
|
||||
reason?: string | null,
|
||||
options?: RequestOptions,
|
||||
): Promise<Order>;
|
||||
}
|
||||
|
||||
export function createCommerceResource(http: HttpClient): CommerceResource {
|
||||
/**
|
||||
* `credentials: 'include'` on every call, not just the mutations.
|
||||
*
|
||||
* The cart cookie is httpOnly and same-origin, and a GET without credentials
|
||||
* reads a different (empty) bag than the POST that filled it — a failure that
|
||||
* looks like "the cart randomly empties" rather than a missing option.
|
||||
*/
|
||||
const cartOptions = (locale: Locale, options?: RequestOptions): RequestOptions => ({
|
||||
...options,
|
||||
credentials: 'include',
|
||||
query: { locale, ...options?.query },
|
||||
// A bag is per-visitor state; caching it anywhere would serve someone
|
||||
// else's.
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return {
|
||||
getCart: (locale, options) => http.get<Cart>('/cart', cartOptions(locale, options)),
|
||||
|
||||
addCartLine: (locale, input, options) =>
|
||||
http.post<Cart>('/cart/lines', input, cartOptions(locale, options)),
|
||||
|
||||
updateCartLine: (locale, variantId, quantity, options) =>
|
||||
http.patch<Cart>(
|
||||
`/cart/lines/${encodeURIComponent(variantId)}`,
|
||||
{ quantity },
|
||||
cartOptions(locale, options),
|
||||
),
|
||||
|
||||
removeCartLine: (locale, variantId, options) =>
|
||||
http.delete<Cart>(
|
||||
`/cart/lines/${encodeURIComponent(variantId)}`,
|
||||
cartOptions(locale, options),
|
||||
),
|
||||
|
||||
getCheckoutQuote: (locale, options) =>
|
||||
http.get<Cart>('/checkout/quote', cartOptions(locale, options)),
|
||||
|
||||
placeOrder: (locale, input, options) =>
|
||||
http.post<Order>('/checkout/orders', input, cartOptions(locale, options)),
|
||||
|
||||
getPlacedOrder: (id, options) =>
|
||||
http.get<Order>(`/checkout/orders/${encodeURIComponent(id)}`, {
|
||||
...options,
|
||||
cache: 'no-store',
|
||||
}),
|
||||
|
||||
lookupOrder: (orderNumber, email, options) =>
|
||||
http.get<Order>('/checkout/orders/lookup', {
|
||||
...options,
|
||||
query: { orderNumber, email },
|
||||
cache: 'no-store',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrdersAdminResource(http: HttpClient): OrdersAdminResource {
|
||||
return {
|
||||
listOrders: (params = {}, options) =>
|
||||
http.get<OffsetPaginated<OrderListItem>>('/admin/orders', {
|
||||
...options,
|
||||
query: { ...params, ...options?.query },
|
||||
}),
|
||||
|
||||
getOrder: (id, options) => http.get<Order>(`/admin/orders/${encodeURIComponent(id)}`, options),
|
||||
|
||||
updateOrderStatus: (id, status, reason, options) =>
|
||||
http.patch<Order>(
|
||||
`/admin/orders/${encodeURIComponent(id)}/status`,
|
||||
{ status, reason: reason ?? null },
|
||||
options,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Slug } from '../primitives';
|
||||
|
||||
export interface SearchSuggestion {
|
||||
/** The product name, as it will be shown in the dropdown. */
|
||||
readonly text: string;
|
||||
readonly productSlug: Slug;
|
||||
}
|
||||
|
||||
export interface SearchSuggestions {
|
||||
/** Echoed back so a late response can be discarded against the current input. */
|
||||
readonly query: string;
|
||||
readonly suggestions: readonly SearchSuggestion[];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Id, Money, Nullable } from '../primitives';
|
||||
|
||||
/**
|
||||
* The cart as the storefront sees it.
|
||||
*
|
||||
* Every price here is computed by the API from the live catalog on each read —
|
||||
* the stored cart holds nothing but variant ids and quantities (ADR-0010). That
|
||||
* is what makes a thirty-day-old cart safe: it cannot carry a stale price into
|
||||
* an order, and a tampered payload cannot underpay.
|
||||
*/
|
||||
export interface Cart {
|
||||
readonly id: Id;
|
||||
readonly lines: readonly CartLine[];
|
||||
readonly totals: CartTotals;
|
||||
/** Lines dropped since the cart was last seen, so the UI can explain itself. */
|
||||
readonly notices: readonly CartNotice[];
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CartLine {
|
||||
readonly variantId: Id;
|
||||
readonly productName: string;
|
||||
readonly productSlug: string;
|
||||
readonly variantTitle: string;
|
||||
readonly sku: string;
|
||||
readonly imageUrl: Nullable<string>;
|
||||
readonly unitPrice: Money;
|
||||
/** The pre-markdown price when this line is discounted, for a strikethrough. */
|
||||
readonly compareAtPrice: Nullable<Money>;
|
||||
readonly quantity: number;
|
||||
readonly lineTotal: Money;
|
||||
/** What the shopper may still raise this line to, given stock. */
|
||||
readonly maxQuantity: number;
|
||||
}
|
||||
|
||||
export interface CartTotals {
|
||||
readonly itemCount: number;
|
||||
readonly subtotal: Money;
|
||||
readonly discount: Money;
|
||||
readonly shipping: Money;
|
||||
readonly tax: Money;
|
||||
readonly total: Money;
|
||||
}
|
||||
|
||||
export const CART_NOTICE_REASONS = {
|
||||
/** The variant was archived or the product unpublished since it was added. */
|
||||
UNAVAILABLE: 'UNAVAILABLE',
|
||||
/** Quantity was lowered to what is actually in stock. */
|
||||
QUANTITY_REDUCED: 'QUANTITY_REDUCED',
|
||||
/** Nothing left; the line was removed entirely. */
|
||||
OUT_OF_STOCK: 'OUT_OF_STOCK',
|
||||
/** The price moved while the cart was sitting there. */
|
||||
PRICE_CHANGED: 'PRICE_CHANGED',
|
||||
} as const;
|
||||
|
||||
export type CartNoticeReason = (typeof CART_NOTICE_REASONS)[keyof typeof CART_NOTICE_REASONS];
|
||||
|
||||
export interface CartNotice {
|
||||
readonly reason: CartNoticeReason;
|
||||
readonly variantId: Id;
|
||||
readonly productName: string;
|
||||
readonly previousQuantity: Nullable<number>;
|
||||
readonly quantity: Nullable<number>;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CurrencyCode, Id, Money, Nullable } from '../primitives';
|
||||
|
||||
export const ORDER_STATUSES = {
|
||||
PENDING: 'PENDING',
|
||||
CONFIRMED: 'CONFIRMED',
|
||||
FULFILLED: 'FULFILLED',
|
||||
COMPLETED: 'COMPLETED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
} as const;
|
||||
|
||||
export type OrderStatus = (typeof ORDER_STATUSES)[keyof typeof ORDER_STATUSES];
|
||||
|
||||
export const PAYMENT_STATUSES = {
|
||||
UNPAID: 'UNPAID',
|
||||
PAID: 'PAID',
|
||||
PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
} as const;
|
||||
|
||||
export type PaymentStatus = (typeof PAYMENT_STATUSES)[keyof typeof PAYMENT_STATUSES];
|
||||
|
||||
export const FULFILLMENT_STATUSES = {
|
||||
UNFULFILLED: 'UNFULFILLED',
|
||||
PARTIALLY_FULFILLED: 'PARTIALLY_FULFILLED',
|
||||
FULFILLED: 'FULFILLED',
|
||||
} as const;
|
||||
|
||||
export type FulfillmentStatus = (typeof FULFILLMENT_STATUSES)[keyof typeof FULFILLMENT_STATUSES];
|
||||
|
||||
export interface ShippingAddress {
|
||||
readonly fullName: string;
|
||||
readonly phone: string;
|
||||
readonly line1: string;
|
||||
readonly line2: Nullable<string>;
|
||||
readonly ward: Nullable<string>;
|
||||
readonly district: Nullable<string>;
|
||||
readonly province: string;
|
||||
readonly countryCode: string;
|
||||
readonly postalCode: Nullable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placed order.
|
||||
*
|
||||
* Every field is a snapshot taken when the order was placed. Nothing here is
|
||||
* joined from the catalog at read time, which is why an order stays readable
|
||||
* after a product is renamed, repriced or archived.
|
||||
*/
|
||||
export interface Order {
|
||||
readonly id: Id;
|
||||
/** Formatted for display, e.g. "SP-000123". */
|
||||
readonly orderNumber: string;
|
||||
readonly status: OrderStatus;
|
||||
readonly paymentStatus: PaymentStatus;
|
||||
readonly fulfillmentStatus: FulfillmentStatus;
|
||||
|
||||
readonly email: string;
|
||||
readonly phone: string;
|
||||
readonly shippingAddress: ShippingAddress;
|
||||
readonly customerNote: Nullable<string>;
|
||||
|
||||
readonly currency: CurrencyCode;
|
||||
readonly subtotal: Money;
|
||||
readonly discount: Money;
|
||||
readonly shipping: Money;
|
||||
readonly tax: Money;
|
||||
readonly total: Money;
|
||||
|
||||
readonly lines: readonly OrderLine[];
|
||||
|
||||
readonly placedAt: string;
|
||||
readonly confirmedAt: Nullable<string>;
|
||||
readonly cancelledAt: Nullable<string>;
|
||||
readonly cancelReason: Nullable<string>;
|
||||
}
|
||||
|
||||
export interface OrderLine {
|
||||
readonly id: Id;
|
||||
readonly variantId: Nullable<Id>;
|
||||
readonly productName: string;
|
||||
readonly variantTitle: string;
|
||||
readonly sku: string;
|
||||
readonly imageUrl: Nullable<string>;
|
||||
readonly unitPrice: Money;
|
||||
readonly quantity: number;
|
||||
readonly lineTotal: Money;
|
||||
}
|
||||
|
||||
/** Row shape for the admin order table. */
|
||||
export interface OrderListItem {
|
||||
readonly id: Id;
|
||||
readonly orderNumber: string;
|
||||
readonly status: OrderStatus;
|
||||
readonly paymentStatus: PaymentStatus;
|
||||
readonly fulfillmentStatus: FulfillmentStatus;
|
||||
readonly email: string;
|
||||
readonly customerName: string;
|
||||
readonly itemCount: number;
|
||||
readonly total: Money;
|
||||
readonly placedAt: string;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export * from './catalog/taxonomy';
|
||||
export * from './catalog/media';
|
||||
export * from './catalog/navigation';
|
||||
export * from './catalog/facets';
|
||||
export * from './catalog/search';
|
||||
export * from './catalog/admin';
|
||||
export * from './i18n/locale';
|
||||
export * from './inventory/stock';
|
||||
export * from './commerce/cart';
|
||||
export * from './commerce/order';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { anyIdSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
/**
|
||||
* Cart and checkout input.
|
||||
*
|
||||
* A cart line carries a variant and a quantity and nothing else. There is
|
||||
* deliberately no price field anywhere in this file: prices are the API's to
|
||||
* compute, and accepting one from the client would make every total a
|
||||
* negotiation.
|
||||
*/
|
||||
|
||||
/** One line cannot exceed this, regardless of stock — it is a shop, not a wholesaler. */
|
||||
export const MAX_LINE_QUANTITY = 20;
|
||||
|
||||
export const addCartLineSchema = z.object({
|
||||
variantId: anyIdSchema,
|
||||
quantity: z.coerce.number().int().min(1).max(MAX_LINE_QUANTITY).default(1),
|
||||
});
|
||||
|
||||
export const updateCartLineSchema = z.object({
|
||||
/** Zero removes the line, which is what a quantity stepper reaching 0 means. */
|
||||
quantity: z.coerce.number().int().min(0).max(MAX_LINE_QUANTITY),
|
||||
});
|
||||
|
||||
/** Vietnamese addresses: ward and district are optional because rural
|
||||
* addresses frequently have neither, and rejecting those loses real orders. */
|
||||
export const shippingAddressSchema = z.object({
|
||||
fullName: z.string().trim().min(1).max(160),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(?:\+84|0)\d{8,10}$/, 'Enter a Vietnamese phone number'),
|
||||
line1: z.string().trim().min(1).max(255),
|
||||
line2: z.string().trim().max(255).nullish(),
|
||||
ward: z.string().trim().max(120).nullish(),
|
||||
district: z.string().trim().max(120).nullish(),
|
||||
province: z.string().trim().min(1).max(120),
|
||||
countryCode: z.string().trim().length(2).default('VN'),
|
||||
postalCode: z.string().trim().max(20).nullish(),
|
||||
});
|
||||
|
||||
export const placeOrderSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email().max(255),
|
||||
shippingAddress: shippingAddressSchema,
|
||||
customerNote: z.string().trim().max(1000).nullish(),
|
||||
});
|
||||
|
||||
export const orderListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
status: z.enum(['PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED']).optional(),
|
||||
});
|
||||
|
||||
export const updateOrderStatusSchema = z.object({
|
||||
status: z.enum(['PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED']),
|
||||
/** Required when cancelling: "why" is the whole value of the audit entry. */
|
||||
reason: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
export type AddCartLineInput = z.output<typeof addCartLineSchema>;
|
||||
export type UpdateCartLineInput = z.output<typeof updateCartLineSchema>;
|
||||
export type ShippingAddressInput = z.output<typeof shippingAddressSchema>;
|
||||
export type PlaceOrderInput = z.output<typeof placeOrderSchema>;
|
||||
export type OrderListQuery = z.output<typeof orderListQuerySchema>;
|
||||
export type UpdateOrderStatusInput = z.output<typeof updateOrderStatusSchema>;
|
||||
@@ -17,3 +17,4 @@ export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './users';
|
||||
export * from './catalog-admin';
|
||||
export * from './commerce';
|
||||
|
||||
Reference in New Issue
Block a user