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
@@ -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ủ"
}
}