Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
+29 -12
View File
@@ -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>
);
}
+10 -9
View File
@@ -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">
+32
View File
@@ -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',
});
}
+55
View File
@@ -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"
}
}
}
+55
View File
@@ -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);
+167
View File
@@ -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")
}
+5
View File
@@ -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,
},
});
+50
View File
@@ -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;
}
}
+16 -13
View File
@@ -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 {}
+353
View File
@@ -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;
}
+2 -1
View File
@@ -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 },
};
}
}
+16 -16
View File
@@ -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,
},
});
}
}
}
+2 -1
View File
@@ -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`);
}
}
+2 -1
View File
@@ -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() };
}
}
+28 -16
View File
@@ -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)}
/>
);
}
+6 -1
View File
@@ -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;
}
+22
View File
@@ -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,
+1
View File
@@ -19,6 +19,7 @@ export const routes = {
cart: () => '/cart',
checkout: () => '/checkout',
orderConfirmation: () => '/order-confirmation',
account: () => '/account',
accountProfile: () => '/account/profile',
+63 -11
View File
@@ -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"
}
}
+63 -11
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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),
};
}
+6
View File
@@ -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,
),
};
}
+13
View File
@@ -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[];
}
+64
View File
@@ -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>;
}
+101
View File
@@ -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;
}
+3
View File
@@ -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';
+67
View File
@@ -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>;
+1
View File
@@ -17,3 +17,4 @@ export * from './auth';
export * from './catalog';
export * from './users';
export * from './catalog-admin';
export * from './commerce';