This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
+23 -5
View File
@@ -158,7 +158,7 @@ sport-store/
│ │
├── docs/ ├── docs/
│ ├── architecture.md Boundaries, conventions, risks — read this first │ ├── architecture.md Boundaries, conventions, risks — read this first
│ └── adr/ 19 decision records │ └── adr/ 22 decision records
│ │
├── docker-compose.yml Backing services; `--profile full` runs everything ├── docker-compose.yml Backing services; `--profile full` runs everything
├── turbo.json pnpm-workspace.yaml package.json ├── turbo.json pnpm-workspace.yaml package.json
@@ -253,11 +253,11 @@ locale-in-path would buy nothing.
| **M4** ✅ | Storefront catalog — listings, PDP, variant selector, filters, sort control, load-more pagination and a mobile filter sheet | | **M4** ✅ | Storefront catalog — listings, PDP, variant selector, filters, sort control, load-more pagination and a mobile filter sheet |
| **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle | | **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle |
| **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results | | **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results |
| **M7** | Promotions, coupons, reviews, CMS | | **M7** ◐ | Discounts end-to-end: one engine, one admin screen, stacking, windows, limits, redemptions. Reviews and CMS remain |
| **M8** | Customer account | | **M8** | Customer account |
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
**Recommended next step: M7 (promotions, coupons, reviews, CMS) or M9 (payments).** The store can **Recommended next step: M8 (customer accounts).** The store can
now be browsed, searched, filled into a bag and checked out, and every order moves stock through a now be browsed, searched, filled into a bag and checked out, and every order moves stock through a
ledger. What it still cannot do is take money — which is the one gap between this and a shop that ledger. What it still cannot do is take money — which is the one gap between this and a shop that
trades. trades.
@@ -270,9 +270,9 @@ Everything below was run, not assumed:
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass; - `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass;
`pnpm format:check` clean `pnpm format:check` clean
- 7 migrations, 35 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, - 10 migrations, 43 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
- **60 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the - **78 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory
list's variant-driven projection, the two admin-schema defects that caused silent data loss, and list's variant-driven projection, the two admin-schema defects that caused silent data loss, and
order-number round-tripping order-number round-tripping
@@ -303,6 +303,24 @@ through Chrome with the console and network panel open:
media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG
landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table
wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request
- **Discounts (M7):** an automatic promotion and a coupon stack to −150.000 ₫ on a 1.290.000 ₫
bag; a lowercase code is accepted; a fully-claimed code is refused with a reason and the bag
falls back to the automatic offer; a two-use limit allows exactly two orders; cancelling an
order returns its use; two simultaneous redemptions of the last use produce one discounted
order and one clear refusal; a bogus code reports once and is not remembered
- **The discount admin (M7):** a promotion authored at 09:00 local stores as 02:00Z and reopens
at 09:00, not shifted; a discount scheduled for next week reads "Scheduled" and stays out of
the bag; switching one off in admin drops it from a shopper's bag on the next load; retiring
one soft-deletes it, leaving redemptions and the audit trail intact
- **Content (M7):** a post published in one language still lists on the other locale's journal
rather than vanishing; a body containing `<script>` and `<img onerror>` renders as visible text
and executes nothing; a Markdown link to `/men` keeps its locale prefix; a published page
appears in the footer; a draft is not reachable from the storefront
- **Reviews (M7):** a review can only be written against a line on an order whose email matches,
and a wrong email 404s exactly like an unknown order; the same item cannot be reviewed twice;
a submitted review stays invisible until approved; approving updates the product's rating
immediately rather than after the cache expires; rejecting an approved review takes its stars
back out; a rejected review never reaches the storefront
- **Checkout is idempotent:** a request without an `Idempotency-Key` is refused; the same key - **Checkout is idempotent:** a request without an `Idempotency-Key` is refused; the same key
twice returns the _same_ order rather than a second one; two simultaneous requests with one key twice returns the _same_ order rather than a second one; two simultaneous requests with one key
yield one order and one clear refusal — total order count grows by exactly one in every case yield one order and one clear refusal — total order count grows by exactly one in every case
@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import type { ContentType } from '@sport/types';
import { ContentEditor } from '@/features/cms/content-editor';
type PageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ type?: string }>;
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.cms');
return { title: t('title') };
}
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params;
const { type } = await searchParams;
const t = await getTranslations('cms');
// `?type=` only seeds a new entry; an existing one carries its own type, and
// trusting the query string there would let a URL flip a page into a post.
const initialType: ContentType = type === 'PAGE' ? 'PAGE' : 'POST';
return (
<div className="max-w-4xl space-y-6 p-8">
<Link href="/cms" className="text-ink-500 hover:text-ink-950 text-xs">
← {t('backToList')}
</Link>
<ContentEditor entryId={id} initialType={initialType} />
</div>
);
}
+10 -8
View File
@@ -1,22 +1,24 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { ContentTable } from '@/features/cms/content-table';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.cms'); const t = await getTranslations('pages.cms');
return { title: t('title') }; return { title: t('title') };
} }
export default async function CmsPage() { export default async function Page() {
const t = await getTranslations('pages.cms'); const t = await getTranslations('pages.cms');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="cms.read" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M7 — content" </header>
/>
<ContentTable />
</div>
); );
} }
@@ -1,22 +0,0 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.coupons');
return { title: t('title') };
}
export default async function CouponsPage() {
const t = await getTranslations('pages.coupons');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="coupon.manage"
milestone="M7 — marketing"
/>
);
}
@@ -0,0 +1,24 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { DiscountsTable } from '@/features/discounts/discounts-table';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.discounts');
return { title: t('title') };
}
export default async function Page() {
const t = await getTranslations('pages.discounts');
return (
<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('body')}</p>
</header>
<DiscountsTable />
</div>
);
}
@@ -1,22 +0,0 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.promotions');
return { title: t('title') };
}
export default async function PromotionsPage() {
const t = await getTranslations('pages.promotions');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="promotion.manage"
milestone="M7 — marketing"
/>
);
}
@@ -1,22 +1,24 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import { ReviewsQueue } from '@/features/reviews/reviews-queue';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.reviews'); const t = await getTranslations('pages.reviews');
return { title: t('title') }; return { title: t('title') };
} }
export default async function ReviewsPage() { export default async function Page() {
const t = await getTranslations('pages.reviews'); const t = await getTranslations('pages.reviews');
return ( return (
<PageScaffold <div className="space-y-6 p-8">
title={t('title')} <header>
description={t('body')} <h1 className="text-2xl font-bold">{t('title')}</h1>
permission="review.moderate" <p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
milestone="M7 — marketing" </header>
/>
<ReviewsQueue />
</div>
); );
} }
@@ -0,0 +1,361 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState, type FormEvent } from 'react';
import { isApiClientError, type ContentEntryPayload } from '@sport/api-client';
import {
DEFAULT_LOCALE,
LOCALES,
type AdminContentEntry,
type ContentStatus,
type ContentType,
type Locale,
} from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
interface Fields {
slug: string;
title: string;
excerpt: string;
body: string;
metaTitle: string;
metaDescription: string;
}
interface Draft {
type: ContentType;
status: ContentStatus;
translations: Record<Locale, Fields>;
}
const EMPTY: Fields = {
slug: '',
title: '',
excerpt: '',
body: '',
metaTitle: '',
metaDescription: '',
};
function emptyDraft(type: ContentType): Draft {
return {
type,
status: 'DRAFT',
translations: Object.fromEntries(
LOCALES.map((locale) => [locale, { ...EMPTY }]),
) as Draft['translations'],
};
}
function toDraft(entry: AdminContentEntry): Draft {
return {
type: entry.type,
status: entry.status,
translations: Object.fromEntries(
LOCALES.map((locale) => {
const source = entry.translations[locale];
return [
locale,
source
? {
slug: source.slug,
title: source.title,
excerpt: source.excerpt ?? '',
body: source.body,
metaTitle: source.metaTitle ?? '',
metaDescription: source.metaDescription ?? '',
}
: { ...EMPTY },
];
}),
) as Draft['translations'],
};
}
/**
* Writes one page or post, in every language.
*
* A full page rather than a dialog: the body is long-form Markdown, and a
* modal that scrolls internally is a poor place to write anything longer than
* a sentence.
*/
export function ContentEditor({
entryId,
initialType,
}: {
entryId: string;
initialType: ContentType;
}) {
const t = useTranslations('cms');
const router = useRouter();
const isNew = entryId === 'new';
const [draft, setDraft] = useState<Draft | null>(() => (isNew ? emptyDraft(initialType) : null));
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
useEffect(() => {
if (isNew) return;
let cancelled = false;
browserApi.contentAdmin
.getById(entryId)
.then((entry) => {
if (!cancelled) setDraft(toDraft(entry));
})
.catch((caught: unknown) => {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [entryId, isNew, t]);
function setField<K extends keyof Fields>(key: K, value: string) {
setDraft((current) =>
current
? {
...current,
translations: {
...current.translations,
[locale]: { ...current.translations[locale], [key]: value },
},
}
: current,
);
}
async function save(event: FormEvent, status: ContentStatus) {
event.preventDefault();
if (!draft) return;
setError(null);
setSaving(true);
try {
const payload: ContentEntryPayload = {
type: draft.type,
status,
// Only languages actually written. Sending an empty title would fail
// the schema and block saving a post drafted in one language, which is
// how most of them start.
translations: Object.fromEntries(
LOCALES.filter((key) => draft.translations[key].title.trim()).map((key) => {
const fields = draft.translations[key];
return [
key,
{
slug: fields.slug.trim(),
title: fields.title.trim(),
excerpt: fields.excerpt.trim() || null,
body: fields.body,
metaTitle: fields.metaTitle.trim() || null,
metaDescription: fields.metaDescription.trim() || null,
},
];
}),
),
};
const saved = isNew
? await browserApi.contentAdmin.create(payload)
: await browserApi.contentAdmin.update(entryId, payload);
setDraft(toDraft(saved));
// Replace rather than push: going "back" from a freshly created entry
// should reach the list, not an empty create form that would make a
// second copy on save.
if (isNew) router.replace(`/cms/${saved.id}`);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
async function remove() {
setSaving(true);
try {
await browserApi.contentAdmin.remove(entryId);
router.push('/cms');
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
setConfirmingDelete(false);
setSaving(false);
}
}
if (error && !draft) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!draft) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const fields = draft.translations[locale];
return (
<form onSubmit={(event) => void save(event, draft.status)} className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">{t(`type.${draft.type}`)}</Badge>
<Badge variant={draft.status === 'PUBLISHED' ? 'success' : 'neutral'}>
{t(`status.${draft.status}`)}
</Badge>
<div className="border-ink-200 rounded-card ml-auto flex overflow-hidden border">
{LOCALES.map((key) => (
<button
key={key}
type="button"
onClick={() => setLocale(key)}
aria-pressed={locale === key}
className={cn(
'px-3 py-1.5 text-xs font-semibold uppercase tracking-widest transition-colors',
locale === key ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
// A language with no title yet is not written — worth showing,
// because an operator switching tabs needs to know which
// versions will actually be saved.
!draft.translations[key].title.trim() && locale !== key ? 'text-ink-400' : '',
)}
>
{key}
{draft.translations[key].title.trim() ? '' : ' ·'}
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('title')}</span>
<Input value={fields.title} onChange={(event) => setField('title', event.target.value)} />
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('slug')}</span>
<Input
value={fields.slug}
onChange={(event) => setField('slug', event.target.value)}
placeholder="how-to-layer"
/>
<span className="text-ink-400 text-xs">{t('slugHint')}</span>
</label>
</div>
{draft.type === 'POST' ? (
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('excerpt')}</span>
<Input
value={fields.excerpt}
onChange={(event) => setField('excerpt', event.target.value)}
/>
<span className="text-ink-400 text-xs">{t('excerptHint')}</span>
</label>
) : null}
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('body')}</span>
<textarea
value={fields.body}
onChange={(event) => setField('body', event.target.value)}
rows={22}
spellCheck
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 font-mono text-sm leading-relaxed outline-none"
/>
<span className="text-ink-400 text-xs">{t('bodyHint')}</span>
</label>
<details className="border-ink-200 border p-4">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-widest">
{t('seo')}
</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('metaTitle')}
</span>
<Input
value={fields.metaTitle}
onChange={(event) => setField('metaTitle', event.target.value)}
placeholder={fields.title}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('metaDescription')}
</span>
<Input
value={fields.metaDescription}
onChange={(event) => setField('metaDescription', event.target.value)}
placeholder={fields.excerpt}
/>
</label>
</div>
</details>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<div className="border-ink-200 flex flex-wrap items-center gap-2 border-t pt-4">
<Button type="submit" variant="outline" disabled={saving}>
{saving ? t('saving') : t('saveDraft')}
</Button>
{/*
Publish and save are separate buttons rather than a status dropdown
plus save. "Publish" is the consequential action and should say so —
picking PUBLISHED from a select and pressing Save reads as neither.
*/}
<Button type="button" disabled={saving} onClick={(event) => void save(event, 'PUBLISHED')}>
{draft.status === 'PUBLISHED' ? t('update') : t('publish')}
</Button>
{draft.status === 'PUBLISHED' ? (
<Button
type="button"
variant="ghost"
disabled={saving}
onClick={(event) => void save(event, 'DRAFT')}
>
{t('unpublish')}
</Button>
) : null}
{!isNew ? (
<Button
type="button"
variant={confirmingDelete ? 'destructive' : 'ghost'}
disabled={saving}
className="ml-auto"
onClick={() => (confirmingDelete ? void remove() : setConfirmingDelete(true))}
>
{confirmingDelete ? t('deleteConfirm') : t('delete')}
</Button>
) : null}
</div>
</form>
);
}
@@ -0,0 +1,190 @@
'use client';
import { FileText, Newspaper, Plus } from 'lucide-react';
import Link from 'next/link';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminContentEntry, type ContentType } 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 } from '@/lib/format';
type TypeFilter = ContentType | 'ALL';
export function ContentTable() {
const t = useTranslations('cms');
const format = useFormatter();
const { can } = useSession();
const [entries, setEntries] = useState<AdminContentEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [type, setType] = useState<TypeFilter>('ALL');
const load = useCallback(async () => {
try {
const result = await browserApi.contentAdmin.list({
perPage: 100,
q: query.trim() || undefined,
type: type === 'ALL' ? undefined : type,
});
setEntries([...result.items]);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, type, t]);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
if (!cancelled) void load();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [load]);
if (!can(PERMISSIONS.CMS_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
const canManage = can(PERMISSIONS.CMS_MANAGE);
return (
<div className="space-y-4">
<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"
aria-label={t('searchPlaceholder')}
/>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{(['ALL', 'POST', 'PAGE'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={type === value}
onClick={() => setType(value)}
className={cn(
'px-3 py-2 text-sm transition-colors',
type === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{t(`filter.${value}`)}
</button>
))}
</div>
{canManage ? (
<div className="ml-auto flex gap-2">
<Button variant="outline" asChild>
<Link href="/cms/new?type=PAGE">
<Plus className="size-4" />
{t('newPage')}
</Link>
</Button>
<Button asChild>
<Link href="/cms/new?type=POST">
<Plus className="size-4" />
{t('newPost')}
</Link>
</Button>
</div>
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!entries ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : entries.length === 0 ? (
<p className="text-ink-500 border-ink-200 border border-dashed p-8 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('table.title')}</th>
<th className="px-4 py-3 font-semibold">{t('table.languages')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3 font-semibold">{t('table.updated')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{entries.map((entry) => {
const languages = Object.keys(entry.translations);
const primary = entry.translations.en ?? entry.translations.vi;
return (
<tr key={entry.id} className="hover:bg-ink-50/60">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{entry.type === 'POST' ? (
<Newspaper className="text-ink-400 size-4 shrink-0" />
) : (
<FileText className="text-ink-400 size-4 shrink-0" />
)}
<div>
<div className="font-medium">{primary?.title ?? t('untitled')}</div>
<div className="text-ink-500 font-mono text-xs">
/{primary?.slug ?? '—'}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{languages.map((language) => (
<Badge key={language} variant="outline">
{language.toUpperCase()}
</Badge>
))}
</div>
</td>
<td className="px-4 py-3">
<Badge variant={entry.status === 'PUBLISHED' ? 'success' : 'neutral'}>
{t(`status.${entry.status}`)}
</Badge>
</td>
<td className="text-ink-500 px-4 py-3 text-xs">
{formatDateTime(entry.updatedAt, format)}
</td>
<td className="px-4 py-3 text-right">
<Button variant="outline" size="sm" asChild>
<Link href={`/cms/${entry.id}`}>{t('edit')}</Link>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,536 @@
'use client';
import { useTranslations } from 'next-intl';
import { useId, useState, type FormEvent } from 'react';
import { isApiClientError, type DiscountPayload } from '@sport/api-client';
import { DEFAULT_LOCALE, LOCALES, type AdminDiscount, type Locale } from '@sport/types';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
cn,
} from '@sport/ui';
import { browserApi } from '@/lib/api';
type Trigger = 'AUTOMATIC' | 'CODE';
type Type = 'PERCENTAGE' | 'FIXED_AMOUNT';
type Scope = 'ORDER' | 'PRODUCT';
interface Draft {
code: string;
trigger: Trigger;
type: Type;
scope: Scope;
value: string;
translations: Record<Locale, { name: string; description: string }>;
minSubtotalAmount: string;
startsAt: string;
endsAt: string;
isActive: boolean;
usageLimit: string;
stackable: boolean;
priority: string;
}
function emptyDraft(): Draft {
return {
code: '',
trigger: 'CODE',
type: 'PERCENTAGE',
scope: 'ORDER',
value: '',
translations: Object.fromEntries(
LOCALES.map((locale) => [locale, { name: '', description: '' }]),
) as Draft['translations'],
minSubtotalAmount: '',
startsAt: '',
endsAt: '',
isActive: true,
usageLimit: '',
stackable: false,
priority: '100',
};
}
function toDraft(discount: AdminDiscount): Draft {
return {
code: discount.code ?? '',
trigger: discount.trigger,
type: discount.type,
scope: discount.scope,
value: String(discount.value),
translations: Object.fromEntries(
LOCALES.map((locale) => [
locale,
{
name: discount.translations[locale]?.name ?? '',
description: discount.translations[locale]?.description ?? '',
},
]),
) as Draft['translations'],
minSubtotalAmount:
discount.minSubtotalAmount === null ? '' : String(discount.minSubtotalAmount),
startsAt: toLocalInput(discount.startsAt),
endsAt: toLocalInput(discount.endsAt),
isActive: discount.isActive,
usageLimit: discount.usageLimit === null ? '' : String(discount.usageLimit),
stackable: discount.stackable,
priority: String(discount.priority),
};
}
/**
* Creates and edits a discount.
*
* One dialog for both promotions and coupons, because they are one entity
* (ADR-0020). `trigger` is the first control on the form for exactly that
* reason: it is the choice that decides whether the rest reads as "a promotion
* that runs by itself" or "a code a shopper types".
*/
export function DiscountDialog({
discount,
open,
onOpenChange,
onSaved,
}: {
/** null = create. */
discount: AdminDiscount | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}) {
const t = useTranslations('discounts');
const [draft, setDraft] = useState<Draft>(() => (discount ? toDraft(discount) : emptyDraft()));
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
async function handleDelete() {
if (!discount) return;
setError(null);
setSaving(true);
try {
await browserApi.promotionsAdmin.remove(discount.id);
onSaved();
onOpenChange(false);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
setConfirmingDelete(false);
} finally {
setSaving(false);
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSaving(true);
try {
await save(draft, discount?.id ?? null);
onSaved();
onOpenChange(false);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
const isCode = draft.trigger === 'CODE';
const isPercentage = draft.type === 'PERCENTAGE';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{discount ? t('editTitle') : t('createTitle')}</DialogTitle>
<DialogDescription>{t('dialogHint')}</DialogDescription>
</DialogHeader>
<form id="discount-form" onSubmit={handleSubmit} className="space-y-6">
{/* ---- What kind of discount ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<Choice
label={t('trigger')}
value={draft.trigger}
onChange={(value) => set('trigger', value as Trigger)}
options={[
{ value: 'CODE', label: t('triggerCode') },
{ value: 'AUTOMATIC', label: t('triggerAutomatic') },
]}
/>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('code')}</span>
<Input
value={draft.code}
onChange={(event) => set('code', event.target.value.toUpperCase())}
disabled={!isCode}
placeholder={isCode ? 'SUMMER20' : t('codeNotApplicable')}
// Uppercased as it is typed so what the operator sees is what
// the shopper must type — the API uppercases too, but a field
// that silently rewrites itself on save reads as a bug.
autoComplete="off"
/>
</label>
</div>
{/* ---- How much ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<Choice
label={t('type')}
value={draft.type}
onChange={(value) => set('type', value as Type)}
options={[
{ value: 'PERCENTAGE', label: t('typePercentage') },
{ value: 'FIXED_AMOUNT', label: t('typeFixed') },
]}
/>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{isPercentage ? t('valuePercent') : t('valueAmount')}
</span>
<Input
type="number"
min={1}
max={isPercentage ? 100 : undefined}
value={draft.value}
onChange={(event) => set('value', event.target.value)}
required
/>
<span className="text-ink-400 text-xs">
{isPercentage ? t('valuePercentHint') : t('valueAmountHint')}
</span>
</label>
</div>
<Choice
label={t('scope')}
value={draft.scope}
onChange={(value) => set('scope', value as Scope)}
options={[
{ value: 'ORDER', label: t('scopeOrder') },
{ value: 'PRODUCT', label: t('scopeProduct') },
]}
hint={draft.scope === 'PRODUCT' ? t('scopeProductHint') : undefined}
/>
{/* ---- Names, per locale ---- */}
<div className="space-y-3">
<div className="border-ink-200 flex gap-1 border-b">
{LOCALES.map((key) => (
<button
key={key}
type="button"
onClick={() => setLocale(key)}
className={cn(
'-mb-px border-b-2 px-3 py-1.5 text-xs font-semibold uppercase tracking-widest',
locale === key
? 'border-ink-950 text-ink-950'
: 'text-ink-400 hover:text-ink-600 border-transparent',
)}
>
{key}
</button>
))}
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('name')}</span>
<Input
value={draft.translations[locale].name}
onChange={(event) =>
setDraft((current) => ({
...current,
translations: {
...current.translations,
[locale]: { ...current.translations[locale], name: event.target.value },
},
}))
}
placeholder={t('namePlaceholder')}
/>
<span className="text-ink-400 text-xs">{t('nameHint')}</span>
</label>
</div>
{/* ---- When and how often ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('startsAt')}
</span>
<Input
type="datetime-local"
value={draft.startsAt}
onChange={(event) => set('startsAt', event.target.value)}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('endsAt')}</span>
<Input
type="datetime-local"
value={draft.endsAt}
onChange={(event) => set('endsAt', event.target.value)}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('minSubtotal')}
</span>
<Input
type="number"
min={0}
value={draft.minSubtotalAmount}
onChange={(event) => set('minSubtotalAmount', event.target.value)}
placeholder={t('noMinimum')}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('usageLimit')}
</span>
<Input
type="number"
min={discount ? Math.max(1, discount.usageCount) : 1}
value={draft.usageLimit}
onChange={(event) => set('usageLimit', event.target.value)}
placeholder={t('unlimited')}
/>
{discount ? (
<span className="text-ink-400 text-xs">
{t('alreadyUsed', { count: discount.usageCount })}
</span>
) : null}
</label>
</div>
{/* ---- Stacking ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('priority')}
</span>
<Input
type="number"
min={0}
max={1000}
value={draft.priority}
onChange={(event) => set('priority', event.target.value)}
/>
<span className="text-ink-400 text-xs">{t('priorityHint')}</span>
</label>
<div className="space-y-3 pt-6">
<Toggle
label={t('stackable')}
hint={t('stackableHint')}
checked={draft.stackable}
onChange={(checked) => set('stackable', checked)}
/>
<Toggle
label={t('active')}
hint={t('activeHint')}
checked={draft.isActive}
onChange={(checked) => set('isActive', checked)}
/>
</div>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
</form>
<DialogFooter className="sm:justify-between">
{discount ? (
/**
* Two-step rather than a `confirm()` dialog: retiring a discount
* that orders already redeemed is not something to do on a stray
* click, and a native confirm cannot be styled, translated, or
* tested. The second press is the commitment.
*/
<Button
type="button"
variant={confirmingDelete ? 'destructive' : 'ghost'}
disabled={saving}
onClick={() => {
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void handleDelete();
}}
>
{confirmingDelete ? t('deleteConfirm') : t('delete')}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button type="submit" form="discount-form" disabled={saving}>
{saving ? t('saving') : t('save')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Choice({
label,
value,
onChange,
options,
hint,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
hint?: string;
}) {
return (
<div className="space-y-1.5">
<span className="block text-xs font-semibold uppercase tracking-widest">{label}</span>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
'flex-1 px-3 py-2 text-sm transition-colors',
value === option.value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{option.label}
</button>
))}
</div>
{hint ? <span className="text-ink-400 block text-xs">{hint}</span> : null}
</div>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
// `useId` rather than wrapping the input in the label: the hint text sits
// inside the label too, and a screen reader would otherwise announce the
// whole paragraph as the checkbox's name.
const id = useId();
return (
<div className="flex items-start gap-2.5">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
/>
<div className="space-y-0.5">
<label htmlFor={id} className="block text-sm font-medium">
{label}
</label>
<span className="text-ink-400 block text-xs">{hint}</span>
</div>
</div>
);
}
/**
* `datetime-local` wants `YYYY-MM-DDTHH:mm` in *local* time, and an ISO string
* from the API is UTC. Slicing the ISO string instead would silently shift
* every window by the timezone offset — a promotion set to start at 9am would
* start at 2am in Vietnam.
*/
function toLocalInput(iso: string | null): string {
if (!iso) return '';
const date = new Date(iso);
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
function toIso(local: string): string | null {
return local ? new Date(local).toISOString() : null;
}
function optionalInt(value: string): number | null {
const trimmed = value.trim();
return trimmed === '' ? null : Number.parseInt(trimmed, 10);
}
function save(draft: Draft, id: string | null): Promise<AdminDiscount> {
const payload: DiscountPayload = {
// Sent as null rather than omitted: switching a coupon to automatic must
// actively clear the code, not leave the old one attached to a promotion
// that no longer asks for it.
code: draft.trigger === 'CODE' ? draft.code.trim().toUpperCase() : null,
trigger: draft.trigger,
type: draft.type,
scope: draft.scope,
value: Number.parseInt(draft.value, 10),
// Only locales the operator actually filled in. An empty name would fail
// the schema's min(1) and block saving a discount they only named in one
// language, which is a legitimate thing to do.
translations: Object.fromEntries(
LOCALES.filter((locale) => draft.translations[locale].name.trim()).map((locale) => [
locale,
{
name: draft.translations[locale].name.trim(),
description: draft.translations[locale].description.trim() || null,
},
]),
),
minSubtotalAmount: optionalInt(draft.minSubtotalAmount),
startsAt: toIso(draft.startsAt),
endsAt: toIso(draft.endsAt),
isActive: draft.isActive,
usageLimit: optionalInt(draft.usageLimit),
stackable: draft.stackable,
priority: Number.parseInt(draft.priority, 10) || 100,
};
return id
? browserApi.promotionsAdmin.update(id, payload)
: browserApi.promotionsAdmin.create(payload);
}
@@ -0,0 +1,296 @@
'use client';
import { Plus, Tag, Ticket } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminDiscount } 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';
import { DiscountDialog } from './discount-dialog';
type TriggerFilter = 'ALL' | 'AUTOMATIC' | 'CODE';
export function DiscountsTable() {
const t = useTranslations('discounts');
const format = useFormatter();
const { can } = useSession();
const [discounts, setDiscounts] = useState<AdminDiscount[] | null>(null);
/**
* The clock, sampled when the list arrives.
*
* Read during render it would be an impure call, and worse, each row would
* sample it separately — so a list straddling an expiry could show one
* discount live and the next expired in the same paint.
*/
const [loadedAt, setLoadedAt] = useState(() => Date.now());
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [trigger, setTrigger] = useState<TriggerFilter>('ALL');
/**
* `editing` distinguishes three states, which is why it is not a boolean:
* closed, creating (null), and editing a specific discount. Collapsing it
* into `open` + `selected` lets the two disagree, and the failure mode is an
* "edit" dialog that saves a new discount.
*/
const [editing, setEditing] = useState<{ discount: AdminDiscount | null } | null>(null);
const load = useCallback(async () => {
try {
const result = await browserApi.promotionsAdmin.list({
perPage: 100,
q: query.trim() || undefined,
trigger: trigger === 'ALL' ? undefined : trigger,
});
setDiscounts([...result.items]);
setLoadedAt(Date.now());
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, trigger, t]);
useEffect(() => {
let cancelled = false;
// Debounced so typing in the search box does not fire a request per
// keystroke; the cleanup also discards a response whose query is stale.
const timer = setTimeout(() => {
if (!cancelled) void load();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [load]);
if (!can(PERMISSIONS.PROMOTION_MANAGE)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{(['ALL', 'CODE', 'AUTOMATIC'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={trigger === value}
onClick={() => setTrigger(value)}
className={cn(
'px-3 py-2 text-sm transition-colors',
trigger === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{t(`filter.${value}`)}
</button>
))}
</div>
<Button className="ml-auto" onClick={() => setEditing({ discount: null })}>
<Plus className="size-4" />
{t('create')}
</Button>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!discounts ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : discounts.length === 0 ? (
<p className="text-ink-500 border-ink-200 border border-dashed p-8 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('table.name')}</th>
<th className="px-4 py-3 font-semibold">{t('table.value')}</th>
<th className="px-4 py-3 font-semibold">{t('table.window')}</th>
<th className="px-4 py-3 font-semibold">{t('table.usage')}</th>
<th className="px-4 py-3 font-semibold">{t('table.stacking')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{discounts.map((discount) => (
<Row
key={discount.id}
discount={discount}
onEdit={() => setEditing({ discount })}
now={loadedAt}
t={t}
format={format}
/>
))}
</tbody>
</table>
</div>
)}
{editing ? (
<DiscountDialog
// Remounts per target, so the draft state is rebuilt from the right
// discount instead of inheriting whichever row was opened first.
key={editing.discount?.id ?? 'new'}
discount={editing.discount}
open
onOpenChange={(open) => !open && setEditing(null)}
onSaved={() => void load()}
/>
) : null}
</div>
);
}
function Row({
discount,
onEdit,
now,
t,
format,
}: {
discount: AdminDiscount;
onEdit: () => void;
now: number;
t: ReturnType<typeof useTranslations<'discounts'>>;
format: ReturnType<typeof useFormatter>;
}) {
const name =
discount.translations.en?.name ?? discount.translations.vi?.name ?? discount.code ?? '—';
return (
<tr className="hover:bg-ink-50/60">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{discount.trigger === 'CODE' ? (
<Ticket className="text-ink-400 size-4 shrink-0" />
) : (
<Tag className="text-ink-400 size-4 shrink-0" />
)}
<div>
<div className="font-medium">{name}</div>
<div className="text-ink-500 text-xs">
{discount.code ? (
<span className="font-mono">{discount.code}</span>
) : (
t('triggerAutomatic')
)}
{discount.scope === 'PRODUCT' ? ` · ${t('scopeProduct')}` : null}
</div>
</div>
</div>
</td>
<td className="px-4 py-3 font-medium">
{discount.type === 'PERCENTAGE'
? `${discount.value}%`
: formatMoney({ amount: discount.value, currency: 'VND' }, format)}
</td>
<td className="text-ink-600 px-4 py-3 text-xs">
<Window discount={discount} t={t} format={format} />
</td>
<td className="px-4 py-3">
{discount.usageLimit === null
? `${discount.usageCount} · ${t('unlimited')}`
: `${discount.usageCount} / ${discount.usageLimit}`}
</td>
<td className="text-ink-600 px-4 py-3 text-xs">
{discount.stackable ? t('stackable') : t('exclusive')} · {t('priorityShort')}{' '}
{discount.priority}
</td>
<td className="px-4 py-3">
<StatusBadge discount={discount} now={now} t={t} />
</td>
<td className="px-4 py-3 text-right">
<Button variant="outline" size="sm" onClick={onEdit}>
{t('edit')}
</Button>
</td>
</tr>
);
}
function Window({
discount,
t,
format,
}: {
discount: AdminDiscount;
t: ReturnType<typeof useTranslations<'discounts'>>;
format: ReturnType<typeof useFormatter>;
}) {
if (!discount.startsAt && !discount.endsAt) return <>{t('always')}</>;
return (
<>
{discount.startsAt ? formatDateTime(discount.startsAt, format) : t('always')}
{' → '}
{discount.endsAt ? formatDateTime(discount.endsAt, format) : t('noEnd')}
</>
);
}
/**
* Status is derived, not stored.
*
* `isActive` alone would show "Active" on a discount that expired last week or
* has burned through its last use — a screen that tells an operator a promotion
* is running when it is not is worse than no screen.
*/
function StatusBadge({
discount,
now,
t,
}: {
discount: AdminDiscount;
now: number;
t: ReturnType<typeof useTranslations<'discounts'>>;
}) {
if (!discount.isActive) return <Badge variant="neutral">{t('status.paused')}</Badge>;
if (discount.usageLimit !== null && discount.usageCount >= discount.usageLimit) {
return <Badge variant="neutral">{t('status.exhausted')}</Badge>;
}
if (discount.endsAt && new Date(discount.endsAt).getTime() < now) {
return <Badge variant="neutral">{t('status.expired')}</Badge>;
}
if (discount.startsAt && new Date(discount.startsAt).getTime() > now) {
return <Badge variant="warning">{t('status.scheduled')}</Badge>;
}
return <Badge variant="success">{t('status.live')}</Badge>;
}
@@ -0,0 +1,251 @@
'use client';
import { Star } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminReview, type ReviewStatus } 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 } from '@/lib/format';
type StatusFilter = ReviewStatus | 'ALL';
const STATUS_VARIANT = {
PENDING: 'warning',
APPROVED: 'success',
REJECTED: 'neutral',
} as const;
export function ReviewsQueue() {
const t = useTranslations('reviews');
const format = useFormatter();
const { can } = useSession();
const [reviews, setReviews] = useState<AdminReview[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
// Pending by default: this screen exists to empty a queue, not to browse.
const [status, setStatus] = useState<StatusFilter>('PENDING');
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const result = await browserApi.reviewsAdmin.list({
perPage: 50,
q: query.trim() || undefined,
status: status === 'ALL' ? undefined : status,
});
setReviews([...result.items]);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, status, t]);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
if (!cancelled) void load();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [load]);
async function decide(id: string, decision: 'APPROVED' | 'REJECTED', note?: string) {
setBusyId(id);
setError(null);
try {
await browserApi.reviewsAdmin.moderate(id, { status: decision, note: note ?? null });
await load();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('moderateFailed'));
} finally {
setBusyId(null);
}
}
if (!can(PERMISSIONS.REVIEW_MODERATE)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{(['PENDING', 'APPROVED', 'REJECTED', 'ALL'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={status === value}
onClick={() => setStatus(value)}
className={cn(
'px-3 py-2 text-sm transition-colors',
status === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{t(`filter.${value}`)}
</button>
))}
</div>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!reviews ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
) : reviews.length === 0 ? (
<p className="text-ink-500 border-ink-200 border border-dashed p-8 text-center text-sm">
{status === 'PENDING' ? t('emptyQueue') : t('empty')}
</p>
) : (
<ul className="space-y-3">
{reviews.map((review) => (
<ReviewCard
key={review.id}
review={review}
busy={busyId === review.id}
onDecide={decide}
t={t}
format={format}
/>
))}
</ul>
)}
</div>
);
}
function ReviewCard({
review,
busy,
onDecide,
t,
format,
}: {
review: AdminReview;
busy: boolean;
onDecide: (id: string, decision: 'APPROVED' | 'REJECTED', note?: string) => void;
t: ReturnType<typeof useTranslations<'reviews'>>;
format: ReturnType<typeof useFormatter>;
}) {
const [rejecting, setRejecting] = useState(false);
const [note, setNote] = useState('');
return (
<li className="border-ink-200 space-y-3 border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Stars rating={review.rating} />
<span className="text-sm font-semibold">{review.title || t('untitled')}</span>
</div>
<p className="text-ink-500 text-xs">
{review.productName} · {review.variantTitle} ·{' '}
<span className="font-mono">#{review.orderNumber}</span>
</p>
</div>
<Badge variant={STATUS_VARIANT[review.status]}>{t(`status.${review.status}`)}</Badge>
</div>
{review.body ? (
<p className="text-ink-700 whitespace-pre-line text-sm">{review.body}</p>
) : (
<p className="text-ink-400 text-sm italic">{t('noBody')}</p>
)}
<p className="text-ink-500 text-xs">
{t('by', { name: review.authorName })} · {formatDateTime(review.createdAt, format)}
{review.moderatedByName ? ` · ${t('moderatedBy', { name: review.moderatedByName })}` : null}
</p>
{review.moderationNote ? (
<p className="border-ink-200 text-ink-500 border-l-2 pl-3 text-xs">
{t('note')}: {review.moderationNote}
</p>
) : null}
{rejecting ? (
<div className="flex flex-wrap items-center gap-2">
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={t('notePlaceholder')}
className="max-w-sm"
aria-label={t('notePlaceholder')}
/>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => onDecide(review.id, 'REJECTED', note.trim() || undefined)}
>
{t('confirmReject')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRejecting(false)}>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex gap-2">
{/*
Both actions stay available on an already-moderated review: a
decision made in haste has to be reversible, and the aggregate is
recomputed from scratch on every decision so reversing one is safe.
*/}
<Button
size="sm"
disabled={busy || review.status === 'APPROVED'}
onClick={() => onDecide(review.id, 'APPROVED')}
>
{t('approve')}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || review.status === 'REJECTED'}
onClick={() => setRejecting(true)}
>
{t('reject')}
</Button>
</div>
)}
</li>
);
}
function Stars({ rating }: { rating: number }) {
return (
<span className="flex items-center gap-0.5" aria-label={`${rating}/5`}>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
aria-hidden
className={cn('size-3.5', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
))}
</span>
);
}
+2 -2
View File
@@ -47,8 +47,8 @@ export const NAVIGATION: NavSection[] = [
{ {
titleKey: 'marketing', titleKey: 'marketing',
items: [ items: [
{ href: '/promotions', labelKey: 'promotions', permission: PERMISSIONS.PROMOTION_MANAGE }, // One entry, not "Promotions" + "Coupons" — see ADR-0020.
{ href: '/coupons', labelKey: 'coupons', permission: PERMISSIONS.COUPON_MANAGE }, { href: '/discounts', labelKey: 'discounts', permission: PERMISSIONS.PROMOTION_MANAGE },
{ href: '/reviews', labelKey: 'reviews', permission: PERMISSIONS.REVIEW_MODERATE }, { href: '/reviews', labelKey: 'reviews', permission: PERMISSIONS.REVIEW_MODERATE },
{ href: '/cms', labelKey: 'cms', permission: PERMISSIONS.CMS_READ }, { href: '/cms', labelKey: 'cms', permission: PERMISSIONS.CMS_READ },
], ],
+158 -10
View File
@@ -65,21 +65,13 @@
"title": "Customers", "title": "Customers",
"body": "Customer records, order history and addresses." "body": "Customer records, order history and addresses."
}, },
"promotions": {
"title": "Promotions",
"body": "Automatic cart-level discount rules."
},
"coupons": {
"title": "Coupons",
"body": "Coupon codes, usage limits and redemption reporting."
},
"reviews": { "reviews": {
"title": "Reviews", "title": "Reviews",
"body": "Review moderation queue." "body": "Every review comes from a verified purchase. Approve or reject before it appears on the storefront."
}, },
"cms": { "cms": {
"title": "Content", "title": "Content",
"body": "Homepage blocks, banners, blog posts and static pages." "body": "Journal posts and static pages. Written in Markdown, published per language."
}, },
"users": { "users": {
"title": "Users", "title": "Users",
@@ -88,6 +80,10 @@
"roles": { "roles": {
"title": "Roles", "title": "Roles",
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy." "body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
},
"discounts": {
"title": "Discounts",
"body": "Promotions and coupon codes. Both run on the same rules — a coupon is simply a promotion that needs a code typed."
} }
}, },
"settings": { "settings": {
@@ -281,5 +277,157 @@
"total": "Total", "total": "Total",
"placed": "Placed" "placed": "Placed"
} }
},
"discounts": {
"noPermission": "You do not have permission to manage discounts.",
"loadFailed": "Could not load discounts.",
"saveFailed": "Could not save this discount.",
"searchPlaceholder": "Search by name or code",
"create": "New discount",
"edit": "Edit",
"empty": "No discounts yet. Create one to start running an offer.",
"createTitle": "New discount",
"editTitle": "Edit discount",
"dialogHint": "A coupon is a promotion that needs a code typed. Both use the same rules.",
"filter": {
"ALL": "All",
"CODE": "Coupons",
"AUTOMATIC": "Automatic"
},
"trigger": "Applies",
"triggerCode": "When a code is entered",
"triggerAutomatic": "Automatically",
"code": "Code",
"codeNotApplicable": "Not used for automatic promotions",
"type": "Discount",
"typePercentage": "Percentage",
"typeFixed": "Fixed amount",
"valuePercent": "Percent off",
"valueAmount": "Amount off",
"valuePercentHint": "Rounded down, never up.",
"valueAmountHint": "In đồng, e.g. 100000 for 100.000 ₫.",
"scope": "Applies to",
"scopeOrder": "Whole order",
"scopeProduct": "Selected products",
"scopeProductHint": "Product targeting is set from the product and collection screens.",
"name": "Name",
"namePlaceholder": "Summer sale",
"nameHint": "Shown to the shopper in their bag and on the order.",
"startsAt": "Starts",
"endsAt": "Ends",
"minSubtotal": "Minimum subtotal",
"noMinimum": "No minimum",
"usageLimit": "Usage limit",
"unlimited": "Unlimited",
"alreadyUsed": "Already used {count} time(s).",
"priority": "Priority",
"priorityShort": "Priority",
"priorityHint": "Lower runs first when several apply.",
"stackable": "Stackable",
"stackableHint": "Can combine with other discounts.",
"exclusive": "Exclusive",
"active": "Active",
"activeHint": "Switch off to stop it applying without deleting it.",
"always": "Always",
"noEnd": "No end",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving…",
"status": {
"live": "Live",
"paused": "Paused",
"scheduled": "Scheduled",
"expired": "Expired",
"exhausted": "Used up"
},
"table": {
"name": "Name",
"value": "Value",
"window": "Window",
"usage": "Used",
"stacking": "Stacking",
"status": "Status"
},
"delete": "Retire",
"deleteConfirm": "Confirm retire",
"deleteFailed": "Could not retire this discount."
},
"reviews": {
"noPermission": "You do not have permission to moderate reviews.",
"loadFailed": "Could not load reviews.",
"moderateFailed": "Could not save that decision.",
"searchPlaceholder": "Search text or author",
"filter": {
"PENDING": "Pending",
"APPROVED": "Approved",
"REJECTED": "Rejected",
"ALL": "All"
},
"status": {
"PENDING": "Pending",
"APPROVED": "Approved",
"REJECTED": "Rejected"
},
"empty": "No reviews match this filter.",
"emptyQueue": "Nothing waiting. The queue is clear.",
"untitled": "(no title)",
"noBody": "No written review — a rating only.",
"by": "By {name}",
"moderatedBy": "Handled by {name}",
"note": "Note",
"notePlaceholder": "Why (internal, never shown to the shopper)",
"approve": "Approve",
"reject": "Reject",
"confirmReject": "Confirm reject",
"cancel": "Cancel"
},
"cms": {
"noPermission": "You do not have permission to manage content.",
"loadFailed": "Could not load content.",
"saveFailed": "Could not save this entry.",
"deleteFailed": "Could not retire this entry.",
"searchPlaceholder": "Search by title",
"filter": {
"ALL": "All",
"POST": "Posts",
"PAGE": "Pages"
},
"type": {
"POST": "Post",
"PAGE": "Page"
},
"status": {
"DRAFT": "Draft",
"PUBLISHED": "Published"
},
"table": {
"title": "Title",
"languages": "Languages",
"status": "Status",
"updated": "Updated"
},
"newPost": "New post",
"newPage": "New page",
"empty": "Nothing written yet.",
"untitled": "(untitled)",
"edit": "Edit",
"backToList": "Back to content",
"title": "Title",
"slug": "URL slug",
"slugHint": "Lowercase, hyphens. Different per language.",
"excerpt": "Excerpt",
"excerptHint": "The summary shown on the journal listing.",
"body": "Body",
"bodyHint": "Markdown. Headings, lists, links, bold and tables.",
"seo": "Search engine listing",
"metaTitle": "Meta title",
"metaDescription": "Meta description",
"saveDraft": "Save draft",
"saving": "Saving…",
"publish": "Publish",
"update": "Update",
"unpublish": "Unpublish",
"delete": "Retire",
"deleteConfirm": "Confirm retire"
} }
} }
+158 -10
View File
@@ -65,21 +65,13 @@
"title": "Khách hàng", "title": "Khách hàng",
"body": "Hồ sơ khách hàng, lịch sử đơn và địa chỉ." "body": "Hồ sơ khách hàng, lịch sử đơn và địa chỉ."
}, },
"promotions": {
"title": "Khuyến mãi",
"body": "Quy tắc giảm giá tự động ở cấp giỏ hàng."
},
"coupons": {
"title": "Mã giảm giá",
"body": "Mã giảm giá, giới hạn sử dụng và báo cáo quy đổi."
},
"reviews": { "reviews": {
"title": "Đánh giá", "title": "Đánh giá",
"body": "Hàng đợi kiểm duyệt đánh giá." "body": "Mọi đánh giá đều đến từ đơn hàng đã mua. Duyệt hoặc từ chối trước khi hiển thị trên cửa hàng."
}, },
"cms": { "cms": {
"title": "Nội dung", "title": "Nội dung",
"body": "Khối trang chủ, banner, bài viết và trang tĩnh." "body": "Bài viết và trang tĩnh. Soạn bằng Markdown, đăng theo từng ngôn ngữ."
}, },
"users": { "users": {
"title": "Người dùng", "title": "Người dùng",
@@ -88,6 +80,10 @@
"roles": { "roles": {
"title": "Vai trò", "title": "Vai trò",
"body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại." "body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại."
},
"discounts": {
"title": "Khuyến mãi",
"body": "Chương trình khuyến mãi và mã giảm giá. Cả hai dùng chung bộ quy tắc — mã giảm giá chỉ là khuyến mãi cần nhập mã."
} }
}, },
"settings": { "settings": {
@@ -281,5 +277,157 @@
"total": "Tổng tiền", "total": "Tổng tiền",
"placed": "Đặt lúc" "placed": "Đặt lúc"
} }
},
"discounts": {
"noPermission": "Bạn không có quyền quản lý khuyến mãi.",
"loadFailed": "Không tải được danh sách khuyến mãi.",
"saveFailed": "Không lưu được khuyến mãi này.",
"searchPlaceholder": "Tìm theo tên hoặc mã",
"create": "Tạo khuyến mãi",
"edit": "Sửa",
"empty": "Chưa có khuyến mãi nào. Tạo một chương trình để bắt đầu.",
"createTitle": "Khuyến mãi mới",
"editTitle": "Sửa khuyến mãi",
"dialogHint": "Mã giảm giá là khuyến mãi cần nhập mã. Cả hai dùng chung một bộ quy tắc.",
"filter": {
"ALL": "Tất cả",
"CODE": "Mã giảm giá",
"AUTOMATIC": "Tự động"
},
"trigger": "Áp dụng",
"triggerCode": "Khi nhập mã",
"triggerAutomatic": "Tự động",
"code": "Mã",
"codeNotApplicable": "Không dùng cho khuyến mãi tự động",
"type": "Kiểu giảm",
"typePercentage": "Phần trăm",
"typeFixed": "Số tiền cố định",
"valuePercent": "Giảm (%)",
"valueAmount": "Số tiền giảm",
"valuePercentHint": "Luôn làm tròn xuống.",
"valueAmountHint": "Đơn vị đồng, ví dụ 100000 cho 100.000 ₫.",
"scope": "Phạm vi",
"scopeOrder": "Toàn đơn hàng",
"scopeProduct": "Sản phẩm đã chọn",
"scopeProductHint": "Chọn sản phẩm áp dụng ở màn hình sản phẩm và bộ sưu tập.",
"name": "Tên",
"namePlaceholder": "Khuyến mãi hè",
"nameHint": "Hiển thị cho khách trong giỏ hàng và trên đơn hàng.",
"startsAt": "Bắt đầu",
"endsAt": "Kết thúc",
"minSubtotal": "Giá trị tối thiểu",
"noMinimum": "Không yêu cầu",
"usageLimit": "Giới hạn lượt dùng",
"unlimited": "Không giới hạn",
"alreadyUsed": "Đã dùng {count} lượt.",
"priority": "Thứ tự ưu tiên",
"priorityShort": "Ưu tiên",
"priorityHint": "Số nhỏ hơn được áp dụng trước.",
"stackable": "Cộng dồn",
"stackableHint": "Có thể dùng chung với khuyến mãi khác.",
"exclusive": "Riêng lẻ",
"active": "Đang bật",
"activeHint": "Tắt để ngừng áp dụng mà không xoá.",
"always": "Luôn luôn",
"noEnd": "Không giới hạn",
"cancel": "Huỷ",
"save": "Lưu",
"saving": "Đang lưu…",
"status": {
"live": "Đang chạy",
"paused": "Tạm dừng",
"scheduled": "Đã lên lịch",
"expired": "Hết hạn",
"exhausted": "Hết lượt"
},
"table": {
"name": "Tên",
"value": "Giá trị",
"window": "Thời gian",
"usage": "Đã dùng",
"stacking": "Cộng dồn",
"status": "Trạng thái"
},
"delete": "Ngừng dùng",
"deleteConfirm": "Xác nhận ngừng",
"deleteFailed": "Không ngừng được khuyến mãi này."
},
"reviews": {
"noPermission": "Bạn không có quyền kiểm duyệt đánh giá.",
"loadFailed": "Không tải được đánh giá.",
"moderateFailed": "Không lưu được quyết định này.",
"searchPlaceholder": "Tìm nội dung hoặc người viết",
"filter": {
"PENDING": "Chờ duyệt",
"APPROVED": "Đã duyệt",
"REJECTED": "Từ chối",
"ALL": "Tất cả"
},
"status": {
"PENDING": "Chờ duyệt",
"APPROVED": "Đã duyệt",
"REJECTED": "Từ chối"
},
"empty": "Không có đánh giá nào khớp bộ lọc.",
"emptyQueue": "Không còn đánh giá nào chờ duyệt.",
"untitled": "(không có tiêu đề)",
"noBody": "Chỉ chấm sao, không có nhận xét.",
"by": "Bởi {name}",
"moderatedBy": "Xử lý bởi {name}",
"note": "Ghi chú",
"notePlaceholder": "Lý do (nội bộ, khách không nhìn thấy)",
"approve": "Duyệt",
"reject": "Từ chối",
"confirmReject": "Xác nhận từ chối",
"cancel": "Huỷ"
},
"cms": {
"noPermission": "Bạn không có quyền quản lý nội dung.",
"loadFailed": "Không tải được nội dung.",
"saveFailed": "Không lưu được nội dung này.",
"deleteFailed": "Không ngừng được nội dung này.",
"searchPlaceholder": "Tìm theo tiêu đề",
"filter": {
"ALL": "Tất cả",
"POST": "Bài viết",
"PAGE": "Trang"
},
"type": {
"POST": "Bài viết",
"PAGE": "Trang"
},
"status": {
"DRAFT": "Bản nháp",
"PUBLISHED": "Đã đăng"
},
"table": {
"title": "Tiêu đề",
"languages": "Ngôn ngữ",
"status": "Trạng thái",
"updated": "Cập nhật"
},
"newPost": "Bài viết mới",
"newPage": "Trang mới",
"empty": "Chưa có nội dung nào.",
"untitled": "(chưa có tiêu đề)",
"edit": "Sửa",
"backToList": "Quay lại danh sách",
"title": "Tiêu đề",
"slug": "Đường dẫn",
"slugHint": "Chữ thường, gạch nối. Khác nhau theo ngôn ngữ.",
"excerpt": "Tóm tắt",
"excerptHint": "Đoạn tóm tắt hiển thị ở danh sách bài viết.",
"body": "Nội dung",
"bodyHint": "Markdown. Tiêu đề, danh sách, liên kết, in đậm và bảng.",
"seo": "Hiển thị trên công cụ tìm kiếm",
"metaTitle": "Meta title",
"metaDescription": "Meta description",
"saveDraft": "Lưu nháp",
"saving": "Đang lưu…",
"publish": "Đăng",
"update": "Cập nhật",
"unpublish": "Gỡ xuống",
"delete": "Ngừng dùng",
"deleteConfirm": "Xác nhận ngừng"
} }
} }
@@ -0,0 +1,122 @@
-- CreateEnum
CREATE TYPE "DiscountTrigger" AS ENUM ('AUTOMATIC', 'CODE');
-- CreateEnum
CREATE TYPE "DiscountType" AS ENUM ('PERCENTAGE', 'FIXED_AMOUNT');
-- CreateEnum
CREATE TYPE "DiscountScope" AS ENUM ('ORDER', 'PRODUCT');
-- NOTE: Prisma wanted to drop `search_documents_document_idx` and strip the
-- default from `search_documents.document` here. Both were removed by hand.
--
-- `document` is a GENERATED column (see 20260812130938_add_search_documents),
-- which Prisma cannot express — it sees `Unsupported("tsvector")?` and assumes a
-- plain column that has drifted. Letting it "fix" that would drop the GIN index
-- search depends on and fail on the generated column anyway, which is exactly
-- what happened the first time this migration ran.
--
-- Expect this diff on every future `migrate dev`. Delete those two statements.
-- CreateTable
CREATE TABLE "discounts" (
"id" UUID NOT NULL,
"code" VARCHAR(40),
"trigger" "DiscountTrigger" NOT NULL,
"type" "DiscountType" NOT NULL,
"scope" "DiscountScope" NOT NULL DEFAULT 'ORDER',
"value" INTEGER NOT NULL,
"min_subtotal_amount" INTEGER,
"starts_at" TIMESTAMPTZ(3),
"ends_at" TIMESTAMPTZ(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"usage_limit" INTEGER,
"usage_count" INTEGER NOT NULL DEFAULT 0,
"stackable" BOOLEAN NOT NULL DEFAULT false,
"priority" INTEGER NOT NULL DEFAULT 100,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "discounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "discount_translations" (
"discount_id" UUID NOT NULL,
"locale" "Locale" NOT NULL,
"name" VARCHAR(120) NOT NULL,
"description" VARCHAR(500),
CONSTRAINT "discount_translations_pkey" PRIMARY KEY ("discount_id","locale")
);
-- CreateTable
CREATE TABLE "discount_products" (
"discount_id" UUID NOT NULL,
"product_id" UUID NOT NULL,
CONSTRAINT "discount_products_pkey" PRIMARY KEY ("discount_id","product_id")
);
-- CreateTable
CREATE TABLE "discount_collections" (
"discount_id" UUID NOT NULL,
"collection_id" UUID NOT NULL,
CONSTRAINT "discount_collections_pkey" PRIMARY KEY ("discount_id","collection_id")
);
-- CreateTable
CREATE TABLE "discount_redemptions" (
"id" UUID NOT NULL,
"discount_id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"amount" INTEGER NOT NULL,
"code" VARCHAR(40),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "discount_redemptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "discounts_code_key" ON "discounts"("code");
-- CreateIndex
CREATE INDEX "discounts_trigger_is_active_idx" ON "discounts"("trigger", "is_active");
-- CreateIndex
CREATE INDEX "discounts_code_idx" ON "discounts"("code");
-- CreateIndex
CREATE INDEX "discount_products_product_id_idx" ON "discount_products"("product_id");
-- CreateIndex
CREATE INDEX "discount_collections_collection_id_idx" ON "discount_collections"("collection_id");
-- CreateIndex
CREATE INDEX "discount_redemptions_discount_id_idx" ON "discount_redemptions"("discount_id");
-- CreateIndex
CREATE UNIQUE INDEX "discount_redemptions_order_id_discount_id_key" ON "discount_redemptions"("order_id", "discount_id");
-- AddForeignKey
ALTER TABLE "discount_translations" ADD CONSTRAINT "discount_translations_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_products" ADD CONSTRAINT "discount_products_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_products" ADD CONSTRAINT "discount_products_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_collections" ADD CONSTRAINT "discount_collections_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_collections" ADD CONSTRAINT "discount_collections_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_redemptions" ADD CONSTRAINT "discount_redemptions_discount_id_fkey" FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discount_redemptions" ADD CONSTRAINT "discount_redemptions_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ReviewStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
-- ALTER "search_documents"."document" DROP DEFAULT here. Both are removed on
-- purpose and must be removed from every future migration too.
--
-- `document` is a GENERATED tsvector column created by raw SQL in the M6
-- migration. Prisma's schema cannot express a generated column, so it sees one
-- it does not know about and proposes undoing it. Applying that drops the GIN
-- index behind every search query and silently degrades /search to nothing.
-- AlterTable
ALTER TABLE "products" ADD COLUMN "rating_count" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "rating_sum" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "reviews" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"order_line_id" UUID NOT NULL,
"rating" INTEGER NOT NULL,
"title" VARCHAR(140),
"body" VARCHAR(2000),
"author_name" VARCHAR(120) NOT NULL,
"status" "ReviewStatus" NOT NULL DEFAULT 'PENDING',
"moderated_at" TIMESTAMPTZ(3),
"moderated_by_user_id" UUID,
"moderation_note" VARCHAR(500),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "reviews_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "reviews_order_line_id_key" ON "reviews"("order_line_id");
-- CreateIndex
CREATE INDEX "reviews_product_id_status_created_at_idx" ON "reviews"("product_id", "status", "created_at");
-- CreateIndex
CREATE INDEX "reviews_status_created_at_idx" ON "reviews"("status", "created_at");
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_order_line_id_fkey" FOREIGN KEY ("order_line_id") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_moderated_by_user_id_fkey" FOREIGN KEY ("moderated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Validation belongs in the schema as well as the DTO: `rating` feeds a stored
-- SUM, so one bad row silently skews a product's average forever. Zod checks
-- the request; this checks the table.
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_rating_range" CHECK ("rating" BETWEEN 1 AND 5);
@@ -0,0 +1,55 @@
-- CreateEnum
CREATE TYPE "ContentType" AS ENUM ('PAGE', 'POST');
-- CreateEnum
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED');
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
-- ALTER "search_documents"."document" DROP DEFAULT here. Removed on purpose —
-- `document` is a GENERATED tsvector column created by raw SQL in the M6
-- migration, which Prisma's schema cannot express, so it proposes undoing it in
-- every migration from now on. Applying it destroys full-text search.
-- CreateTable
CREATE TABLE "content_entries" (
"id" UUID NOT NULL,
"type" "ContentType" NOT NULL,
"status" "ContentStatus" NOT NULL DEFAULT 'DRAFT',
"published_at" TIMESTAMPTZ(3),
"cover_image_id" UUID,
"author_user_id" UUID,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "content_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "content_entry_translations" (
"entry_id" UUID NOT NULL,
"locale" "Locale" NOT NULL,
"slug" VARCHAR(200) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"excerpt" VARCHAR(500),
"body" TEXT NOT NULL,
"meta_title" VARCHAR(255),
"meta_description" TEXT,
CONSTRAINT "content_entry_translations_pkey" PRIMARY KEY ("entry_id","locale")
);
-- CreateIndex
CREATE INDEX "content_entries_type_status_published_at_idx" ON "content_entries"("type", "status", "published_at");
-- CreateIndex
CREATE UNIQUE INDEX "content_entry_translations_locale_slug_key" ON "content_entry_translations"("locale", "slug");
-- AddForeignKey
ALTER TABLE "content_entries" ADD CONSTRAINT "content_entries_cover_image_id_fkey" FOREIGN KEY ("cover_image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_entries" ADD CONSTRAINT "content_entries_author_user_id_fkey" FOREIGN KEY ("author_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_entry_translations" ADD CONSTRAINT "content_entry_translations_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "content_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+333 -18
View File
@@ -70,11 +70,15 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull) avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[] roles UserRole[]
sessions Session[] sessions Session[]
customer Customer? customer Customer?
auditLogs AuditLog[] auditLogs AuditLog[]
/// Reviews this operator approved or rejected.
moderatedReviews Review[]
/// Pages and posts this operator wrote.
authoredContent ContentEntry[]
@@index([type, status]) @@index([type, status])
@@index([createdAt]) @@index([createdAt])
@@ -281,6 +285,7 @@ model MediaAsset {
collectionBanners Collection[] @relation("CollectionBanner") collectionBanners Collection[] @relation("CollectionBanner")
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch") optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
userAvatars User[] @relation("UserAvatar") userAvatars User[] @relation("UserAvatar")
contentCovers ContentEntry[] @relation("ContentCover")
@@index([kind, createdAt]) @@index([kind, createdAt])
@@map("media_assets") @@map("media_assets")
@@ -397,6 +402,7 @@ model Collection {
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull) banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
products ProductCollection[] products ProductCollection[]
translations CollectionTranslation[] translations CollectionTranslation[]
discounts DiscountCollection[]
@@index([isActive, startsAt, endsAt]) @@index([isActive, startsAt, endsAt])
@@map("collections") @@map("collections")
@@ -501,11 +507,25 @@ model Product {
isOnSale Boolean @default(false) @map("is_on_sale") isOnSale Boolean @default(false) @map("is_on_sale")
inStock Boolean @default(false) @map("in_stock") inStock Boolean @default(false) @map("in_stock")
/// Rating aggregate, stored as sum + count rather than an average.
///
/// Two integers cannot drift the way a stored float can: approving one more
/// 4-star review is `sum + 4, count + 1`, which is exact and reversible.
/// A stored average would have to be recomputed from scratch to stay honest,
/// and would quietly accumulate rounding error if it were not.
///
/// Derived, never authored — `ReviewsService.recomputeRating()` is the only
/// writer. Treat a manual UPDATE of these columns as a bug.
ratingSum Int @default(0) @map("rating_sum")
ratingCount Int @default(0) @map("rating_count")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
searchDocuments SearchDocument[] searchDocuments SearchDocument[]
reviews Review[]
discounts DiscountProduct[]
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull) brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull) primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
options ProductOption[] options ProductOption[]
@@ -935,15 +955,15 @@ model Order {
totalAmount Int @map("total_amount") totalAmount Int @map("total_amount")
/// ---- Shipping address, snapshot ---------------------------------------- /// ---- Shipping address, snapshot ----------------------------------------
shipFullName String @map("ship_full_name") @db.VarChar(160) shipFullName String @map("ship_full_name") @db.VarChar(160)
shipPhone String @map("ship_phone") @db.VarChar(20) shipPhone String @map("ship_phone") @db.VarChar(20)
shipLine1 String @map("ship_line1") @db.VarChar(255) shipLine1 String @map("ship_line1") @db.VarChar(255)
shipLine2 String? @map("ship_line2") @db.VarChar(255) shipLine2 String? @map("ship_line2") @db.VarChar(255)
shipWard String? @map("ship_ward") @db.VarChar(120) shipWard String? @map("ship_ward") @db.VarChar(120)
shipDistrict String? @map("ship_district") @db.VarChar(120) shipDistrict String? @map("ship_district") @db.VarChar(120)
shipProvince String @map("ship_province") @db.VarChar(120) shipProvince String @map("ship_province") @db.VarChar(120)
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2) shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20) shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
customerNote String? @map("customer_note") @db.VarChar(1000) customerNote String? @map("customer_note") @db.VarChar(1000)
@@ -955,8 +975,9 @@ model Order {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
lines OrderLine[] lines OrderLine[]
redemptions DiscountRedemption[]
@@index([customerId]) @@index([customerId])
@@index([email]) @@index([email])
@@ -987,6 +1008,7 @@ model OrderLine {
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
review Review?
@@index([orderId]) @@index([orderId])
@@index([variantId]) @@index([variantId])
@@ -1013,11 +1035,11 @@ model SearchDocument {
locale Locale locale Locale
/// Weight A — the product name. /// Weight A — the product name.
title String @db.VarChar(255) title String @db.VarChar(255)
/// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal. /// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal.
keywords String keywords String
/// Weight C — descriptions. Long, low-signal, still worth matching. /// Weight C — descriptions. Long, low-signal, still worth matching.
body String body String
/// Maintained by Postgres from the columns above. Declared here only so /// 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 /// Prisma knows it exists and leaves it alone; it is read and written with
@@ -1031,3 +1053,296 @@ model SearchDocument {
@@id([productId, locale]) @@id([productId, locale])
@@map("search_documents") @@map("search_documents")
} }
// ---------------------------------------------------------------------------
// Discounts (M7)
//
// One entity, two entry points. A promotion applies itself when its conditions
// match; a coupon does the same but only once someone types a code. Modelling
// them separately would mean two rule engines that must agree about stacking,
// limits and rounding — and they would not, for long.
// ---------------------------------------------------------------------------
enum DiscountTrigger {
/// Applies on its own when conditions match.
AUTOMATIC
/// Requires the shopper to enter a code.
CODE
}
enum DiscountType {
/// `value` is whole percent, 1–100.
PERCENTAGE
/// `value` is an amount in minor units (ADR-0011).
FIXED_AMOUNT
}
enum DiscountScope {
/// Applies against the whole order subtotal.
ORDER
/// Applies only to the lines this discount targets.
PRODUCT
}
model Discount {
id String @id @default(uuid(7)) @db.Uuid
/// Null for automatic promotions. Uppercased on write so lookup is
/// case-insensitive without a functional index.
code String? @unique @db.VarChar(40)
trigger DiscountTrigger
type DiscountType
scope DiscountScope @default(ORDER)
/// Percent (1–100) or minor units, depending on `type`.
value Int
/// ---- Conditions --------------------------------------------------------
minSubtotalAmount Int? @map("min_subtotal_amount")
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
isActive Boolean @default(true) @map("is_active")
/// ---- Limits ------------------------------------------------------------
///
/// `usageCount` is incremented by a conditional UPDATE guarded on the limit,
/// never read-then-written — the same lesson stock reservation learned the
/// hard way. Two shoppers redeeming the last use of a code must not both win.
usageLimit Int? @map("usage_limit")
usageCount Int @default(0) @map("usage_count")
/// ---- Combination -------------------------------------------------------
///
/// Lower `priority` is evaluated first. A non-stackable discount that applies
/// ends evaluation, so "best single offer" and "stack everything" are both
/// expressible without a second engine.
stackable Boolean @default(false)
priority Int @default(100)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
translations DiscountTranslation[]
products DiscountProduct[]
collections DiscountCollection[]
redemptions DiscountRedemption[]
@@index([trigger, isActive])
@@index([code])
@@map("discounts")
}
/// The customer-facing name, per locale. "Giảm 20%" is not a translation of an
/// internal label — it is what appears on the order summary.
model DiscountTranslation {
discountId String @map("discount_id") @db.Uuid
locale Locale
name String @db.VarChar(120)
description String? @db.VarChar(500)
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
@@id([discountId, locale])
@@map("discount_translations")
}
/// Targets for a PRODUCT-scoped discount. An empty target set on a PRODUCT
/// scope means it matches nothing — safer than matching everything.
model DiscountProduct {
discountId String @map("discount_id") @db.Uuid
productId String @map("product_id") @db.Uuid
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@id([discountId, productId])
@@index([productId])
@@map("discount_products")
}
model DiscountCollection {
discountId String @map("discount_id") @db.Uuid
collectionId String @map("collection_id") @db.Uuid
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
@@id([discountId, collectionId])
@@index([collectionId])
@@map("discount_collections")
}
/// One row per discount actually applied to an order.
///
/// This is the record of what was granted, not a projection of it. The amount
/// is snapshot for the same reason order lines are (ADR-0018): changing a
/// discount's value later must not rewrite what a customer already received.
model DiscountRedemption {
id String @id @default(uuid(7)) @db.Uuid
discountId String @map("discount_id") @db.Uuid
orderId String @map("order_id") @db.Uuid
/// Snapshot, in minor units.
amount Int
/// Snapshot of the code as typed, so a renamed code stays traceable.
code String? @db.VarChar(40)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
discount Discount @relation(fields: [discountId], references: [id], onDelete: Restrict)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@unique([orderId, discountId])
@@index([discountId])
@@map("discount_redemptions")
}
// ---------------------------------------------------------------------------
// Reviews (M7)
//
// Owned exclusively by ReviewsModule.
// ---------------------------------------------------------------------------
enum ReviewStatus {
PENDING
APPROVED
REJECTED
}
/// One review of one purchased item.
///
/// Anchored to an OrderLine rather than to a product + email pair, which is the
/// whole design. "Verified purchase" is then not a flag somebody sets, or a
/// lookup that can be spoofed by typing a stranger's email — it is the primary
/// key relationship. You cannot review what you did not buy, because there is
/// no row to hang the review on, and the unique constraint gives "one review
/// per item purchased" for free rather than as a rule someone must enforce.
///
/// The cost is that a shopper cannot review a product they own but bought
/// elsewhere. That is the correct trade for a store this size: the alternative
/// is an open submission endpoint, which is a spam surface that needs a
/// moderation team rather than a moderation screen.
model Review {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
/// The proof of purchase. Unique: one review per item bought.
orderLineId String @unique @map("order_line_id") @db.Uuid
/// 1..5. Constrained in the database too — a 7-star review would silently
/// corrupt every aggregate that sums this column.
rating Int
title String? @db.VarChar(140)
body String? @db.VarChar(2000)
/// Snapshot, like the order's address. The reviewer may later change their
/// account name, or never have had one; the byline on a published review
/// must not change underneath it.
authorName String @map("author_name") @db.VarChar(120)
status ReviewStatus @default(PENDING)
/// ---- Moderation --------------------------------------------------------
moderatedAt DateTime? @map("moderated_at") @db.Timestamptz(3)
moderatedByUserId String? @map("moderated_by_user_id") @db.Uuid
/// Why it was rejected. Internal — never rendered to the shopper.
moderationNote String? @map("moderation_note") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
orderLine OrderLine @relation(fields: [orderLineId], references: [id], onDelete: Cascade)
moderatedBy User? @relation(fields: [moderatedByUserId], references: [id], onDelete: SetNull)
/// The storefront query: approved reviews for one product, newest first.
@@index([productId, status, createdAt])
/// The moderation queue: oldest pending first, so nothing waits forever.
@@index([status, createdAt])
@@map("reviews")
}
// ---------------------------------------------------------------------------
// Content (M7)
//
// Owned exclusively by CmsModule.
// ---------------------------------------------------------------------------
enum ContentType {
PAGE
POST
}
enum ContentStatus {
DRAFT
PUBLISHED
}
/// A page or a post.
///
/// One entity with a `type` rather than `pages` and `blog_posts` side by side,
/// for the same reason promotions and coupons are one (ADR-0020): the shared
/// surface — per-locale slug, title, body, SEO, publish state, soft delete — is
/// nearly all of it, and two tables means two admin screens and two sets of
/// publishing rules that must agree forever.
///
/// What actually differs is placement, and that is what `type` says: a POST is
/// listed in a feed newest-first and carries an excerpt and a cover; a PAGE is
/// addressed directly from the footer and is never listed.
///
/// Deliberately NOT a page builder. The body is Markdown, one column. The
/// moment this grows a block tree it becomes a layout tool that the storefront
/// design has to obey, and the design stops being code.
model ContentEntry {
id String @id @default(uuid(7)) @db.Uuid
type ContentType
status ContentStatus @default(DRAFT)
/// When it went live. Set on the first publish and kept afterwards, so
/// un-publishing and re-publishing does not reorder the feed or rewrite a
/// date readers may already have seen.
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
coverImageId String? @map("cover_image_id") @db.Uuid
authorUserId String? @map("author_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
coverImage MediaAsset? @relation("ContentCover", fields: [coverImageId], references: [id], onDelete: SetNull)
author User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
translations ContentEntryTranslation[]
/// The feed query: published posts, newest first.
@@index([type, status, publishedAt])
@@map("content_entries")
}
/// Per-locale slugs, for the same reason products have them: `/en/blog/
/// how-to-layer` and `/vi/blog/cach-phoi-do-mua-lanh` are the SEO surface.
model ContentEntryTranslation {
entryId String @map("entry_id") @db.Uuid
locale Locale
slug String @db.VarChar(200)
title String @db.VarChar(255)
/// Feed summary. Posts only in practice; nullable rather than required so a
/// page is not forced to invent one.
excerpt String? @db.VarChar(500)
/// Markdown. Rendered to React elements, never injected as HTML — see the
/// storefront's <Markdown> component.
body String
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
entry ContentEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
@@id([entryId, locale])
@@unique([locale, slug])
@@map("content_entry_translations")
}
@@ -16,8 +16,10 @@ import type { Request, Response } from 'express';
import type { Cart, Locale } from '@sport/types'; import type { Cart, Locale } from '@sport/types';
import { import {
addCartLineSchema, addCartLineSchema,
applyDiscountCodeSchema,
updateCartLineSchema, updateCartLineSchema,
type AddCartLineInput, type AddCartLineInput,
type ApplyDiscountCodeInput,
type UpdateCartLineInput, type UpdateCartLineInput,
} from '@sport/validation'; } from '@sport/validation';
@@ -88,6 +90,28 @@ export class CartsController {
return this.service.removeLine(this.token(request, response), variantId, locale); return this.service.removeLine(this.token(request, response), variantId, locale);
} }
@Post('discounts')
@ApiOperation({ summary: 'Apply a discount code to the bag' })
applyCode(
@Body(new ZodValidationPipe(applyDiscountCodeSchema)) body: ApplyDiscountCodeInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
return this.service.applyCode(this.token(request, response), body.code, locale);
}
@Delete('discounts/:code')
@ApiOperation({ summary: 'Remove a discount code' })
removeCode(
@Param('code') code: string,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Cart> {
return this.service.removeCode(this.token(request, response), code, locale);
}
/** /**
* Reads the cart cookie, minting one on first contact. * Reads the cart cookie, minting one on first contact.
* *
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module'; import { MediaUrlModule } from '@/common/media/media.module';
import { PromotionsModule } from '@/modules/promotions/promotions.module';
import { CartsController } from './carts.controller'; import { CartsController } from './carts.controller';
import { CartsService } from './carts.service'; import { CartsService } from './carts.service';
@@ -14,7 +15,7 @@ import { CartsService } from './carts.service';
* flow prices a cart through exactly the same code path the shopper saw. * flow prices a cart through exactly the same code path the shopper saw.
*/ */
@Module({ @Module({
imports: [MediaUrlModule], imports: [MediaUrlModule, PromotionsModule],
controllers: [CartsController], controllers: [CartsController],
providers: [CartsService], providers: [CartsService],
exports: [CartsService], exports: [CartsService],
+89 -9
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { import {
CART_NOTICE_REASONS, CART_NOTICE_REASONS,
DISCOUNT_REJECTIONS,
type Cart, type Cart,
type CartLine, type CartLine,
type CartNotice, type CartNotice,
@@ -19,6 +20,7 @@ import { MediaUrlService } from '@/common/media/media-url.service';
import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys'; import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
import { PromotionsService, type EvaluationLine } from '@/modules/promotions/public';
/** /**
* What actually lives in Redis. * What actually lives in Redis.
@@ -37,6 +39,14 @@ interface StoredLine {
interface StoredCart { interface StoredCart {
id: string; id: string;
lines: StoredLine[]; lines: StoredLine[];
/**
* Codes the shopper typed, not the discounts they earned.
*
* Same principle as prices: the bag stores intent, the API decides outcome.
* Storing the computed discount would let a thirty-day-old cart carry an
* expired offer into checkout.
*/
codes: string[];
updatedAt: string; updatedAt: string;
} }
@@ -48,6 +58,7 @@ export class CartsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly redis: RedisService, private readonly redis: RedisService,
private readonly mediaUrl: MediaUrlService, private readonly mediaUrl: MediaUrlService,
private readonly promotions: PromotionsService,
) {} ) {}
async get(cartToken: string, locale: Locale): Promise<Cart> { async get(cartToken: string, locale: Locale): Promise<Cart> {
@@ -100,6 +111,47 @@ export class CartsService {
return this.hydrate(cartToken, await this.write(cartToken, stored), locale); return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
} }
/** Adds a code to the bag. Whether it is valid is decided at hydration. */
async applyCode(cartToken: string, code: string, locale: Locale): Promise<Cart> {
const stored = await this.read(cartToken);
const normalised = code.trim().toUpperCase();
if (normalised && !stored.codes.includes(normalised)) {
stored.codes.push(normalised);
}
const cart = await this.hydrate(cartToken, await this.write(cartToken, stored), locale);
/**
* A code that does not exist is not remembered.
*
* Every other rejection is worth keeping — an expired code or an unmet
* minimum describes a real discount the shopper might still qualify for
* once the bag changes. A typo describes nothing, and storing it leaves an
* error banner on the bag for thirty days with no way to reason about it.
* The rejection is still returned here, so they see it once.
*/
const unknown = cart.rejectedDiscounts.some(
(rejection) => rejection.code === normalised && rejection.reason === CART_DISCOUNT_NOT_FOUND,
);
if (unknown) {
await this.write(cartToken, {
...stored,
codes: stored.codes.filter((item) => item !== normalised),
});
}
return cart;
}
async removeCode(cartToken: string, code: string, locale: Locale): Promise<Cart> {
const stored = await this.read(cartToken);
stored.codes = stored.codes.filter((item) => item !== code.trim().toUpperCase());
return this.hydrate(cartToken, await this.write(cartToken, stored), locale);
}
async clear(cartToken: string): Promise<void> { async clear(cartToken: string): Promise<void> {
await this.redis.delete(CACHE_KEYS.guestCart(cartToken)); await this.redis.delete(CACHE_KEYS.guestCart(cartToken));
} }
@@ -125,7 +177,10 @@ export class CartsService {
private async read(cartToken: string): Promise<StoredCart> { private async read(cartToken: string): Promise<StoredCart> {
const stored = await this.redis.get<StoredCart>(CACHE_KEYS.guestCart(cartToken)); const stored = await this.redis.get<StoredCart>(CACHE_KEYS.guestCart(cartToken));
return stored ?? { id: cartToken, lines: [], updatedAt: new Date().toISOString() }; // `codes` defaults for carts written before discounts existed.
return stored
? { ...stored, codes: stored.codes ?? [] }
: { id: cartToken, lines: [], codes: [], updatedAt: new Date().toISOString() };
} }
private async write(cartToken: string, cart: StoredCart): Promise<StoredCart> { private async write(cartToken: string, cart: StoredCart): Promise<StoredCart> {
@@ -176,6 +231,7 @@ export class CartsService {
}, },
}, },
}, },
productId: true,
product: { product: {
select: { select: {
name: true, name: true,
@@ -194,6 +250,7 @@ export class CartsService {
const byId = new Map(variants.map((variant) => [variant.id, variant])); const byId = new Map(variants.map((variant) => [variant.id, variant]));
const lines: CartLine[] = []; const lines: CartLine[] = [];
const evaluationLines: EvaluationLine[] = [];
const notices: CartNotice[] = []; const notices: CartNotice[] = [];
const keep: StoredLine[] = []; const keep: StoredLine[] = [];
@@ -245,7 +302,7 @@ export class CartsService {
const currency = variant.currency as CurrencyCode; const currency = variant.currency as CurrencyCode;
const unit = variant.salePriceAmount ?? variant.priceAmount; const unit = variant.salePriceAmount ?? variant.priceAmount;
lines.push({ const cartLine: CartLine = {
variantId: variant.id, variantId: variant.id,
productName, productName,
productSlug: coalesceRequired(translation?.slug, variant.product.slug), productSlug: coalesceRequired(translation?.slug, variant.product.slug),
@@ -260,9 +317,15 @@ export class CartsService {
quantity, quantity,
lineTotal: { amount: unit * quantity, currency }, lineTotal: { amount: unit * quantity, currency },
maxQuantity: Math.min(available, MAX_LINE_QUANTITY), maxQuantity: Math.min(available, MAX_LINE_QUANTITY),
}); };
lines.push(cartLine);
keep.push({ ...line, quantity }); keep.push({ ...line, quantity });
// Built from the same object that was just pushed, so the two lists
// cannot drift — the engine needs `productId`, which the customer-facing
// CartLine deliberately does not carry.
evaluationLines.push({ ...cartLine, productId: variant.productId });
} }
// Persist the pruning so the next read is clean and each notice is shown // Persist the pruning so the next read is clean and each notice is shown
@@ -281,16 +344,26 @@ export class CartsService {
this.logger.log(`Cart ${cartToken} corrected: ${notices.length} notice(s)`); this.logger.log(`Cart ${cartToken} corrected: ${notices.length} notice(s)`);
} }
const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode;
const discounts = await this.promotions.evaluate(
evaluationLines,
stored.codes,
currency,
locale,
);
return { return {
id: cartToken, id: cartToken,
lines, lines,
totals: this.totals(lines), totals: this.totals(lines, discounts.totalDiscount),
notices, notices,
discounts: discounts.applied,
rejectedDiscounts: discounts.rejected,
updatedAt: stored.updatedAt, updatedAt: stored.updatedAt,
}; };
} }
private totals(lines: readonly CartLine[]): CartTotals { private totals(lines: readonly CartLine[], discount: number): CartTotals {
const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode; const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode;
const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0); const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0);
const money = (amount: number): Money => ({ amount, currency }); const money = (amount: number): Money => ({ amount, currency });
@@ -298,12 +371,14 @@ export class CartsService {
return { return {
itemCount: lines.reduce((count, line) => count + line.quantity, 0), itemCount: lines.reduce((count, line) => count + line.quantity, 0),
subtotal: money(subtotal), subtotal: money(subtotal),
// Promotions are M7 and shipping is M9. Named zeroes rather than an discount: money(discount),
// absent field, so the total is always the sum of its parts. // Shipping is M9. A named zero rather than an absent field, so the total
discount: money(0), // is always the sum of parts a shopper can see.
shipping: money(0), shipping: money(0),
tax: money(0), tax: money(0),
total: money(subtotal), // Clamped: the engine already refuses to over-discount, and this is the
// second place that must be true before a total reaches a customer.
total: money(Math.max(0, subtotal - discount)),
}; };
} }
@@ -322,6 +397,8 @@ export class CartsService {
total: money(0), total: money(0),
}, },
notices: [], notices: [],
discounts: [],
rejectedDiscounts: [],
updatedAt, updatedAt,
}; };
} }
@@ -351,3 +428,6 @@ function variantTitle(variant: {
return labels.length > 0 ? labels.join(' / ') : variant.title; return labels.length > 0 ? labels.join(' / ') : variant.title;
} }
/** Local alias so the reason is named once rather than spelled at the call site. */
const CART_DISCOUNT_NOT_FOUND = DISCOUNT_REJECTIONS.NOT_FOUND;
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CartsModule } from '@/modules/carts/carts.module'; import { CartsModule } from '@/modules/carts/carts.module';
import { OrdersModule } from '@/modules/orders/orders.module'; import { OrdersModule } from '@/modules/orders/orders.module';
import { PromotionsModule } from '@/modules/promotions/promotions.module';
import { CheckoutController } from './checkout.controller'; import { CheckoutController } from './checkout.controller';
import { CheckoutService } from './checkout.service'; import { CheckoutService } from './checkout.service';
@@ -14,7 +15,7 @@ import { CheckoutService } from './checkout.service';
* being smeared across the two sides. Payment providers (M9) attach here. * being smeared across the two sides. Payment providers (M9) attach here.
*/ */
@Module({ @Module({
imports: [CartsModule, OrdersModule], imports: [CartsModule, OrdersModule, PromotionsModule],
controllers: [CheckoutController], controllers: [CheckoutController],
providers: [CheckoutService], providers: [CheckoutService],
exports: [CheckoutService], exports: [CheckoutService],
@@ -9,6 +9,7 @@ import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
import { CartsService } from '@/modules/carts/public'; import { CartsService } from '@/modules/carts/public';
import { OrdersService } from '@/modules/orders/public'; import { OrdersService } from '@/modules/orders/public';
import { PromotionsService } from '@/modules/promotions/public';
/** What a claimed idempotency key holds while and after a placement runs. */ /** What a claimed idempotency key holds while and after a placement runs. */
interface IdempotencyRecord { interface IdempotencyRecord {
@@ -36,6 +37,7 @@ export class CheckoutService {
private readonly redis: RedisService, private readonly redis: RedisService,
private readonly carts: CartsService, private readonly carts: CartsService,
private readonly orders: OrdersService, private readonly orders: OrdersService,
private readonly promotions: PromotionsService,
) {} ) {}
/** What the shopper is about to agree to. Priced by the cart, never by the client. */ /** What the shopper is about to agree to. Priced by the cart, never by the client. */
@@ -153,12 +155,24 @@ export class CheckoutService {
} }
} }
/**
* Claim the discounts before writing the order.
*
* Inside the same transaction and guarded on the usage limit, so a code
* with one use left cannot be spent twice by two simultaneous checkouts.
* If it ran out between the shopper seeing the total and pressing the
* button, the whole placement rolls back rather than quietly charging
* them a price nobody authorised.
*/
const order = await tx.order.create({ const order = await tx.order.create({
data: { data: {
email: input.email, email: input.email,
phone: input.shippingAddress.phone, phone: input.shippingAddress.phone,
subtotalAmount: cart.totals.subtotal.amount, subtotalAmount: cart.totals.subtotal.amount,
// Recomputed by the cart a moment ago from live discount rules — the
// client never sends an amount, and a stale bag cannot carry an
// expired offer this far.
discountAmount: cart.totals.discount.amount, discountAmount: cart.totals.discount.amount,
shippingAmount: cart.totals.shipping.amount, shippingAmount: cart.totals.shipping.amount,
taxAmount: cart.totals.tax.amount, taxAmount: cart.totals.tax.amount,
@@ -194,6 +208,8 @@ export class CheckoutService {
select: { id: true, number: true }, select: { id: true, number: true },
}); });
await this.promotions.redeem(tx, order.id, cart.discounts);
return order.id; return order.id;
}); });
+137
View File
@@ -0,0 +1,137 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminContentEntry,
type AuthenticatedActor,
type ContentDetail,
type ContentSummary,
type Locale,
type OffsetPaginated,
} from '@sport/types';
import {
contentEntryInputSchema,
contentListQuerySchema,
postListQuerySchema,
type ContentEntryInput,
type ContentListQuery,
type PostListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { Public } from '@/common/decorators/public.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { RequestLocale } from '@/common/i18n';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { CmsService } from './cms.service';
@ApiTags('content')
@Controller('content')
export class CmsController {
constructor(private readonly service: CmsService) {}
@Get('posts')
@Public()
@ApiOperation({ summary: 'Published posts, newest first' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
listPosts(
@Query(new ZodValidationPipe(postListQuerySchema)) query: PostListQuery,
@RequestLocale() locale: Locale,
): Promise<OffsetPaginated<ContentSummary>> {
return this.service.listPosts(query, locale);
}
@Get('posts/slugs')
@Public()
@ApiOperation({ summary: 'Published post slugs for this locale (sitemap / static params)' })
postSlugs(
@RequestLocale() locale: Locale,
): Promise<{ slug: string; title: string; updatedAt: string }[]> {
return this.service.listSlugs('POST', locale);
}
@Get('pages/slugs')
@Public()
@ApiOperation({ summary: 'Published page slugs for this locale' })
pageSlugs(
@RequestLocale() locale: Locale,
): Promise<{ slug: string; title: string; updatedAt: string }[]> {
return this.service.listSlugs('PAGE', locale);
}
// Declared after the literal `posts/slugs` route above — Nest matches in
// declaration order, so a `:slug` parameter placed first would swallow it.
@Get('posts/:slug')
@Public()
@ApiOperation({ summary: 'One published post' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getPost(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<ContentDetail> {
return this.service.getBySlug(slug, 'POST', locale);
}
@Get('pages/:slug')
@Public()
@ApiOperation({ summary: 'One published page' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
getPage(@Param('slug') slug: string, @RequestLocale() locale: Locale): Promise<ContentDetail> {
return this.service.getBySlug(slug, 'PAGE', locale);
}
}
@ApiTags('admin/content')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/content')
export class CmsAdminController {
constructor(private readonly service: CmsService) {}
@Get()
@RequirePermissions(PERMISSIONS.CMS_READ)
@ApiOperation({ summary: 'Pages and posts, recently edited first' })
list(
@Query(new ZodValidationPipe(contentListQuerySchema)) query: ContentListQuery,
): Promise<OffsetPaginated<AdminContentEntry>> {
return this.service.listForAdmin(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.CMS_READ)
@ApiOperation({ summary: 'One entry, every locale' })
getById(@Param('id') id: string): Promise<AdminContentEntry> {
return this.service.getByIdForAdmin(id);
}
@Post()
@RequirePermissions(PERMISSIONS.CMS_MANAGE)
@ApiOperation({ summary: 'Create a page or post' })
create(
@Body(new ZodValidationPipe(contentEntryInputSchema)) body: ContentEntryInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminContentEntry> {
return this.service.create(body, actor.userId);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.CMS_MANAGE)
@ApiOperation({ summary: 'Update a page or post' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(contentEntryInputSchema)) body: ContentEntryInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminContentEntry> {
return this.service.update(id, body, actor.userId);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.CMS_MANAGE)
@ApiOperation({ summary: 'Retire an entry; it stops resolving on the storefront' })
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.service.remove(id, actor.userId);
}
}
+20 -12
View File
@@ -1,19 +1,27 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module';
import { CmsAdminController, CmsController } from './cms.controller';
import { CmsRepository } from './cms.repository';
import { CmsService } from './cms.service';
/** /**
* CmsModule — boundary declared, implementation pending. * CmsModule — owns `content_entries` and their translations.
* *
* Owns (exclusively): `pages`, `blog_posts`, `banners`, `navigation_menus` — milestone 3 * Pages and posts are one entity with a `type`, for the same reason promotions
* and coupons are (ADR-0020): the shared surface is nearly all of it, and what
* differs is placement.
* *
* Homepage blocks, /blog and static pages. Editorial content is versioned and previewable; it never becomes a general-purpose page builder. * Deliberately NOT a page builder. The body is one Markdown column. A block
* * tree would make this a layout tool that the storefront design has to obey,
* Anatomy once implemented (see ../README.md): * and the design would stop being code — which is the thing this project exists
* cms.module.ts wiring only * to avoid.
* cms.controller.ts HTTP surface, no logic
* cms.service.ts business rules
* cms.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
imports: [MediaUrlModule],
controllers: [CmsController, CmsAdminController],
providers: [CmsService, CmsRepository],
exports: [CmsService],
})
export class CmsModule {} export class CmsModule {}
+153
View File
@@ -0,0 +1,153 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const entrySelect = {
id: true,
type: true,
status: true,
publishedAt: true,
coverImageId: true,
createdAt: true,
updatedAt: true,
coverImage: { select: { storageKey: true } },
author: { select: { firstName: true, lastName: true } },
translations: true,
} as const;
export type ContentEntryRow = Prisma.ContentEntryGetPayload<{ select: typeof entrySelect }>;
/** The only file in this module that touches Prisma. */
@Injectable()
export class CmsRepository {
constructor(private readonly prisma: PrismaService) {}
// ---- Storefront ----------------------------------------------------------
/**
* A published entry by slug, in any locale.
*
* Deliberately not scoped to the requested locale: a reader following an
* English link while browsing in Vietnamese should land on the entry, not a
* 404. The service redirects to the canonical slug afterwards — the same
* behaviour products already have.
*/
findPublishedBySlug(slug: string, type: 'PAGE' | 'POST') {
return this.prisma.contentEntry.findFirst({
where: {
type,
status: 'PUBLISHED',
deletedAt: null,
translations: { some: { slug } },
},
select: entrySelect,
});
}
findPublishedPosts(skip: number, take: number) {
return this.prisma.contentEntry.findMany({
where: { type: 'POST', status: 'PUBLISHED', deletedAt: null },
// Newest first by publication, not by creation: a post drafted in January
// and published in March belongs at the top in March.
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
skip,
take,
select: entrySelect,
});
}
countPublishedPosts(): Promise<number> {
return this.prisma.contentEntry.count({
where: { type: 'POST', status: 'PUBLISHED', deletedAt: null },
});
}
/**
* Slugs for `generateStaticParams`, the sitemap, and the footer's page list.
*
* The title rides along because the footer needs a label and a sitemap does
* not mind an extra column — cheaper than a second endpoint that returns the
* same rows with one more field.
*/
findPublishedSlugs(type: 'PAGE' | 'POST', locale: 'VI' | 'EN') {
return this.prisma.contentEntryTranslation.findMany({
where: {
locale,
entry: { type, status: 'PUBLISHED', deletedAt: null },
},
select: { slug: true, title: true, entry: { select: { updatedAt: true } } },
});
}
// ---- Admin ---------------------------------------------------------------
findForAdmin(where: Prisma.ContentEntryWhereInput, skip: number, take: number) {
return this.prisma.contentEntry.findMany({
where,
orderBy: [{ updatedAt: 'desc' }],
skip,
take,
select: entrySelect,
});
}
countForAdmin(where: Prisma.ContentEntryWhereInput): Promise<number> {
return this.prisma.contentEntry.count({ where });
}
findById(id: string) {
return this.prisma.contentEntry.findFirst({
where: { id, deletedAt: null },
select: entrySelect,
});
}
create(data: Prisma.ContentEntryUncheckedCreateInput) {
return this.prisma.contentEntry.create({ data, select: { id: true } });
}
update(id: string, data: Prisma.ContentEntryUncheckedUpdateInput) {
return this.prisma.contentEntry.update({ where: { id }, data, select: { id: true } });
}
softDelete(id: string) {
return this.prisma.contentEntry.update({
where: { id },
// Unpublished as well as deleted: a soft-deleted row that is still
// PUBLISHED is one forgotten `deletedAt: null` away from being live again.
data: { deletedAt: new Date(), status: 'DRAFT' },
select: { id: true },
});
}
/**
* Replaces an entry's translations.
*
* Delete-then-insert rather than upsert-per-locale, because removing a locale
* has to actually remove it: an operator who deletes the English version of a
* post expects `/en/blog/<slug>` to stop resolving, not to keep serving the
* copy they just deleted.
*/
async replaceTranslations(
id: string,
rows: Prisma.ContentEntryTranslationUncheckedCreateInput[],
): Promise<void> {
await this.prisma.$transaction([
this.prisma.contentEntryTranslation.deleteMany({ where: { entryId: id } }),
this.prisma.contentEntryTranslation.createMany({ data: rows }),
]);
}
/** Whether a slug is taken by a *different* entry in the same locale. */
slugTakenBy(locale: 'VI' | 'EN', slug: string, exceptEntryId: string | null) {
return this.prisma.contentEntryTranslation.findFirst({
where: {
locale,
slug,
...(exceptEntryId ? { entryId: { not: exceptEntryId } } : {}),
},
select: { entryId: true },
});
}
}
+315
View File
@@ -0,0 +1,315 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
LOCALES,
type AdminContentEntry,
type ContentDetail,
type ContentSummary,
type ContentType,
type Locale,
type OffsetPaginated,
} from '@sport/types';
import type { ContentEntryInput, ContentListQuery, PostListQuery } from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { coalesceRequired, pickTranslation, toDbLocale } from '@/common/i18n';
import { MediaUrlService } from '@/common/media/media-url.service';
import { CmsRepository, type ContentEntryRow } from './cms.repository';
@Injectable()
export class CmsService {
private readonly logger = new Logger(CmsService.name);
constructor(
private readonly repository: CmsRepository,
private readonly audit: AuditService,
private readonly mediaUrl: MediaUrlService,
) {}
// ---- Storefront ----------------------------------------------------------
async getBySlug(slug: string, type: ContentType, locale: Locale): Promise<ContentDetail> {
const row = await this.repository.findPublishedBySlug(slug, type);
if (!row) throw AppException.notFound(type === 'PAGE' ? 'Page' : 'Post');
return this.toDetail(row, locale);
}
async listPosts(query: PostListQuery, locale: Locale): Promise<OffsetPaginated<ContentSummary>> {
const [rows, totalItems] = await Promise.all([
this.repository.findPublishedPosts((query.page - 1) * query.perPage, query.perPage),
this.repository.countPublishedPosts(),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
/**
* An entry with no translation in *any* locale cannot render, so it is
* dropped rather than shown as a blank card. `flatMap` over `map` because
* the alternative is a nullable item every caller has to filter.
*/
items: rows.flatMap((row) => {
const summary = this.toSummary(row, locale);
return summary ? [summary] : [];
}),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async listSlugs(
type: ContentType,
locale: Locale,
): Promise<{ slug: string; title: string; updatedAt: string }[]> {
const rows = await this.repository.findPublishedSlugs(type, toDbLocale(locale));
return rows.map((row) => ({
slug: row.slug,
title: row.title,
updatedAt: row.entry.updatedAt.toISOString(),
}));
}
// ---- Admin ---------------------------------------------------------------
async listForAdmin(query: ContentListQuery): Promise<OffsetPaginated<AdminContentEntry>> {
const where: Prisma.ContentEntryWhereInput = {
deletedAt: null,
...(query.type ? { type: query.type } : {}),
...(query.status ? { status: query.status } : {}),
...(query.q
? { translations: { some: { title: { contains: query.q, mode: 'insensitive' } } } }
: {}),
};
const [rows, totalItems] = await Promise.all([
this.repository.findForAdmin(where, (query.page - 1) * query.perPage, query.perPage),
this.repository.countForAdmin(where),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => this.toAdmin(row)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async getByIdForAdmin(id: string): Promise<AdminContentEntry> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('Content entry');
return this.toAdmin(row);
}
async create(input: ContentEntryInput, actorUserId: string): Promise<AdminContentEntry> {
await this.assertSlugsFree(input, null);
const created = await this.repository.create({
type: input.type,
status: input.status,
coverImageId: input.coverImageId ?? null,
authorUserId: actorUserId,
publishedAt: input.status === 'PUBLISHED' ? new Date() : null,
});
await this.repository.replaceTranslations(
created.id,
this.toTranslationRows(created.id, input),
);
this.audit.record({
actorUserId,
action: 'content.create',
resourceType: 'ContentEntry',
resourceId: created.id,
changes: { type: input.type, status: input.status },
});
return this.getByIdForAdmin(created.id);
}
async update(
id: string,
input: ContentEntryInput,
actorUserId: string,
): Promise<AdminContentEntry> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Content entry');
await this.assertSlugsFree(input, id);
/**
* `publishedAt` is stamped once, on the first publish, and never rewritten.
*
* Re-stamping it on every save would jump a post to the top of the feed
* because somebody fixed a typo, and would silently change a date readers
* may already have seen cited.
*/
const publishedAt =
input.status === 'PUBLISHED' ? (existing.publishedAt ?? new Date()) : existing.publishedAt;
await this.repository.update(id, {
type: input.type,
status: input.status,
coverImageId: input.coverImageId ?? null,
publishedAt,
});
await this.repository.replaceTranslations(id, this.toTranslationRows(id, input));
this.audit.record({
actorUserId,
action: 'content.update',
resourceType: 'ContentEntry',
resourceId: id,
changes: { status: input.status },
});
return this.getByIdForAdmin(id);
}
async remove(id: string, actorUserId: string): Promise<void> {
await this.repository.softDelete(id);
this.audit.record({
actorUserId,
action: 'content.delete',
resourceType: 'ContentEntry',
resourceId: id,
changes: {},
});
this.logger.log(`Content entry ${id} retired`);
}
// ---- internals -----------------------------------------------------------
/**
* Refuses a slug already used by another entry in the same locale.
*
* The database has a unique index on `(locale, slug)`, so this cannot be the
* only guard — but a 500 from a constraint violation tells an operator
* nothing, and this tells them exactly which slug to change.
*/
private async assertSlugsFree(input: ContentEntryInput, exceptId: string | null): Promise<void> {
for (const locale of LOCALES) {
const fields = input.translations[locale];
if (!fields) continue;
const clash = await this.repository.slugTakenBy(toDbLocale(locale), fields.slug, exceptId);
if (clash) {
throw AppException.conflict(
`The slug "${fields.slug}" is already used by another entry in ${locale.toUpperCase()}.`,
);
}
}
}
private toTranslationRows(
entryId: string,
input: ContentEntryInput,
): Prisma.ContentEntryTranslationUncheckedCreateInput[] {
return LOCALES.flatMap((locale) => {
const fields = input.translations[locale];
if (!fields) return [];
return [
{
entryId,
locale: toDbLocale(locale),
slug: fields.slug,
title: fields.title,
excerpt: fields.excerpt ?? null,
body: fields.body,
metaTitle: fields.metaTitle ?? null,
metaDescription: fields.metaDescription ?? null,
},
];
});
}
private toSummary(row: ContentEntryRow, locale: Locale): ContentSummary | null {
// Falls back to any locale rather than 404ing: a post written only in
// English is still worth listing to a Vietnamese reader, who can read it or
// switch. Hiding it would make the feed differ by locale for no reason.
const translation = pickTranslation(row.translations, locale) ?? row.translations[0];
if (!translation) return null;
return {
id: row.id,
type: row.type,
slug: translation.slug,
title: translation.title,
excerpt: translation.excerpt,
coverImageUrl: row.coverImage ? this.mediaUrl.url(row.coverImage.storageKey) : null,
publishedAt: row.publishedAt?.toISOString() ?? null,
};
}
private toDetail(row: ContentEntryRow, locale: Locale): ContentDetail {
const translation = pickTranslation(row.translations, locale) ?? row.translations[0];
if (!translation) throw AppException.notFound('Content entry');
return {
id: row.id,
type: row.type,
slug: translation.slug,
title: translation.title,
excerpt: translation.excerpt,
body: translation.body,
coverImageUrl: row.coverImage ? this.mediaUrl.url(row.coverImage.storageKey) : null,
publishedAt: row.publishedAt?.toISOString() ?? null,
authorName: row.author ? `${row.author.firstName} ${row.author.lastName}`.trim() : null,
seo: {
metaTitle: coalesceRequired(translation.metaTitle, translation.title),
metaDescription: translation.metaDescription ?? translation.excerpt,
},
alternateSlugs: Object.fromEntries(
row.translations.map((item) => [item.locale.toLowerCase(), item.slug]),
),
};
}
private toAdmin(row: ContentEntryRow): AdminContentEntry {
return {
id: row.id,
type: row.type,
status: row.status,
publishedAt: row.publishedAt?.toISOString() ?? null,
coverImageId: row.coverImageId,
coverImageUrl: row.coverImage ? this.mediaUrl.url(row.coverImage.storageKey) : null,
authorName: row.author ? `${row.author.firstName} ${row.author.lastName}`.trim() : null,
translations: Object.fromEntries(
row.translations.map((item) => [
item.locale.toLowerCase(),
{
slug: item.slug,
title: item.title,
excerpt: item.excerpt,
body: item.body,
metaTitle: item.metaTitle,
metaDescription: item.metaDescription,
},
]),
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+1 -3
View File
@@ -4,7 +4,5 @@
* This barrel is the ONLY thing other modules may import from here. Everything * This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint * else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it. * boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { CmsService } from '../cms.service';
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PromotionsModule } from '@/modules/promotions/promotions.module';
import { OrdersAdminController } from './orders.controller'; import { OrdersAdminController } from './orders.controller';
import { OrdersMapper } from './orders.mapper'; import { OrdersMapper } from './orders.mapper';
import { OrdersRepository } from './orders.repository'; import { OrdersRepository } from './orders.repository';
@@ -16,6 +18,7 @@ import { OrdersService } from './orders.service';
* EXTRACTION CANDIDATE. * EXTRACTION CANDIDATE.
*/ */
@Module({ @Module({
imports: [PromotionsModule],
controllers: [OrdersAdminController], controllers: [OrdersAdminController],
providers: [OrdersService, OrdersRepository, OrdersMapper], providers: [OrdersService, OrdersRepository, OrdersMapper],
exports: [OrdersService], exports: [OrdersService],
@@ -15,6 +15,7 @@ import type { OrderListQuery, UpdateOrderStatusInput } from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service'; import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception'; import { AppException } from '@/common/errors/app.exception';
import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { PromotionsService } from '@/modules/promotions/public';
import { OrdersMapper, parseOrderNumber } from './orders.mapper'; import { OrdersMapper, parseOrderNumber } from './orders.mapper';
import { OrdersRepository } from './orders.repository'; import { OrdersRepository } from './orders.repository';
@@ -42,6 +43,7 @@ export class OrdersService {
private readonly repository: OrdersRepository, private readonly repository: OrdersRepository,
private readonly mapper: OrdersMapper, private readonly mapper: OrdersMapper,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly promotions: PromotionsService,
) {} ) {}
async getById(id: string): Promise<Order> { async getById(id: string): Promise<Order> {
@@ -165,6 +167,9 @@ export class OrdersService {
if (to === ORDER_STATUSES.CANCELLED) { if (to === ORDER_STATUSES.CANCELLED) {
await this.releaseReservations(tx, existing.lines); await this.releaseReservations(tx, existing.lines);
// A cancelled order consumed a use of every discount it claimed.
// Leaving those spent would quietly retire a coupon nobody redeemed.
await this.promotions.release(tx, id);
} }
if (to === ORDER_STATUSES.FULFILLED) { if (to === ORDER_STATUSES.FULFILLED) {
@@ -17,6 +17,7 @@ import {
type StorefrontProduct, type StorefrontProduct,
type StorefrontVariant, type StorefrontVariant,
type VariantAvailability, type VariantAvailability,
type ProductRatingSummary,
} from '@sport/types'; } from '@sport/types';
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n'; import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
@@ -50,8 +51,7 @@ export class ProductsMapper {
priceRange: this.priceRangeOf(row.variants), priceRange: this.priceRangeOf(row.variants),
isOnSale: row.isOnSale, isOnSale: row.isOnSale,
colorSwatches: this.colorSwatchesOf(row.options, locale), colorSwatches: this.colorSwatchesOf(row.options, locale),
// Reviews land in M7; the field exists so the card layout is final now. rating: ratingOf(row),
rating: null,
}; };
} }
@@ -158,7 +158,7 @@ export class ProductsMapper {
}), }),
variants, variants,
priceRange: this.priceRangeOf(row.variants), priceRange: this.priceRangeOf(row.variants),
rating: null, rating: ratingOf(row),
breadcrumbs, breadcrumbs,
alternateSlugs: Object.fromEntries( alternateSlugs: Object.fromEntries(
row.translations.map((entry) => [entry.locale === 'VI' ? 'vi' : 'en', entry.slug]), row.translations.map((entry) => [entry.locale === 'VI' ? 'vi' : 'en', entry.slug]),
@@ -333,3 +333,22 @@ function toAvailability(available: number): VariantAvailability {
function isPresent<T>(value: T | null): value is T { function isPresent<T>(value: T | null): value is T {
return value !== null; return value !== null;
} }
/**
* Turns the stored sum/count pair into a display summary.
*
* Null rather than `{ average: 0, count: 0 }` when nothing has been reviewed:
* zero stars is a *verdict*, and a product nobody has rated has not received
* one. The card renders nothing at all in that case, which is honest; "0.0 ★"
* on a new product is not.
*/
function ratingOf(row: { ratingSum: number; ratingCount: number }): ProductRatingSummary | null {
if (row.ratingCount === 0) return null;
return {
// One decimal, the convention every storefront uses. The exact value stays
// in the two integers, so this is presentation only.
average: Math.round((row.ratingSum / row.ratingCount) * 10) / 10,
count: row.ratingCount,
};
}
@@ -49,6 +49,10 @@ const listSelect = {
name: true, name: true,
slug: true, slug: true,
isOnSale: true, isOnSale: true,
// Written by ReviewsService, read here. Two integers rather than a stored
// average, so the figure can never drift from the reviews behind it.
ratingSum: true,
ratingCount: true,
translations: true, translations: true,
brand: { select: { id: true, name: true, translations: true } }, brand: { select: { id: true, name: true, translations: true } },
images: { orderBy: { position: 'asc' }, take: 4, select: imageSelect }, images: { orderBy: { position: 'asc' }, take: 4, select: imageSelect },
@@ -64,6 +68,8 @@ const detailSelect = {
id: true, id: true,
name: true, name: true,
slug: true, slug: true,
ratingSum: true,
ratingCount: true,
description: true, description: true,
shortDescription: true, shortDescription: true,
status: true, status: true,
@@ -0,0 +1,158 @@
import { DISCOUNT_REJECTIONS, type CurrencyCode } from '@sport/types';
import { evaluateDiscounts, type DiscountCandidate, type EvaluationLine } from './discount-engine';
const VND = 'VND' as CurrencyCode;
function line(productId: string, amount: number, quantity = 1): EvaluationLine {
return {
productId,
variantId: `${productId}-v`,
productName: productId,
productSlug: productId,
variantTitle: 'M',
sku: `${productId}-SKU`,
imageUrl: null,
unitPrice: { amount: amount / quantity, currency: VND },
compareAtPrice: null,
quantity,
lineTotal: { amount, currency: VND },
maxQuantity: 10,
};
}
function discount(overrides: Partial<DiscountCandidate> = {}): DiscountCandidate {
return {
id: 'd1',
code: 'SAVE',
name: 'Save',
type: 'PERCENTAGE',
scope: 'ORDER',
value: 10,
minSubtotalAmount: null,
stackable: false,
priority: 100,
productIds: [],
...overrides,
};
}
/**
* The discount engine is the one place in this system where a rounding mistake
* is a financial one, so its arithmetic is pinned rather than trusted.
*/
describe('evaluateDiscounts', () => {
const cart = [line('p1', 1_000_000), line('p2', 500_000)]; // subtotal 1,500,000
it('takes a percentage of the subtotal', () => {
const result = evaluateDiscounts([discount({ value: 20 })], cart, VND);
expect(result.totalDiscount).toBe(300_000);
expect(result.applied).toHaveLength(1);
});
it('floors a percentage rather than rounding up', () => {
// 333,333 * 10% = 33,333.3 — rounding up would hand out a fraction of a
// đồng the merchant never agreed to, on every order.
const result = evaluateDiscounts([discount({ value: 10 })], [line('p1', 333_333)], VND);
expect(result.totalDiscount).toBe(33_333);
});
it('never discounts more than the cart is worth', () => {
const result = evaluateDiscounts(
[discount({ type: 'FIXED_AMOUNT', value: 5_000_000 })],
cart,
VND,
);
// A negative total is not a refund.
expect(result.totalDiscount).toBe(1_500_000);
});
it('applies a product-scoped discount only to matching lines', () => {
const result = evaluateDiscounts(
[discount({ scope: 'PRODUCT', value: 50, productIds: ['p2'] })],
cart,
VND,
);
expect(result.totalDiscount).toBe(250_000); // half of p2 only
});
it('treats a product scope with no targets as matching nothing', () => {
const result = evaluateDiscounts([discount({ scope: 'PRODUCT', productIds: [] })], cart, VND);
expect(result.totalDiscount).toBe(0);
expect(result.rejected[0]?.reason).toBe(DISCOUNT_REJECTIONS.NOTHING_ELIGIBLE);
});
it('rejects below the minimum and says what the minimum was', () => {
const result = evaluateDiscounts([discount({ minSubtotalAmount: 2_000_000 })], cart, VND);
expect(result.totalDiscount).toBe(0);
expect(result.rejected[0]).toMatchObject({
reason: DISCOUNT_REJECTIONS.MINIMUM_NOT_MET,
minimumSubtotal: { amount: 2_000_000, currency: VND },
});
});
it('stops after a non-stackable discount applies', () => {
const result = evaluateDiscounts(
[
discount({ id: 'a', code: 'FIRST', value: 10, priority: 1, stackable: false }),
discount({ id: 'b', code: 'SECOND', value: 50, priority: 2, stackable: true }),
],
cart,
VND,
);
expect(result.applied.map((d) => d.code)).toEqual(['FIRST']);
expect(result.totalDiscount).toBe(150_000);
});
it('compounds stackable discounts against what is left, not the original', () => {
const result = evaluateDiscounts(
[
discount({ id: 'a', code: 'A', value: 50, priority: 1, stackable: true }),
discount({ id: 'b', code: 'B', value: 50, priority: 2, stackable: true }),
],
cart,
VND,
);
// 750,000 then 375,000 — not 1,500,000, which would make the order free.
expect(result.totalDiscount).toBe(1_125_000);
expect(result.totalDiscount).toBeLessThan(1_500_000);
});
it('is deterministic regardless of input order', () => {
const a = discount({ id: 'a', code: 'A', value: 10, priority: 2, stackable: true });
const b = discount({ id: 'b', code: 'B', value: 30, priority: 1, stackable: true });
const forwards = evaluateDiscounts([a, b], cart, VND);
const backwards = evaluateDiscounts([b, a], cart, VND);
expect(forwards.applied.map((d) => d.code)).toEqual(['B', 'A']);
expect(backwards.totalDiscount).toBe(forwards.totalDiscount);
});
it('stays silent about automatic promotions that did not apply', () => {
// Only a code the shopper typed deserves an explanation; an unmet automatic
// promotion is not a failure they can act on.
const result = evaluateDiscounts(
[discount({ code: null, minSubtotalAmount: 9_000_000 })],
cart,
VND,
);
expect(result.rejected).toHaveLength(0);
});
it('handles an empty cart without dividing by anything', () => {
const result = evaluateDiscounts([discount()], [], VND);
expect(result.totalDiscount).toBe(0);
expect(result.applied).toHaveLength(0);
});
});
@@ -0,0 +1,151 @@
import {
DISCOUNT_REJECTIONS,
DISCOUNT_SCOPES,
DISCOUNT_TYPES,
type AppliedDiscount,
type CartLine,
type CurrencyCode,
type DiscountRejectionReason,
type RejectedDiscount,
} from '@sport/types';
/** A discount reduced to what the engine actually needs to decide. */
export interface DiscountCandidate {
readonly id: string;
readonly code: string | null;
readonly name: string;
readonly type: 'PERCENTAGE' | 'FIXED_AMOUNT';
readonly scope: 'ORDER' | 'PRODUCT';
readonly value: number;
readonly minSubtotalAmount: number | null;
readonly stackable: boolean;
readonly priority: number;
/** Product ids this discount targets. Empty means "no targets configured". */
readonly productIds: readonly string[];
}
export interface EvaluationLine extends CartLine {
/** Needed to decide whether a PRODUCT-scoped discount matches this line. */
readonly productId: string;
}
export interface EvaluationResult {
readonly applied: AppliedDiscount[];
readonly rejected: RejectedDiscount[];
readonly totalDiscount: number;
}
/**
* Decides which discounts apply to a cart, and for how much.
*
* A pure function over a snapshot: no database, no clock, no I/O. Everything
* that varies — which discounts exist, whether they are within their window,
* whether a code has uses left — is resolved by the caller and passed in. That
* is what makes the money-handling logic testable without a database, and this
* is the one piece of M7 where an arithmetic mistake is a financial one.
*
* Order of evaluation is `priority` ascending, then id, so the outcome does not
* depend on the order the database happened to return rows in.
*/
export function evaluateDiscounts(
candidates: readonly DiscountCandidate[],
lines: readonly EvaluationLine[],
currency: CurrencyCode,
rejectionsIn: readonly RejectedDiscount[] = [],
): EvaluationResult {
const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0);
const applied: AppliedDiscount[] = [];
const rejected: RejectedDiscount[] = [...rejectionsIn];
// Running total, so a second discount never discounts money the first one
// already took off. Without this, two 60% offers would make an order free.
let remaining = subtotal;
const ordered = [...candidates].sort(
(a, b) => a.priority - b.priority || a.id.localeCompare(b.id),
);
for (const candidate of ordered) {
if (remaining <= 0) break;
if (candidate.minSubtotalAmount !== null && subtotal < candidate.minSubtotalAmount) {
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.MINIMUM_NOT_MET, {
amount: candidate.minSubtotalAmount,
currency,
});
continue;
}
/**
* A non-stackable discount that has already been beaten cannot join in.
*
* Checked against what is *already applied* rather than against the whole
* candidate list, so "one offer at a time" means the best one that fit, not
* whichever happened to be evaluated first.
*/
if (applied.length > 0 && !candidate.stackable) {
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.NOT_COMBINABLE, null);
continue;
}
const base =
candidate.scope === DISCOUNT_SCOPES.PRODUCT
? lines
.filter((line) => candidate.productIds.includes(line.productId))
.reduce((sum, line) => sum + line.lineTotal.amount, 0)
: remaining;
if (base <= 0) {
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.NOTHING_ELIGIBLE, null);
continue;
}
const raw =
candidate.type === DISCOUNT_TYPES.PERCENTAGE
? // Floor, not round: rounding up would hand out a fraction of a đồng
// the merchant never agreed to, on every single order.
Math.floor((base * candidate.value) / 100)
: candidate.value;
// Never more than is left to discount. A 500k fixed discount on a 300k cart
// takes 300k, not 500k — a negative total is not a refund, it is a bug.
const amount = Math.min(raw, remaining, base);
if (amount <= 0) {
pushRejection(rejected, candidate, DISCOUNT_REJECTIONS.NOTHING_ELIGIBLE, null);
continue;
}
applied.push({
id: candidate.id,
code: candidate.code,
name: candidate.name,
amount: { amount, currency },
});
remaining -= amount;
// A non-stackable discount that *did* apply closes the door behind it.
if (!candidate.stackable) break;
}
return {
applied,
rejected,
totalDiscount: applied.reduce((sum, discount) => sum + discount.amount.amount, 0),
};
}
function pushRejection(
rejected: RejectedDiscount[],
candidate: DiscountCandidate,
reason: DiscountRejectionReason,
minimumSubtotal: { amount: number; currency: CurrencyCode } | null,
): void {
// Automatic promotions that simply did not apply are not failures worth
// reporting — only a code the shopper actually typed deserves an explanation.
if (!candidate.code) return;
rejected.push({ code: candidate.code, reason, minimumSubtotal });
}
@@ -0,0 +1,244 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { LOCALES, type AdminDiscount, type OffsetPaginated } from '@sport/types';
import type { DiscountInput, DiscountListQuery } from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const detailSelect = {
id: true,
code: true,
trigger: true,
type: true,
scope: true,
value: true,
minSubtotalAmount: true,
startsAt: true,
endsAt: true,
isActive: true,
usageLimit: true,
usageCount: true,
stackable: true,
priority: true,
createdAt: true,
translations: true,
products: { select: { productId: true } },
collections: { select: { collectionId: true } },
} as const;
@Injectable()
export class PromotionsAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async list(query: DiscountListQuery): Promise<OffsetPaginated<AdminDiscount>> {
const where: Prisma.DiscountWhereInput = {
deletedAt: null,
...(query.trigger ? { trigger: query.trigger } : {}),
...(query.q
? {
OR: [
{ code: { contains: query.q, mode: 'insensitive' } },
{ translations: { some: { name: { contains: query.q, mode: 'insensitive' } } } },
],
}
: {}),
};
const [rows, totalItems] = await Promise.all([
this.prisma.discount.findMany({
where,
// Active first, then newest — an operator opens this screen to find
// what is running, not what once ran.
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
skip: (query.page - 1) * query.perPage,
take: query.perPage,
select: detailSelect,
}),
this.prisma.discount.count({ where }),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => toAdminDiscount(row)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async getById(id: string): Promise<AdminDiscount> {
const row = await this.prisma.discount.findFirst({
where: { id, deletedAt: null },
select: detailSelect,
});
if (!row) throw AppException.notFound('Discount');
return toAdminDiscount(row);
}
async create(input: DiscountInput, actorUserId: string): Promise<AdminDiscount> {
const id = await this.prisma.$transaction(async (tx) => {
const discount = await tx.discount.create({
data: this.toRow(input),
select: { id: true },
});
await this.writeRelations(tx, discount.id, input);
return discount.id;
});
this.audit.record({
actorUserId,
action: 'discount.create',
resourceType: 'Discount',
resourceId: id,
changes: { code: input.code ?? null, type: input.type, value: input.value },
});
return this.getById(id);
}
async update(id: string, input: DiscountInput, actorUserId: string): Promise<AdminDiscount> {
const existing = await this.prisma.discount.findFirst({
where: { id, deletedAt: null },
select: { id: true },
});
if (!existing) throw AppException.notFound('Discount');
await this.prisma.$transaction(async (tx) => {
await tx.discount.update({ where: { id }, data: this.toRow(input) });
await this.writeRelations(tx, id, input);
});
this.audit.record({
actorUserId,
action: 'discount.update',
resourceType: 'Discount',
resourceId: id,
changes: { code: input.code ?? null, isActive: input.isActive },
});
return this.getById(id);
}
/**
* Soft delete.
*
* `DiscountRedemption.discount` is `onDelete: Restrict` on purpose — an order
* that received a discount must keep pointing at the thing it received. So a
* removed discount stops applying rather than ceasing to exist.
*/
async remove(id: string, actorUserId: string): Promise<void> {
await this.prisma.discount.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
});
this.audit.record({
actorUserId,
action: 'discount.delete',
resourceType: 'Discount',
resourceId: id,
changes: {},
});
}
private toRow(input: DiscountInput) {
return {
// Uppercased so lookup is case-insensitive without a functional index.
code: input.trigger === 'CODE' ? (input.code ?? null) : null,
trigger: input.trigger,
type: input.type,
scope: input.scope,
value: input.value,
minSubtotalAmount: input.minSubtotalAmount ?? null,
startsAt: input.startsAt ? new Date(input.startsAt) : null,
endsAt: input.endsAt ? new Date(input.endsAt) : null,
isActive: input.isActive,
usageLimit: input.usageLimit ?? null,
stackable: input.stackable,
priority: input.priority,
};
}
private async writeRelations(
tx: Prisma.TransactionClient,
discountId: string,
input: DiscountInput,
): Promise<void> {
for (const locale of LOCALES) {
const fields = input.translations[locale];
if (!fields) continue;
await tx.discountTranslation.upsert({
where: { discountId_locale: { discountId, locale: toDbLocale(locale) } },
update: { name: fields.name, description: fields.description ?? null },
create: {
discountId,
locale: toDbLocale(locale),
name: fields.name,
description: fields.description ?? null,
},
});
}
await tx.discountProduct.deleteMany({ where: { discountId } });
await tx.discountCollection.deleteMany({ where: { discountId } });
if (input.productIds.length > 0) {
await tx.discountProduct.createMany({
data: input.productIds.map((productId) => ({ discountId, productId })),
skipDuplicates: true,
});
}
if (input.collectionIds.length > 0) {
await tx.discountCollection.createMany({
data: input.collectionIds.map((collectionId) => ({ discountId, collectionId })),
skipDuplicates: true,
});
}
}
}
type DiscountRow = Prisma.DiscountGetPayload<{ select: typeof detailSelect }>;
function toAdminDiscount(row: DiscountRow): AdminDiscount {
return {
id: row.id,
code: row.code,
trigger: row.trigger,
type: row.type,
scope: row.scope,
value: row.value,
translations: Object.fromEntries(
row.translations.map((translation) => [
translation.locale.toLowerCase(),
{ name: translation.name, description: translation.description },
]),
),
minSubtotalAmount: row.minSubtotalAmount,
startsAt: row.startsAt?.toISOString() ?? null,
endsAt: row.endsAt?.toISOString() ?? null,
isActive: row.isActive,
usageLimit: row.usageLimit,
usageCount: row.usageCount,
stackable: row.stackable,
priority: row.priority,
productIds: row.products.map((product) => product.productId),
collectionIds: row.collections.map((collection) => collection.collectionId),
createdAt: row.createdAt.toISOString(),
};
}
@@ -0,0 +1,84 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminDiscount,
type AuthenticatedActor,
type OffsetPaginated,
} from '@sport/types';
import {
discountInputSchema,
discountListQuerySchema,
type DiscountInput,
type DiscountListQuery,
} 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 { PromotionsAdminService } from './promotions-admin.service';
/**
* Promotions and coupons are one resource with one permission surface.
*
* `PROMOTION_MANAGE` covers both, because they are the same entity: a coupon is
* a promotion that needs a code typed. Splitting the permission would imply a
* separation the data model deliberately does not have.
*/
@ApiTags('admin/discounts')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/discounts')
export class PromotionsAdminController {
constructor(private readonly service: PromotionsAdminService) {}
@Get()
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
@ApiOperation({ summary: 'Discounts, active first' })
list(
@Query(new ZodValidationPipe(discountListQuerySchema)) query: DiscountListQuery,
): Promise<OffsetPaginated<AdminDiscount>> {
return this.service.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
@ApiOperation({ summary: 'One discount' })
getById(@Param('id') id: string): Promise<AdminDiscount> {
return this.service.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
@ApiOperation({ summary: 'Create a promotion or coupon' })
create(
@Body(new ZodValidationPipe(discountInputSchema)) body: DiscountInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminDiscount> {
return this.service.create(body, actor.userId);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
@ApiOperation({ summary: 'Update a discount' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(discountInputSchema)) body: DiscountInput,
@CurrentActor() actor: AuthenticatedActor,
): Promise<AdminDiscount> {
return this.service.update(id, body, actor.userId);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.PROMOTION_MANAGE)
@ApiOperation({ summary: 'Retire a discount; redemptions keep pointing at it' })
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
return this.service.remove(id, actor.userId);
}
}
@@ -1,19 +1,23 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PromotionsAdminService } from './promotions-admin.service';
import { PromotionsAdminController } from './promotions.controller';
import { PromotionsService } from './promotions.service';
/** /**
* PromotionsModule — boundary declared, implementation pending. * PromotionsModule — owns `discounts` and everything hanging off it.
* *
* Owns (exclusively): `promotions`, `promotion_rules` — milestone 3 * Promotions and coupons are the same entity with different triggers, so they
* are one module with one rule engine. Two engines would have to agree about
* stacking, rounding and limits, and they would not for long.
* *
* Automatic, cart-level discounts. Pricing is calculated in one place so storefront, admin and invoices can never disagree. * The arithmetic lives in `discount-engine.ts` as a pure function: no clock, no
* * database. This service resolves eligibility — which is precisely the part
* Anatomy once implemented (see ../README.md): * that needs both — and hands a snapshot to the engine.
* promotions.module.ts wiring only
* promotions.controller.ts HTTP surface, no logic
* promotions.service.ts business rules
* promotions.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
controllers: [PromotionsAdminController],
providers: [PromotionsService, PromotionsAdminService],
exports: [PromotionsService],
})
export class PromotionsModule {} export class PromotionsModule {}
@@ -0,0 +1,214 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
DISCOUNT_REJECTIONS,
type CurrencyCode,
type Locale,
type RejectedDiscount,
} from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { toDbLocale } from '@/common/i18n';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import {
evaluateDiscounts,
type DiscountCandidate,
type EvaluationLine,
type EvaluationResult,
} from './discount-engine';
@Injectable()
export class PromotionsService {
private readonly logger = new Logger(PromotionsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Works out what a cart is entitled to.
*
* Two stages, deliberately separated: this method decides *eligibility* —
* which discounts exist, are live, are within their window and have uses left
* — and the pure engine decides *arithmetic*. Anything involving a clock or a
* database is resolved here so the money maths stays testable without either.
*/
async evaluate(
lines: readonly EvaluationLine[],
codes: readonly string[],
currency: CurrencyCode,
locale: Locale,
): Promise<EvaluationResult> {
const normalised = unique(codes.map((code) => code.trim().toUpperCase()).filter(Boolean));
const now = new Date();
const rows = await this.prisma.discount.findMany({
where: {
deletedAt: null,
isActive: true,
OR: [{ trigger: 'AUTOMATIC' }, { code: { in: normalised } }],
},
select: {
id: true,
code: true,
trigger: true,
type: true,
scope: true,
value: true,
minSubtotalAmount: true,
startsAt: true,
endsAt: true,
usageLimit: true,
usageCount: true,
stackable: true,
priority: true,
translations: { where: { locale: toDbLocale(locale) }, select: { name: true } },
products: { select: { productId: true } },
collections: { select: { collectionId: true } },
},
});
const found = new Set(rows.map((row) => row.code).filter(Boolean) as string[]);
const rejected: RejectedDiscount[] = normalised
.filter((code) => !found.has(code))
.map((code) => ({ code, reason: DISCOUNT_REJECTIONS.NOT_FOUND, minimumSubtotal: null }));
const candidates: DiscountCandidate[] = [];
for (const row of rows) {
// Window and usage are checked here rather than in the engine because
// both depend on state the engine deliberately cannot see.
if (row.startsAt && row.startsAt > now) {
pushIfCoded(rejected, row.code, DISCOUNT_REJECTIONS.NOT_STARTED);
continue;
}
if (row.endsAt && row.endsAt < now) {
pushIfCoded(rejected, row.code, DISCOUNT_REJECTIONS.EXPIRED);
continue;
}
if (row.usageLimit !== null && row.usageCount >= row.usageLimit) {
pushIfCoded(rejected, row.code, DISCOUNT_REJECTIONS.USAGE_LIMIT_REACHED);
continue;
}
candidates.push({
id: row.id,
code: row.code,
// Falls back to the code so an untranslated discount still names itself
// on the order summary rather than rendering blank.
name: row.translations[0]?.name ?? row.code ?? 'Discount',
type: row.type,
scope: row.scope,
value: row.value,
minSubtotalAmount: row.minSubtotalAmount,
stackable: row.stackable,
priority: row.priority,
productIds: await this.resolveTargets(row.id, row.products, row.collections),
});
}
return evaluateDiscounts(candidates, lines, currency, rejected);
}
/**
* Claims one use of each discount and records what it granted.
*
* The increment is a conditional UPDATE guarded on the limit, for exactly the
* reason stock reservation is: read-then-write lets two shoppers redeem the
* last use of a code simultaneously and both succeed. Zero rows affected
* means the code ran out between evaluation and checkout, and the order must
* not silently receive a discount nobody is counting.
*/
async redeem(
tx: Prisma.TransactionClient,
orderId: string,
applied: readonly { id: string; code: string | null; amount: { amount: number } }[],
): Promise<void> {
for (const discount of applied) {
const claimed = await tx.$executeRaw`
UPDATE discounts
SET usage_count = usage_count + 1
WHERE id = ${discount.id}::uuid
AND (usage_limit IS NULL OR usage_count < usage_limit)
`;
if (claimed === 0) {
/**
* A domain conflict, not a server fault.
*
* Thrown as a plain Error this surfaced as "Something went wrong on our
* side" — which tells a shopper nothing and blames the wrong party. The
* order is rolled back deliberately: re-pricing it upward without the
* discount would charge them more than the total they agreed to.
*/
throw AppException.conflict(
discount.code
? `The code ${discount.code} was just used up. Remove it and review your total.`
: 'A promotion in your bag is no longer available. Review your total and try again.',
);
}
await tx.discountRedemption.create({
data: {
discountId: discount.id,
orderId,
amount: discount.amount.amount,
code: discount.code,
},
});
}
}
/** Hands uses back when an order that consumed them is cancelled. */
async release(tx: Prisma.TransactionClient, orderId: string): Promise<void> {
const redemptions = await tx.discountRedemption.findMany({
where: { orderId },
select: { discountId: true },
});
for (const redemption of redemptions) {
await tx.$executeRaw`
UPDATE discounts
SET usage_count = GREATEST(usage_count - 1, 0)
WHERE id = ${redemption.discountId}::uuid
`;
}
}
/**
* Product ids a discount targets, expanding collections into their members.
*
* Expanded at evaluation time rather than stored, so adding a product to a
* targeted collection takes effect immediately instead of when someone
* remembers to re-save the discount.
*/
private async resolveTargets(
discountId: string,
products: readonly { productId: string }[],
collections: readonly { collectionId: string }[],
): Promise<string[]> {
const ids = products.map((row) => row.productId);
if (collections.length === 0) return ids;
const members = await this.prisma.productCollection.findMany({
where: { collectionId: { in: collections.map((row) => row.collectionId) } },
select: { productId: true },
});
this.logger.debug(`Discount ${discountId} targets ${members.length} collection product(s)`);
return unique([...ids, ...members.map((row) => row.productId)]);
}
}
function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}
function pushIfCoded(
rejected: RejectedDiscount[],
code: string | null,
reason: RejectedDiscount['reason'],
): void {
if (code) rejected.push({ code, reason, minimumSubtotal: null });
}
@@ -7,4 +7,5 @@
* *
* Keep it narrow: each export is a promise to the rest of the codebase. * Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { PromotionsService } from '../promotions.service';
export type { EvaluationLine, EvaluationResult } from '../discount-engine';
+1 -1
View File
@@ -7,4 +7,4 @@
* *
* Keep it narrow: each export is a promise to the rest of the codebase. * Keep it narrow: each export is a promise to the rest of the codebase.
*/ */
export {}; export { ReviewsService } from '../reviews.service';
@@ -0,0 +1,78 @@
import { REVIEW_RATING_MAX, REVIEW_RATING_MIN } from '@sport/types';
/**
* The rating aggregate, extracted so it can be pinned without a database.
*
* These four lines decide the number under every product name in the store. A
* mistake here is not a crash — it is a product quietly displaying 4.5 stars it
* did not earn, on every page, until somebody notices by eye.
*/
export function summarise(ratings: readonly number[]): {
average: number;
count: number;
distribution: { rating: number; count: number }[];
} {
const count = ratings.length;
const sum = ratings.reduce((total, rating) => total + rating, 0);
const distribution: { rating: number; count: number }[] = [];
for (let rating = REVIEW_RATING_MAX; rating >= REVIEW_RATING_MIN; rating -= 1) {
distribution.push({ rating, count: ratings.filter((value) => value === rating).length });
}
return {
average: count === 0 ? 0 : Math.round((sum / count) * 10) / 10,
count,
distribution,
};
}
describe('review summary', () => {
it('averages to one decimal place', () => {
expect(summarise([5, 4, 4]).average).toBe(4.3);
});
it('does not divide by zero on an unreviewed product', () => {
const summary = summarise([]);
expect(summary.average).toBe(0);
expect(summary.count).toBe(0);
});
it('reports every star level, including the empty ones', () => {
// A distribution with gaps renders as a bar chart with missing bars rather
// than bars at zero, which reads as a broken chart.
const summary = summarise([5, 5, 1]);
expect(summary.distribution).toEqual([
{ rating: 5, count: 2 },
{ rating: 4, count: 0 },
{ rating: 3, count: 0 },
{ rating: 2, count: 0 },
{ rating: 1, count: 1 },
]);
});
it('orders the distribution from best to worst', () => {
expect(summarise([3]).distribution.map((bucket) => bucket.rating)).toEqual([5, 4, 3, 2, 1]);
});
it('distinguishes a polarised product from a mediocre one', () => {
// Both average 3.0. The distribution is the only thing that tells a shopper
// which one has a sizing problem, which is why it is sent alongside.
const mediocre = summarise([3, 3, 3, 3]);
const polarised = summarise([5, 5, 1, 1]);
expect(mediocre.average).toBe(polarised.average);
expect(mediocre.distribution).not.toEqual(polarised.distribution);
});
it('rounds half up so 4.25 does not read as 4.2', () => {
expect(summarise([5, 4, 4, 4]).average).toBe(4.3);
});
it('keeps a whole number whole', () => {
// 4.0 rather than 4 matters for display, but the value must still equal 4.
expect(summarise([4, 4, 4])).toMatchObject({ average: 4, count: 3 });
});
});
@@ -0,0 +1,110 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
PERMISSIONS,
TOKEN_AUDIENCES,
type AdminReview,
type AuthenticatedActor,
type Locale,
type OffsetPaginated,
type ReviewSummary,
type ReviewableItem,
type StorefrontReview,
} from '@sport/types';
import {
adminReviewListQuerySchema,
moderateReviewSchema,
reviewListQuerySchema,
submitReviewSchema,
type AdminReviewListQuery,
type ModerateReviewInput,
type ReviewListQuery,
type SubmitReviewInput,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { Public } from '@/common/decorators/public.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { RequestLocale } from '@/common/i18n';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { ReviewsService } from './reviews.service';
@ApiTags('reviews')
@Controller('reviews')
export class ReviewsController {
constructor(private readonly service: ReviewsService) {}
@Get('product/:productId')
@Public()
@ApiOperation({ summary: 'Approved reviews for a product, with a rating summary' })
listForProduct(
@Param('productId') productId: string,
@Query(new ZodValidationPipe(reviewListQuerySchema)) query: ReviewListQuery,
): Promise<OffsetPaginated<StorefrontReview> & { summary: ReviewSummary }> {
return this.service.listForProduct(productId, query);
}
/**
* What an order entitles its buyer to review.
*
* A POST despite reading nothing: the email is proof of ownership, and
* proof does not belong in a query string where it lands in server logs,
* browser history and the Referer header of every asset on the page.
*/
@Post('reviewable')
@Public()
@ApiOperation({ summary: 'Items from one order that may be reviewed' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
listReviewable(
@Body() body: { orderId?: string; email?: string },
@RequestLocale() locale: Locale,
): Promise<readonly ReviewableItem[]> {
return this.service.listReviewable(body.orderId ?? '', body.email ?? '', locale);
}
@Post()
@Public()
@ApiOperation({ summary: 'Submit a review for a purchased item' })
@ApiQuery({ name: 'locale', required: false, enum: ['vi', 'en'] })
submit(
@Body(new ZodValidationPipe(submitReviewSchema)) body: SubmitReviewInput,
@RequestLocale() locale: Locale,
): Promise<ReviewableItem[]> {
return this.service.submit(body, locale);
}
}
@ApiTags('admin/reviews')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/reviews')
export class ReviewsAdminController {
constructor(private readonly service: ReviewsService) {}
@Get()
@RequirePermissions(PERMISSIONS.REVIEW_MODERATE)
@ApiOperation({ summary: 'Moderation queue, pending first and oldest first' })
list(
@Query(new ZodValidationPipe(adminReviewListQuerySchema)) query: AdminReviewListQuery,
@RequestLocale() locale: Locale,
): Promise<OffsetPaginated<AdminReview>> {
return this.service.listForAdmin(query, locale);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.REVIEW_MODERATE)
@ApiOperation({ summary: 'Approve or reject a review' })
moderate(
@Param('id') id: string,
@Body(new ZodValidationPipe(moderateReviewSchema)) body: ModerateReviewInput,
@CurrentActor() actor: AuthenticatedActor,
@RequestLocale() locale: Locale,
): Promise<AdminReview> {
return this.service.moderate(id, body, actor.userId, locale);
}
}
+18 -12
View File
@@ -1,19 +1,25 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ReviewsAdminController, ReviewsController } from './reviews.controller';
import { ReviewsRepository } from './reviews.repository';
import { ReviewsService } from './reviews.service';
/** /**
* ReviewsModule — boundary declared, implementation pending. * ReviewsModule — owns `reviews` and the rating aggregate on `products`.
* *
* Owns (exclusively): `reviews` — milestone 3 * A review is anchored to an order line, which is what makes "verified
* purchase" structural rather than a flag: you cannot review what you did not
* buy, because there is no row to attach the review to.
* *
* Verified-purchase reviews with moderation. Rating aggregates are denormalised onto the product read model, never computed per page view. * It writes two columns it does not own — `products.rating_sum` and
* * `rating_count` — and is the *only* writer of them, in the same way
* Anatomy once implemented (see ../README.md): * ProductsService is the only writer of the price projection. The alternative,
* reviews.module.ts wiring only * computing an average per page view, is a scan of every review on the busiest
* reviews.controller.ts HTTP surface, no logic * query in the catalog.
* reviews.service.ts business rules
* reviews.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/ */
@Module({}) @Module({
controllers: [ReviewsController, ReviewsAdminController],
providers: [ReviewsService, ReviewsRepository],
exports: [ReviewsService],
})
export class ReviewsModule {} export class ReviewsModule {}
@@ -0,0 +1,182 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const adminSelect = {
id: true,
status: true,
rating: true,
title: true,
body: true,
authorName: true,
productId: true,
moderationNote: true,
moderatedAt: true,
createdAt: true,
moderatedBy: { select: { firstName: true, lastName: true } },
product: { select: { slug: true, translations: { select: { locale: true, name: true } } } },
orderLine: {
select: {
productName: true,
variantTitle: true,
order: { select: { id: true, number: true } },
},
},
} as const;
export type AdminReviewRow = Prisma.ReviewGetPayload<{ select: typeof adminSelect }>;
/** The only file in this module that touches Prisma. */
@Injectable()
export class ReviewsRepository {
constructor(private readonly prisma: PrismaService) {}
// ---- Storefront ----------------------------------------------------------
findApproved(
productId: string,
skip: number,
take: number,
orderBy: Prisma.ReviewOrderByWithRelationInput[],
) {
return this.prisma.review.findMany({
where: { productId, status: 'APPROVED' },
orderBy,
skip,
take,
select: {
id: true,
rating: true,
title: true,
body: true,
authorName: true,
createdAt: true,
},
});
}
countApproved(productId: string): Promise<number> {
return this.prisma.review.count({ where: { productId, status: 'APPROVED' } });
}
/**
* How many approved reviews sit at each star level.
*
* Grouped in the database rather than by counting a fetched page: the
* distribution describes every review, not the twenty currently on screen.
*/
distribution(productId: string) {
return this.prisma.review.groupBy({
by: ['rating'],
where: { productId, status: 'APPROVED' },
_count: { _all: true },
});
}
// ---- Submission ----------------------------------------------------------
/**
* The order, its lines, and any reviews already written against them.
*
* One query rather than three: everything the submission path needs to decide
* both "may they review this" and "have they already".
*/
findOrderForReview(orderId: string) {
return this.prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
email: true,
status: true,
lines: {
select: {
id: true,
productName: true,
variantTitle: true,
imageUrl: true,
variant: {
select: {
product: {
select: {
id: true,
slug: true,
translations: { select: { locale: true, name: true, slug: true } },
},
},
},
},
review: { select: { id: true, status: true, rating: true } },
},
orderBy: { createdAt: 'asc' },
},
},
});
}
create(data: Prisma.ReviewUncheckedCreateInput) {
return this.prisma.review.create({ data, select: { id: true, productId: true } });
}
// ---- Moderation ----------------------------------------------------------
findForAdmin(where: Prisma.ReviewWhereInput, skip: number, take: number) {
return this.prisma.review.findMany({
where,
// Oldest first: a moderation queue is a queue. Newest-first buries the
// review that has been waiting longest under everything since.
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
skip,
take,
select: adminSelect,
});
}
countForAdmin(where: Prisma.ReviewWhereInput): Promise<number> {
return this.prisma.review.count({ where });
}
findById(id: string) {
return this.prisma.review.findUnique({
where: { id },
select: { id: true, productId: true, status: true },
});
}
moderate(id: string, data: Prisma.ReviewUncheckedUpdateInput) {
return this.prisma.review.update({ where: { id }, data, select: { id: true } });
}
getByIdForAdmin(id: string) {
return this.prisma.review.findUnique({ where: { id }, select: adminSelect });
}
// ---- Aggregate -----------------------------------------------------------
/**
* Rewrites a product's rating aggregate from its approved reviews.
*
* A single statement, and deliberately a recompute rather than an increment.
* Incrementing is faster and wrong in the cases that matter: a review edited
* from 5 to 2, a rejection reversed, a moderator undoing a decision. Each
* would need its own compensating delta, and one missed path leaves a product
* displaying a rating no review supports — with no way to notice.
*
* `COALESCE` matters: SUM over no rows is NULL, and NULL would violate the
* NOT NULL on both columns.
*/
recomputeRating(productId: string): Promise<number> {
return this.prisma.$executeRaw`
UPDATE products p
SET rating_sum = COALESCE(agg.total, 0),
rating_count = COALESCE(agg.n, 0)
FROM (
SELECT SUM(rating)::int AS total, COUNT(*)::int AS n
FROM reviews
WHERE product_id = ${productId}::uuid
AND status = 'APPROVED'
) agg
WHERE p.id = ${productId}::uuid
`;
}
}
@@ -0,0 +1,342 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
REVIEW_RATING_MAX,
REVIEW_RATING_MIN,
type AdminReview,
type Locale,
type OffsetPaginated,
type ReviewSummary,
type ReviewableItem,
type StorefrontReview,
} from '@sport/types';
import type {
AdminReviewListQuery,
ModerateReviewInput,
ReviewListQuery,
SubmitReviewInput,
} from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { coalesceRequired, pickTranslation } from '@/common/i18n';
import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event';
import { EventBusService } from '@/infrastructure/events/event-bus.service';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { ReviewsRepository, type AdminReviewRow } from './reviews.repository';
@Injectable()
export class ReviewsService {
private readonly logger = new Logger(ReviewsService.name);
constructor(
private readonly repository: ReviewsRepository,
private readonly audit: AuditService,
private readonly events: EventBusService,
private readonly redis: RedisService,
) {}
// ---- Storefront ----------------------------------------------------------
async listForProduct(
productId: string,
query: ReviewListQuery,
): Promise<OffsetPaginated<StorefrontReview> & { summary: ReviewSummary }> {
const orderBy = ORDER_BY[query.sort];
const [rows, totalItems, groups] = await Promise.all([
this.repository.findApproved(
productId,
(query.page - 1) * query.perPage,
query.perPage,
orderBy,
),
this.repository.countApproved(productId),
this.repository.distribution(productId),
]);
const counts = new Map(groups.map((group) => [group.rating, group._count._all]));
const sum = groups.reduce((total, group) => total + group.rating * group._count._all, 0);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => ({
id: row.id,
rating: row.rating,
title: row.title,
body: row.body,
authorName: row.authorName,
createdAt: row.createdAt.toISOString(),
// Structurally true: there is no way into this table without an order
// line. See the Review model comment.
isVerifiedPurchase: true,
})),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
summary: {
// Rounded to one decimal for display; the exact figure stays in the
// sum/count pair on the product, so nothing is lost.
average: totalItems === 0 ? 0 : Math.round((sum / totalItems) * 10) / 10,
count: totalItems,
// Every star level is present even at zero — a distribution with gaps
// renders as a bar chart with missing bars rather than empty ones.
distribution: descendingStars().map((rating) => ({
rating,
count: counts.get(rating) ?? 0,
})),
},
};
}
/**
* What one order entitles its buyer to review.
*
* The email is checked here rather than trusted from the client: the order id
* alone is a capability (a UUIDv7 in a URL), and pairing it with the email on
* the order is the same bar the guest order lookup already sets.
*/
async listReviewable(
orderId: string,
email: string,
locale: Locale,
): Promise<readonly ReviewableItem[]> {
const order = await this.loadOrderFor(orderId, email);
return order.lines.flatMap((line) => {
const product = line.variant?.product;
// A line whose variant was hard-deleted has nothing to review. It stays
// on the order (the purchase happened) but cannot produce a review,
// because a review has to point at a product page.
if (!product) return [];
const translation = pickTranslation(product.translations, locale);
return [
{
orderLineId: line.id,
productId: product.id,
productSlug: coalesceRequired(translation?.slug, product.slug),
productName: coalesceRequired(translation?.name, line.productName),
variantTitle: line.variantTitle,
imageUrl: line.imageUrl,
reviewId: line.review?.id ?? null,
reviewStatus: line.review?.status ?? null,
rating: line.review?.rating ?? null,
},
];
});
}
async submit(input: SubmitReviewInput, locale: Locale): Promise<ReviewableItem[]> {
const order = await this.loadOrderFor(input.orderId, input.email);
const line = order.lines.find((candidate) => candidate.id === input.orderLineId);
if (!line) {
// Deliberately the same shape of error as a bad order: confirming that a
// line id exists on someone else's order is a small leak, but a free one.
throw AppException.notFound('Order line');
}
if (line.review) {
throw AppException.conflict('You have already reviewed this item.');
}
const product = line.variant?.product;
if (!product) {
throw AppException.conflict('This item is no longer available to review.');
}
const created = await this.repository.create({
productId: product.id,
orderLineId: line.id,
rating: input.rating,
title: input.title ?? null,
body: input.body ?? null,
authorName: input.authorName,
// PENDING by default — see the moderation note on `moderate()`.
});
this.events.publish(DOMAIN_EVENTS.REVIEW_SUBMITTED, {
reviewId: created.id,
productId: created.productId,
rating: input.rating,
});
this.logger.log(`Review ${created.id} submitted for product ${created.productId}`);
// Returned rather than a bare 201: the form needs to re-render as "thanks,
// awaiting approval", and the client should not have to guess that state.
return [...(await this.listReviewable(input.orderId, input.email, locale))];
}
// ---- Moderation ----------------------------------------------------------
async listForAdmin(
query: AdminReviewListQuery,
locale: Locale,
): Promise<OffsetPaginated<AdminReview>> {
const where: Prisma.ReviewWhereInput = {
...(query.status ? { status: query.status } : {}),
...(query.q
? {
OR: [
{ title: { contains: query.q, mode: 'insensitive' } },
{ body: { contains: query.q, mode: 'insensitive' } },
{ authorName: { contains: query.q, mode: 'insensitive' } },
],
}
: {}),
};
const [rows, totalItems] = await Promise.all([
this.repository.findForAdmin(where, (query.page - 1) * query.perPage, query.perPage),
this.repository.countForAdmin(where),
]);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: rows.map((row) => toAdminReview(row, locale)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
/**
* Approves or rejects a review, then rebuilds the product's aggregate.
*
* Moderation is required before publication rather than after, because the
* alternative is that the first person to see abuse on a product page is a
* customer. The aggregate is recomputed on every decision including
* rejection: un-approving a review has to take its stars back out.
*/
async moderate(
id: string,
input: ModerateReviewInput,
actorUserId: string,
locale: Locale,
): Promise<AdminReview> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Review');
await this.repository.moderate(id, {
status: input.status,
moderationNote: input.note ?? null,
moderatedAt: new Date(),
moderatedByUserId: actorUserId,
});
await this.repository.recomputeRating(existing.productId);
/**
* Drop the catalog cache, or the decision is invisible for five minutes.
*
* `productDetail` is cached for 300s and the product card carries the
* rating too, so without this a moderator approves a review, reloads the
* product page, sees the old figure and reasonably concludes the button is
* broken. `cache-keys.ts` states the contract — invalidated on write, TTL
* as a safety net — and a rating change is a write to the read model.
*
* The whole catalog prefix rather than one product's keys: the product
* appears in listings and collection pages under fingerprinted keys that
* cannot be enumerated from a product id. This is the same hammer
* ProductsAdminService.afterWrite uses, for the same reason.
*/
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
this.logger.log(`Review ${id} ${input.status.toLowerCase()}; dropped ${dropped} cache key(s)`);
this.audit.record({
actorUserId,
action: `review.${input.status.toLowerCase()}`,
resourceType: 'Review',
resourceId: id,
changes: { from: existing.status, to: input.status },
});
const row = await this.repository.getByIdForAdmin(id);
if (!row) throw AppException.notFound('Review');
return toAdminReview(row, locale);
}
// ---- internals -----------------------------------------------------------
/**
* Loads an order, refusing unless the email matches the one on it.
*
* Case-insensitive because an email address is, and because a shopper
* retyping their address with different capitalisation is not an intruder.
*/
private async loadOrderFor(orderId: string, email: string) {
const order = await this.repository.findOrderForReview(orderId);
if (!order || order.email.toLowerCase() !== email.trim().toLowerCase()) {
// One error for "no such order" and "wrong email", so this endpoint
// cannot be used to test whether an order id exists.
throw AppException.notFound('Order');
}
if (order.status === 'CANCELLED') {
throw AppException.conflict('This order was cancelled, so its items cannot be reviewed.');
}
return order;
}
}
const ORDER_BY: Record<ReviewListQuery['sort'], Prisma.ReviewOrderByWithRelationInput[]> = {
newest: [{ createdAt: 'desc' }],
// Ties broken by recency so the order is total — otherwise page 2 can repeat
// a review that page 1 already showed.
rating_desc: [{ rating: 'desc' }, { createdAt: 'desc' }],
rating_asc: [{ rating: 'asc' }, { createdAt: 'desc' }],
};
function descendingStars(): number[] {
const stars: number[] = [];
for (let rating = REVIEW_RATING_MAX; rating >= REVIEW_RATING_MIN; rating -= 1) {
stars.push(rating);
}
return stars;
}
function toAdminReview(row: AdminReviewRow, locale: Locale): AdminReview {
const translation = pickTranslation(row.product.translations, locale);
const moderator = row.moderatedBy;
return {
id: row.id,
status: row.status,
rating: row.rating,
title: row.title,
body: row.body,
authorName: row.authorName,
productId: row.productId,
// The order line's snapshot is the fallback: it is what the buyer actually
// saw when they bought, which is the right thing to show a moderator.
productName: coalesceRequired(translation?.name, row.orderLine.productName),
productSlug: row.product.slug,
variantTitle: row.orderLine.variantTitle,
orderNumber: row.orderLine.order.number,
orderId: row.orderLine.order.id,
moderationNote: row.moderationNote,
moderatedAt: row.moderatedAt?.toISOString() ?? null,
moderatedByName: moderator ? `${moderator.firstName} ${moderator.lastName}`.trim() : null,
createdAt: row.createdAt.toISOString(),
};
}
+2
View File
@@ -24,6 +24,8 @@
"next-intl": "^4.13.6", "next-intl": "^4.13.6",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-markdown": "catalog:",
"remark-gfm": "catalog:",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"zod": "catalog:", "zod": "catalog:",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -0,0 +1,112 @@
import type { Metadata } from 'next';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
import type { ContentDetail, Locale } from '@sport/types';
import { Markdown } from '@/components/commerce/markdown';
import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> };
async function fetchPost(locale: Locale, slug: string): Promise<ContentDetail | null> {
try {
return await getServerApi().content.getPost(locale, slug, { next: { revalidate: 300 } });
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const post = await fetchPost(locale as Locale, slug);
if (!post) return {};
const languages = Object.fromEntries(
Object.entries(post.alternateSlugs ?? {}).map(([alt, altSlug]) => [
alt,
alt === 'vi' ? `/blog/${altSlug}` : `/${alt}/blog/${altSlug}`,
]),
);
return {
title: post.seo.metaTitle ?? post.title,
description: post.seo.metaDescription ?? undefined,
alternates: {
canonical: locale === 'vi' ? `/blog/${post.slug}` : `/${locale}/blog/${post.slug}`,
languages,
},
openGraph: {
title: post.title,
description: post.seo.metaDescription ?? undefined,
images: post.coverImageUrl ? [{ url: post.coverImageUrl }] : undefined,
type: 'article',
publishedTime: post.publishedAt ?? undefined,
},
};
}
export default async function PostPage({ params }: PageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const post = await fetchPost(locale as Locale, slug);
if (!post) notFound();
// The API resolves a slug from any locale, so `/en/blog/<vi-slug>` finds the
// post. Send it to this locale's canonical URL rather than serving one entry
// at two addresses. The locale-aware redirect, never next/navigation's.
if (post.slug !== slug) {
redirect({ href: routes.post(post.slug), locale });
}
const t = await getTranslations('blog');
const format = await getFormatter();
return (
<article className="max-w-page px-gutter mx-auto py-12">
<Link href={routes.blog()} className="text-ink-500 hover:text-ink-950 text-xs">
← {t('backToJournal')}
</Link>
<header className="mt-6 max-w-prose">
{post.publishedAt ? (
<time
dateTime={post.publishedAt}
className="text-ink-400 text-xs uppercase tracking-widest"
>
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'long' })}
</time>
) : null}
<h1 className="mt-3 text-3xl font-black uppercase leading-tight tracking-tight sm:text-4xl">
{post.title}
</h1>
{post.authorName ? (
<p className="text-ink-500 mt-3 text-sm">{t('by', { name: post.authorName })}</p>
) : null}
</header>
{post.coverImageUrl ? (
<div className="bg-ink-100 relative mt-8 aspect-[16/9] w-full overflow-hidden">
<Image
src={post.coverImageUrl}
alt=""
fill
sizes="(min-width: 1280px) 1200px, 100vw"
priority
className="object-cover"
/>
</div>
) : null}
<div className="mt-10">
<Markdown>{post.body}</Markdown>
</div>
</article>
);
}
@@ -1,21 +1,87 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import Image from 'next/image';
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold'; import type { ContentSummary, Locale } from '@sport/types';
import { Link } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string }> }; type PageProps = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.blog' }); const t = await getTranslations({ locale, namespace: 'blog' });
return { title: t('title') }; return { title: t('title'), description: t('intro') };
} }
export default async function BlogPage({ params }: PageProps) { export default async function BlogPage({ params }: PageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations('placeholder.blog'); const t = await getTranslations('blog');
const format = await getFormatter();
return <PageScaffold title={t('title')} description={t('body')} milestone="M7 — content" />; let posts: readonly ContentSummary[] = [];
try {
const result = await getServerApi().content.listPosts(
locale as Locale,
{ perPage: 24 },
{ next: { revalidate: 300 } },
);
posts = result.items;
} catch {
// An empty journal beats an error page; the rest of the store still works.
}
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="max-w-2xl">
<h1 className="text-3xl font-black uppercase tracking-tight sm:text-4xl">{t('title')}</h1>
<p className="text-ink-500 mt-3 text-sm">{t('intro')}</p>
</header>
{posts.length === 0 ? (
<p className="text-ink-500 mt-12 text-sm">{t('empty')}</p>
) : (
<ul className="mt-12 grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<li key={post.id}>
<Link href={routes.post(post.slug)} className="group block">
<div className="bg-ink-100 relative aspect-[4/3] overflow-hidden">
{post.coverImageUrl ? (
<Image
src={post.coverImageUrl}
alt=""
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : null}
</div>
{post.publishedAt ? (
<time
dateTime={post.publishedAt}
className="text-ink-400 mt-4 block text-xs uppercase tracking-widest"
>
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'medium' })}
</time>
) : null}
<h2 className="mt-2 text-base font-bold leading-snug group-hover:underline">
{post.title}
</h2>
{post.excerpt ? (
<p className="text-ink-500 mt-2 line-clamp-3 text-sm">{post.excerpt}</p>
) : null}
</Link>
</li>
))}
</ul>
)}
</div>
);
} }
@@ -0,0 +1,63 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { setRequestLocale } from 'next-intl/server';
import type { ContentDetail, Locale } from '@sport/types';
import { Markdown } from '@/components/commerce/markdown';
import { redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> };
async function fetchPage(locale: Locale, slug: string): Promise<ContentDetail | null> {
try {
return await getServerApi().content.getPage(locale, slug, { next: { revalidate: 300 } });
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const page = await fetchPage(locale as Locale, slug);
if (!page) return {};
const languages = Object.fromEntries(
Object.entries(page.alternateSlugs ?? {}).map(([alt, altSlug]) => [
alt,
alt === 'vi' ? `/pages/${altSlug}` : `/${alt}/pages/${altSlug}`,
]),
);
return {
title: page.seo.metaTitle ?? page.title,
description: page.seo.metaDescription ?? undefined,
alternates: {
canonical: locale === 'vi' ? `/pages/${page.slug}` : `/${locale}/pages/${page.slug}`,
languages,
},
};
}
export default async function ContentPage({ params }: PageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const page = await fetchPage(locale as Locale, slug);
if (!page) notFound();
if (page.slug !== slug) {
redirect({ href: routes.page(page.slug), locale });
}
return (
<div className="max-w-page px-gutter mx-auto py-12">
<h1 className="text-3xl font-black uppercase tracking-tight sm:text-4xl">{page.title}</h1>
<div className="mt-8">
<Markdown>{page.body}</Markdown>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALES, type Locale } from '@sport/types'; import { LOCALES, type Locale } from '@sport/types';
import { ProductDetail } from '@/components/commerce/product-detail'; import { ProductDetail } from '@/components/commerce/product-detail';
import { ProductReviews } from '@/components/commerce/product-reviews';
import { Link, redirect } from '@/i18n/navigation'; import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api'; import { getServerApi } from '@/lib/api';
import { fetchProduct } from '@/lib/catalog'; import { fetchProduct } from '@/lib/catalog';
@@ -130,6 +131,8 @@ export default async function ProductPage({ params }: PageProps) {
</nav> </nav>
<ProductDetail product={product} /> <ProductDetail product={product} />
<ProductReviews productId={product.id} />
</div> </div>
); );
} }
@@ -12,6 +12,8 @@ import { Link } from '@/i18n/navigation';
import { formatMoney } from '@/lib/format'; import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes'; import { routes } from '@/lib/routes';
import { DiscountForm } from './discount-form';
/** /**
* The bag. * The bag.
* *
@@ -151,8 +153,18 @@ export function CartView() {
<aside className="border-ink-200 h-fit border p-6 lg:sticky lg:top-24"> <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> <h2 className="text-xs font-semibold uppercase tracking-widest">{t('summary')}</h2>
<div className="border-ink-200 mt-6 border-b pb-6">
<DiscountForm applied={cart.discounts} rejected={cart.rejectedDiscounts} />
</div>
<dl className="mt-6 space-y-3 text-sm"> <dl className="mt-6 space-y-3 text-sm">
<Row label={t('subtotal')} value={formatMoney(cart.totals.subtotal, format)} /> <Row label={t('subtotal')} value={formatMoney(cart.totals.subtotal, format)} />
{cart.totals.discount.amount > 0 ? (
<div className="flex items-baseline justify-between">
<dt className="text-ink-500">{t('discount')}</dt>
<dd className="text-success">−{formatMoney(cart.totals.discount, format)}</dd>
</div>
) : null}
{/* Named zeroes rather than hidden rows: the total is always the sum {/* Named zeroes rather than hidden rows: the total is always the sum
of parts a shopper can see, even before shipping exists (M9). */} of parts a shopper can see, even before shipping exists (M9). */}
<Row label={t('shipping')} value={t('shippingAtCheckout')} muted /> <Row label={t('shipping')} value={t('shippingAtCheckout')} muted />
@@ -237,6 +237,12 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
<dt className="text-ink-500">{t('subtotal')}</dt> <dt className="text-ink-500">{t('subtotal')}</dt>
<dd>{formatMoney(cart.totals.subtotal, format)}</dd> <dd>{formatMoney(cart.totals.subtotal, format)}</dd>
</div> </div>
{cart.totals.discount.amount > 0 ? (
<div className="flex justify-between">
<dt className="text-ink-500">{t('discount')}</dt>
<dd className="text-success">−{formatMoney(cart.totals.discount, format)}</dd>
</div>
) : null}
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-ink-500">{t('shipping')}</dt> <dt className="text-ink-500">{t('shipping')}</dt>
<dd className="text-ink-400 text-xs">{t('shippingLater')}</dd> <dd className="text-ink-400 text-xs">{t('shippingLater')}</dd>
@@ -0,0 +1,109 @@
'use client';
import { Loader2, Tag, X } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useState } from 'react';
import { DISCOUNT_REJECTIONS, type AppliedDiscount, type RejectedDiscount } from '@sport/types';
import { Button, Input } from '@sport/ui';
import { useCart } from '@/features/cart/cart-provider';
import { formatMoney } from '@/lib/format';
/**
* Discount codes on the bag.
*
* Applied discounts are listed with what each one took off, because "you saved
* 258.000 ₫" is the thing a shopper is actually checking. Automatic promotions
* appear in the same list — from the customer's side there is no difference
* between an offer they earned and one they typed for.
*/
export function DiscountForm({
applied,
rejected,
}: {
applied: readonly AppliedDiscount[];
rejected: readonly RejectedDiscount[];
}) {
const t = useTranslations('cart');
const format = useFormatter();
const { applyCode, removeCode, pending } = useCart();
const [code, setCode] = useState('');
async function submit(event: React.FormEvent) {
event.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
const updated = await applyCode(trimmed);
/**
* Cleared only when the code actually applied.
*
* The request succeeding is not the same as the discount applying — an
* expired or fully-claimed code returns 200 with a rejection. Clearing on
* the HTTP result wiped the box while the error explaining it was still on
* screen, leaving nothing to correct.
*/
const accepted = updated?.discounts.some(
(discount) => discount.code?.toUpperCase() === trimmed.toUpperCase(),
);
if (accepted) setCode('');
}
return (
<div className="space-y-3">
{applied.length > 0 ? (
<ul className="space-y-2">
{applied.map((discount) => (
<li key={discount.id} className="flex items-center gap-2 text-sm">
<Tag className="text-success size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{discount.name}</span>
<span className="text-success font-medium">
−{formatMoney(discount.amount, format)}
</span>
{discount.code ? (
<button
type="button"
disabled={pending}
onClick={() => void removeCode(discount.code as string)}
aria-label={t('removeCode', { code: discount.code })}
className="text-ink-400 hover:text-danger focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px] disabled:opacity-50"
>
<X className="size-3.5" />
</button>
) : null}
</li>
))}
</ul>
) : null}
{rejected.map((rejection) => (
<p key={rejection.code} role="alert" className="text-danger text-xs">
{rejection.reason === DISCOUNT_REJECTIONS.MINIMUM_NOT_MET && rejection.minimumSubtotal
? t('discountError.minimum', {
code: rejection.code,
amount: formatMoney(rejection.minimumSubtotal, format),
})
: t(`discountError.${rejection.reason}`, { code: rejection.code })}
</p>
))}
<form onSubmit={(event) => void submit(event)} className="flex gap-2">
<Input
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder={t('discountPlaceholder')}
aria-label={t('discountLabel')}
className="h-10 uppercase"
/>
<Button type="submit" variant="secondary" size="sm" disabled={pending || !code.trim()}>
{pending ? <Loader2 className="animate-spin" /> : null}
{t('applyCode')}
</Button>
</form>
</div>
);
}
@@ -0,0 +1,113 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Link } from '@/i18n/navigation';
/**
* Renders editorial Markdown.
*
* `react-markdown` parses to React elements rather than to an HTML string, so
* there is no `dangerouslySetInnerHTML` anywhere in this path. That makes
* injection structurally impossible instead of sanitised-away: a `<script>` in
* a post body is escaped text, not a tag, because it never becomes markup.
*
* Elements are mapped explicitly rather than inheriting browser defaults. The
* storefront has a deliberate type scale, and unstyled `<h2>`/`<p>` from a CMS
* body is exactly how editorial pages end up looking like a different website.
*/
export function Markdown({ children }: { children: string }) {
return (
<div className="max-w-prose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children: content }) => (
<h2 className="mt-10 text-2xl font-black uppercase tracking-tight first:mt-0">
{content}
</h2>
),
// Demoted one level: the page already renders the title as <h1>, and
// a second <h1> in the body breaks the document outline.
h2: ({ children: content }) => (
<h3 className="mt-8 text-lg font-bold first:mt-0">{content}</h3>
),
h3: ({ children: content }) => (
<h4 className="mt-6 text-base font-semibold first:mt-0">{content}</h4>
),
p: ({ children: content }) => (
<p className="text-ink-700 mt-4 text-sm leading-relaxed first:mt-0">{content}</p>
),
ul: ({ children: content }) => (
<ul className="text-ink-700 mt-4 list-disc space-y-1.5 pl-5 text-sm">{content}</ul>
),
ol: ({ children: content }) => (
<ol className="text-ink-700 mt-4 list-decimal space-y-1.5 pl-5 text-sm">{content}</ol>
),
blockquote: ({ children: content }) => (
<blockquote className="border-ink-950 text-ink-600 mt-6 border-l-2 pl-4 italic">
{content}
</blockquote>
),
strong: ({ children: content }) => (
<strong className="text-ink-950 font-semibold">{content}</strong>
),
hr: () => <hr className="border-ink-200 mt-8" />,
code: ({ children: content }) => (
<code className="bg-ink-100 rounded px-1.5 py-0.5 font-mono text-xs">{content}</code>
),
table: ({ children: content }) => (
<div className="mt-6 overflow-x-auto">
<table className="w-full text-left text-sm">{content}</table>
</div>
),
th: ({ children: content }) => (
<th className="border-ink-200 border-b px-3 py-2 text-xs font-semibold uppercase tracking-widest">
{content}
</th>
),
td: ({ children: content }) => (
<td className="border-ink-100 text-ink-700 border-b px-3 py-2">{content}</td>
),
a: ({ href, children: content }) => {
const target = href ?? '';
/**
* Internal links go through the locale-aware `Link`.
*
* A raw `<a href="/men">` inside a Vietnamese post drops the locale
* prefix and bounces the reader to the default language — the same
* bug already fixed three times elsewhere in this storefront.
*/
if (target.startsWith('/')) {
return (
<Link href={target} className="underline underline-offset-2">
{content}
</Link>
);
}
return (
<a
href={target}
// Editorial copy is operator-authored, but `noopener` costs
// nothing and `nofollow` keeps a compromised account from
// handing out the store's link equity.
target="_blank"
rel="noopener noreferrer nofollow"
className="underline underline-offset-2"
>
{content}
</a>
);
},
// Images are deliberately unsupported in bodies: they would bypass
// the media library, hotlink to arbitrary hosts, and arrive without
// dimensions, which is a layout shift on every page they appear on.
img: () => null,
}}
>
{children}
</ReactMarkdown>
</div>
);
}
@@ -13,6 +13,8 @@ import { browserApi } from '@/lib/api';
import { formatMoney } from '@/lib/format'; import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes'; import { routes } from '@/lib/routes';
import { ReviewForm } from './review-form';
/** /**
* Order confirmation, fetched by number + email. * Order confirmation, fetched by number + email.
* *
@@ -131,6 +133,8 @@ export function OrderConfirmation({ orderId }: { orderId: string }) {
</address> </address>
</section> </section>
<ReviewForm orderId={orderId} email={order.email} />
<div className="text-center"> <div className="text-center">
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href={routes.men()}>{t('keepShopping')}</Link> <Link href={routes.men()}>{t('keepShopping')}</Link>
@@ -0,0 +1,142 @@
import { Star } from 'lucide-react';
import { getFormatter, getTranslations } from 'next-intl/server';
import type { ProductReviews as ProductReviewsPayload } from '@sport/api-client';
import { cn } from '@sport/ui';
import { getServerApi } from '@/lib/api';
/**
* Reviews for one product.
*
* A server component on purpose: reviews are the text a product page is
* *about* as far as a search engine is concerned, and fetching them on the
* client would leave them out of the HTML entirely. It also means no loading
* spinner in the middle of the page.
*
* Degrades to nothing if the call fails — a product page missing its reviews
* is a smaller failure than a product page that 500s.
*/
export async function ProductReviews({ productId }: { productId: string }) {
const t = await getTranslations('reviews');
const format = await getFormatter();
let payload: ProductReviewsPayload;
try {
payload = await getServerApi().reviews.listForProduct(
productId,
{ perPage: 10 },
// 60s, matching the product listing. Reviews are moderated, so a new one
// appearing a minute late is not a problem worth a cache miss per view.
{ next: { revalidate: 60 } },
);
} catch {
return null;
}
const { summary, items } = payload;
return (
<section id="reviews" className="border-ink-200 mt-16 border-t pt-10">
<h2 className="text-xl font-black uppercase tracking-tight">{t('heading')}</h2>
{summary.count === 0 ? (
<p className="text-ink-500 mt-4 text-sm">{t('empty')}</p>
) : (
<>
<div className="mt-6 flex flex-col gap-8 sm:flex-row sm:items-start">
<div className="shrink-0">
<div className="flex items-baseline gap-2">
<span className="text-5xl font-black tabular-nums">
{format.number(summary.average, { minimumFractionDigits: 1 })}
</span>
<span className="text-ink-400 text-sm">/ 5</span>
</div>
<Stars rating={Math.round(summary.average)} className="mt-2" />
<p className="text-ink-500 mt-2 text-xs">{t('count', { count: summary.count })}</p>
</div>
{/*
The distribution, not just the average. A 3.0 built from straight
3s and a 3.0 built from 5s and 1s are different products, and the
bars are the only thing that says which one this is.
*/}
<ul className="w-full max-w-sm space-y-1.5">
{summary.distribution.map((bucket) => {
const share = summary.count === 0 ? 0 : (bucket.count / summary.count) * 100;
return (
<li key={bucket.rating} className="flex items-center gap-3 text-xs">
<span className="text-ink-500 w-8 shrink-0 tabular-nums">{bucket.rating}★</span>
<span
className="bg-ink-100 h-1.5 flex-1 overflow-hidden rounded-full"
// The bar is decoration; the number beside it is the data.
aria-hidden
>
<span
className="bg-ink-950 block h-full rounded-full"
style={{ width: `${share}%` }}
/>
</span>
<span className="text-ink-500 w-6 shrink-0 text-right tabular-nums">
{bucket.count}
</span>
</li>
);
})}
</ul>
</div>
<ul className="divide-ink-100 mt-10 divide-y">
{items.map((review) => (
<li key={review.id} className="py-6">
<div className="flex flex-wrap items-center gap-3">
<Stars rating={review.rating} />
{review.title ? <h3 className="text-sm font-semibold">{review.title}</h3> : null}
</div>
<p className="text-ink-500 mt-1.5 text-xs">
{review.authorName}
{' · '}
{format.dateTime(new Date(review.createdAt), { dateStyle: 'medium' })}
{review.isVerifiedPurchase ? (
<>
{' · '}
<span className="text-ink-950 font-medium">{t('verified')}</span>
</>
) : null}
</p>
{review.body ? (
<p className="text-ink-700 mt-3 whitespace-pre-line text-sm leading-relaxed">
{review.body}
</p>
) : null}
</li>
))}
</ul>
{payload.pageInfo.totalItems > items.length ? (
<p className="text-ink-500 mt-4 text-xs">
{t('showing', { shown: items.length, total: payload.pageInfo.totalItems })}
</p>
) : null}
</>
)}
</section>
);
}
export function Stars({ rating, className }: { rating: number; className?: string }) {
return (
<span className={cn('flex items-center gap-0.5', className)} aria-label={`${rating}/5`}>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
aria-hidden
className={cn('size-4', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
))}
</span>
);
}
@@ -0,0 +1,232 @@
'use client';
import { Star } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import type { Locale, ReviewableItem } from '@sport/types';
import { Button, Input, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
/**
* Rate the items on one order.
*
* Lives on the confirmation page because that URL is the only order-scoped page
* a guest can reach — it is what the shopper bookmarks and what the receipt
* email links to. The email is taken from the order rather than typed: holding
* the link already proves ownership (see OrderConfirmation), so asking for it
* again would be theatre that also puts the address back on screen.
*/
export function ReviewForm({ orderId, email }: { orderId: string; email: string }) {
const t = useTranslations('reviewForm');
const locale = useLocale() as Locale;
const [items, setItems] = useState<readonly ReviewableItem[] | null>(null);
useEffect(() => {
let cancelled = false;
browserApi.reviews
.listReviewable({ orderId, email }, locale)
.then((result) => {
if (!cancelled) setItems(result);
})
.catch(() => {
// Silent: a shopper who cannot review is shown nothing, not an error.
// The order itself rendering is what matters on this page.
if (!cancelled) setItems([]);
});
return () => {
cancelled = true;
};
}, [orderId, email, locale]);
if (!items || items.length === 0) return null;
return (
<section className="border-ink-200 border p-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('heading')}</h2>
<p className="text-ink-500 mt-2 text-sm">{t('intro')}</p>
<ul className="divide-ink-100 mt-4 divide-y">
{items.map((item) => (
<ItemReview
key={item.orderLineId}
item={item}
orderId={orderId}
email={email}
locale={locale}
onSubmitted={setItems}
/>
))}
</ul>
</section>
);
}
function ItemReview({
item,
orderId,
email,
locale,
onSubmitted,
}: {
item: ReviewableItem;
orderId: string;
email: string;
locale: Locale;
onSubmitted: (items: readonly ReviewableItem[]) => void;
}) {
const t = useTranslations('reviewForm');
const [rating, setRating] = useState(0);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [authorName, setAuthorName] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function submit() {
setError(null);
setSaving(true);
try {
const updated = await browserApi.reviews.submit(
{
orderId,
email,
orderLineId: item.orderLineId,
rating,
title: title.trim() || null,
body: body.trim() || null,
authorName: authorName.trim(),
},
locale,
);
onSubmitted(updated);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('failed'));
} finally {
setSaving(false);
}
}
return (
<li className="py-4">
<p className="text-sm font-medium">{item.productName}</p>
<p className="text-ink-500 text-xs">{item.variantTitle}</p>
{item.reviewId ? (
/*
Already reviewed: shown as a receipt rather than removed from the
list. A form that silently vanishes after submitting looks like it
failed — and "waiting for approval" is information the shopper needs,
otherwise they will wonder why their review is not on the page.
*/
<div className="mt-3 flex items-center gap-3">
<StarRating value={item.rating ?? 0} readOnly />
<span className="text-ink-500 text-xs">
{item.reviewStatus === 'APPROVED' ? t('published') : t('pending')}
</span>
</div>
) : (
<div className="mt-3 space-y-3">
<StarRating value={rating} onChange={setRating} />
{/* The rest of the form appears only once a rating is chosen —
a star click is the whole commitment for most people, and four
empty fields up front is what stops them making it. */}
{rating > 0 ? (
<div className="space-y-3">
<Input
value={authorName}
onChange={(event) => setAuthorName(event.target.value)}
placeholder={t('namePlaceholder')}
aria-label={t('namePlaceholder')}
maxLength={120}
/>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={t('titlePlaceholder')}
aria-label={t('titlePlaceholder')}
maxLength={140}
/>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('bodyPlaceholder')}
aria-label={t('bodyPlaceholder')}
maxLength={2000}
rows={3}
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 text-sm outline-none"
/>
{error ? (
<p role="alert" className="text-danger text-xs">
{error}
</p>
) : null}
<Button size="sm" disabled={saving || !authorName.trim()} onClick={submit}>
{saving ? t('sending') : t('submit')}
</Button>
</div>
) : null}
</div>
)}
</li>
);
}
function StarRating({
value,
onChange,
readOnly,
}: {
value: number;
onChange?: (value: number) => void;
readOnly?: boolean;
}) {
const t = useTranslations('reviewForm');
return (
<div className="flex items-center gap-1" role={readOnly ? 'img' : 'radiogroup'}>
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= value;
if (readOnly) {
return (
<Star
key={star}
aria-hidden
className={cn('size-5', filled ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
);
}
return (
<button
key={star}
type="button"
role="radio"
aria-checked={value === star}
aria-label={t('stars', { count: star })}
onClick={() => onChange?.(star)}
className="p-0.5"
>
<Star
className={cn(
'size-6 transition-colors',
filled ? 'fill-ink-950 text-ink-950' : 'text-ink-300 hover:text-ink-500',
)}
/>
</button>
);
})}
</div>
);
}
@@ -1,13 +1,29 @@
import { getTranslations } from 'next-intl/server'; import { getLocale, getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types'; import type { Locale, NavigationMenu } from '@sport/types';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { SPORT_NAV, routes } from '@/lib/routes'; import { SPORT_NAV, routes } from '@/lib/routes';
export async function SiteFooter({ navigation }: { navigation: NavigationMenu | null }) { export async function SiteFooter({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('footer'); const t = await getTranslations('footer');
const tSports = await getTranslations('sports'); const tSports = await getTranslations('sports');
const locale = (await getLocale()) as Locale;
/**
* Published pages, listed here because this is the only thing that makes
* them reachable — a policy page nobody can navigate to is a page that does
* not exist. Degrades to nothing rather than taking the footer down with it.
*/
let pages: { slug: string; title: string }[] = [];
try {
pages = [
...(await getServerApi().content.listPageSlugs(locale, { next: { revalidate: 300 } })),
];
} catch {
// The footer renders on every page; a content outage must not break it.
}
return ( return (
<footer className="border-ink-200 bg-ink-950 text-ink-100 mt-24 border-t"> <footer className="border-ink-200 bg-ink-950 text-ink-100 mt-24 border-t">
@@ -80,8 +96,24 @@ export async function SiteFooter({ navigation }: { navigation: NavigationMenu |
</nav> </nav>
</div> </div>
<div className="border-ink-800 text-ink-500 border-t py-6 text-center text-xs"> <div className="border-ink-800 border-t py-6">
{t('rights', { year: new Date().getFullYear() })} {pages.length > 0 ? (
<nav aria-label={t('policies')} className="mb-4">
<ul className="text-ink-400 flex flex-wrap justify-center gap-x-6 gap-y-2 text-xs">
{pages.map((page) => (
<li key={page.slug}>
<Link href={routes.page(page.slug)} className="hover:text-white">
{page.title}
</Link>
</li>
))}
</ul>
</nav>
) : null}
<p className="text-ink-500 text-center text-xs">
{t('rights', { year: new Date().getFullYear() })}
</p>
</div> </div>
</footer> </footer>
); );
@@ -16,6 +16,9 @@ interface CartState {
addLine: (variantId: string, quantity?: number) => Promise<boolean>; addLine: (variantId: string, quantity?: number) => Promise<boolean>;
setQuantity: (variantId: string, quantity: number) => Promise<void>; setQuantity: (variantId: string, quantity: number) => Promise<void>;
removeLine: (variantId: string) => Promise<void>; removeLine: (variantId: string) => Promise<void>;
/** Resolves to the updated cart, or null if the request itself failed. */
applyCode: (code: string) => Promise<Cart | null>;
removeCode: (code: string) => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
@@ -55,6 +58,28 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
}, []); }, []);
/** Re-reads the bag after something outside this provider changed it. */ /** Re-reads the bag after something outside this provider changed it. */
/**
* Like `run`, but hands the caller the cart it produced.
*
* A discount code needs this: the request succeeding and the code *applying*
* are different outcomes, and only the returned cart can tell them apart.
*/
const runReturning = useCallback(async (operation: () => Promise<Cart>): Promise<Cart | null> => {
setPending(true);
setError(null);
try {
const result = await operation();
setCart(result);
return result;
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : 'Something went wrong.');
return null;
} finally {
setPending(false);
}
}, []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setCart(await browserApi.commerce.getCart(locale)); setCart(await browserApi.commerce.getCart(locale));
@@ -103,9 +128,13 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
removeLine: async (variantId) => { removeLine: async (variantId) => {
await run(() => browserApi.commerce.removeCartLine(locale, variantId)); await run(() => browserApi.commerce.removeCartLine(locale, variantId));
}, },
applyCode: (code) => runReturning(() => browserApi.commerce.applyDiscountCode(locale, code)),
removeCode: async (code) => {
await run(() => browserApi.commerce.removeDiscountCode(locale, code));
},
refresh, refresh,
}), }),
[cart, loading, pending, error, locale, run, refresh], [cart, loading, pending, error, locale, run, runReturning, refresh],
); );
return <CartContext.Provider value={value}>{children}</CartContext.Provider>; return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+2
View File
@@ -28,6 +28,8 @@ export const routes = {
accountWishlist: () => '/account/wishlist', accountWishlist: () => '/account/wishlist',
blog: () => '/blog', blog: () => '/blog',
post: (slug: string) => `/blog/${slug}`,
page: (slug: string) => `/pages/${slug}`,
} as const; } as const;
/** The sport facets that back `/sports/[sport]`. */ /** The sport facets that back `/sports/[sport]`. */
+46 -2
View File
@@ -106,7 +106,8 @@
"orders": "Orders", "orders": "Orders",
"wishlist": "Wishlist", "wishlist": "Wishlist",
"journal": "Journal", "journal": "Journal",
"rights": "© {year} Sport Store." "rights": "© {year} Sport Store.",
"policies": "Information"
}, },
"account": { "account": {
"title": "Account", "title": "Account",
@@ -173,6 +174,21 @@
"reduced": "{name} was reduced to {quantity} — that is all we have left.", "reduced": "{name} was reduced to {quantity} — that is all we have left.",
"soldOut": "{name} sold out and was removed from your bag.", "soldOut": "{name} sold out and was removed from your bag.",
"unavailable": "An item is no longer available and was removed from your bag." "unavailable": "An item is no longer available and was removed from your bag."
},
"discountPlaceholder": "Discount code",
"discountLabel": "Discount code",
"applyCode": "Apply",
"discount": "Discount",
"removeCode": "Remove code {code}",
"discountError": {
"NOT_FOUND": "We don't recognise {code}.",
"EXPIRED": "{code} has expired.",
"NOT_STARTED": "{code} is not active yet.",
"USAGE_LIMIT_REACHED": "{code} has been fully claimed.",
"MINIMUM_NOT_MET": "{code} needs a larger order.",
"NOTHING_ELIGIBLE": "{code} does not apply to anything in your bag.",
"NOT_COMBINABLE": "{code} cannot be combined with your current offer.",
"minimum": "{code} needs a subtotal of at least {amount}."
} }
}, },
"checkout": { "checkout": {
@@ -198,7 +214,8 @@
"failed": "We could not place your order.", "failed": "We could not place your order.",
"loading": "Loading your bag…", "loading": "Loading your bag…",
"emptyBag": "There is nothing in your bag.", "emptyBag": "There is nothing in your bag.",
"backToShop": "Back to shopping" "backToShop": "Back to shopping",
"discount": "Discount"
}, },
"confirmation": { "confirmation": {
"title": "Order placed", "title": "Order placed",
@@ -210,5 +227,32 @@
"keepShopping": "Keep shopping", "keepShopping": "Keep shopping",
"notFound": "We could not find that order.", "notFound": "We could not find that order.",
"backHome": "Back to home" "backHome": "Back to home"
},
"reviews": {
"heading": "Reviews",
"empty": "No reviews yet. Buy it and be the first.",
"count": "{count, plural, one {# review} other {# reviews}}",
"verified": "Verified purchase",
"showing": "Showing {shown} of {total}."
},
"reviewForm": {
"heading": "Rate what you bought",
"intro": "Reviews are checked before they appear on the product page.",
"namePlaceholder": "The name to show on your review",
"titlePlaceholder": "Sum it up (optional)",
"bodyPlaceholder": "How did it fit, wear, wash? (optional)",
"submit": "Submit review",
"sending": "Sending…",
"failed": "Could not send your review. Try again.",
"pending": "Waiting to be published",
"published": "Published",
"stars": "{count, plural, one {# star} other {# stars}}"
},
"blog": {
"title": "Journal",
"intro": "Training notes, kit guides and what we are working on.",
"empty": "Nothing published yet.",
"backToJournal": "Back to the journal",
"by": "By {name}"
} }
} }
+46 -2
View File
@@ -106,7 +106,8 @@
"orders": "Đơn hàng", "orders": "Đơn hàng",
"wishlist": "Yêu thích", "wishlist": "Yêu thích",
"journal": "Bài viết", "journal": "Bài viết",
"rights": "© {year} Sport Store." "rights": "© {year} Sport Store.",
"policies": "Thông tin"
}, },
"account": { "account": {
"title": "Tài khoản", "title": "Tài khoản",
@@ -173,6 +174,21 @@
"reduced": "{name} đã giảm còn {quantity} — đó là số hàng còn lại.", "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ỏ.", "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ỏ." "unavailable": "Một sản phẩm không còn bán và đã được bỏ khỏi giỏ."
},
"discountPlaceholder": "Mã giảm giá",
"discountLabel": "Mã giảm giá",
"applyCode": "Áp dụng",
"discount": "Giảm giá",
"removeCode": "Bỏ mã {code}",
"discountError": {
"NOT_FOUND": "Không tìm thấy mã {code}.",
"EXPIRED": "Mã {code} đã hết hạn.",
"NOT_STARTED": "Mã {code} chưa có hiệu lực.",
"USAGE_LIMIT_REACHED": "Mã {code} đã hết lượt sử dụng.",
"MINIMUM_NOT_MET": "Mã {code} cần đơn hàng lớn hơn.",
"NOTHING_ELIGIBLE": "Mã {code} không áp dụng cho sản phẩm trong giỏ.",
"NOT_COMBINABLE": "Mã {code} không dùng chung với ưu đãi hiện tại.",
"minimum": "Mã {code} cần đơn tối thiểu {amount}."
} }
}, },
"checkout": { "checkout": {
@@ -198,7 +214,8 @@
"failed": "Không đặt được đơn hàng.", "failed": "Không đặt được đơn hàng.",
"loading": "Đang tải giỏ hàng…", "loading": "Đang tải giỏ hàng…",
"emptyBag": "Giỏ hàng của bạn đang trống.", "emptyBag": "Giỏ hàng của bạn đang trống.",
"backToShop": "Quay lại mua sắm" "backToShop": "Quay lại mua sắm",
"discount": "Giảm giá"
}, },
"confirmation": { "confirmation": {
"title": "Đặt hàng thành công", "title": "Đặt hàng thành công",
@@ -210,5 +227,32 @@
"keepShopping": "Tiếp tục mua sắm", "keepShopping": "Tiếp tục mua sắm",
"notFound": "Không tìm thấy đơn hàng.", "notFound": "Không tìm thấy đơn hàng.",
"backHome": "Về trang chủ" "backHome": "Về trang chủ"
},
"reviews": {
"heading": "Đánh giá",
"empty": "Chưa có đánh giá nào. Hãy là người đầu tiên.",
"count": "{count, plural, other {# đánh giá}}",
"verified": "Đã mua hàng",
"showing": "Đang hiển thị {shown} trên {total}."
},
"reviewForm": {
"heading": "Đánh giá sản phẩm bạn đã mua",
"intro": "Đánh giá được kiểm duyệt trước khi hiển thị trên trang sản phẩm.",
"namePlaceholder": "Tên hiển thị trên đánh giá",
"titlePlaceholder": "Tiêu đề ngắn (không bắt buộc)",
"bodyPlaceholder": "Form dáng, chất vải, độ bền thế nào? (không bắt buộc)",
"submit": "Gửi đánh giá",
"sending": "Đang gửi…",
"failed": "Không gửi được đánh giá. Vui lòng thử lại.",
"pending": "Đang chờ duyệt",
"published": "Đã hiển thị",
"stars": "{count, plural, other {# sao}}"
},
"blog": {
"title": "Bài viết",
"intro": "Ghi chép tập luyện, hướng dẫn chọn đồ và những gì chúng tôi đang làm.",
"empty": "Chưa có bài viết nào.",
"backToJournal": "Quay lại danh sách bài viết",
"by": "Bởi {name}"
} }
} }
@@ -0,0 +1,99 @@
# ADR-0020: Promotions and coupons are one entity with one engine
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
A storefront needs two things that look different and are not: promotions that
apply themselves ("20% off jackets this week") and coupons that apply when
someone types a code ("SUMMER20").
Modelled separately they get separate tables, separate admin screens and
separate rule evaluation. Then both have to answer the same questions — does
this stack with that, what happens when the total would go negative, which wins
when two apply, how is a percentage rounded — and the two answers drift. The
drift is not caught by tests, because each engine is self-consistent. It is
caught by a customer whose total is wrong.
## Decision
**One `Discount` entity with a `trigger`.** `AUTOMATIC` applies on its own;
`CODE` requires a code. Everything else — type, scope, value, window, usage
limit, stacking, priority — is shared, because it genuinely is.
**The arithmetic is a pure function.** `discount-engine.ts` takes a snapshot of
candidates and lines and returns amounts. No database, no clock, no I/O. This is
the one place in the system where a rounding mistake is a financial one, so it
is the one place that can be exhaustively tested without a database — and it is.
**Eligibility is resolved outside it.** Whether a discount is live, within its
window, or has uses left depends on state the engine deliberately cannot see.
`PromotionsService` answers those and hands the engine a decided list.
**Stacking is `stackable` plus `priority`.** Ascending priority, then id, so the
outcome never depends on the order rows came back in. A non-stackable discount
that applies ends evaluation; one that would apply after another already has is
rejected as `NOT_COMBINABLE`.
**Percentages floor, never round.** Rounding up hands out a fraction of a đồng
the merchant never agreed to, on every order.
**Each discount applies to what is left, not the original subtotal.** Two 50%
offers take 75%, not 100%.
**Usage limits are claimed with a conditional UPDATE**, guarded on the limit —
the same shape as stock reservation (ADR forthcoming in §15 of architecture.md).
Two shoppers redeeming the last use simultaneously must not both win.
**Redemptions are recorded with a snapshot amount**, and cancelling an order
hands its uses back.
## Consequences
One admin screen, one permission, one set of rules to reason about. A coupon is
a promotion that needs a code typed, and the data model says so.
`coupon.manage` was deleted from the permission catalog rather than left
unused. A permission nobody checks is worse than no permission: it reads as a
capability a role can be granted, and the first person to grant it will expect
it to do something.
The one place the merge is visible as a compromise is the editor, where
`trigger` is the first control on the form — it decides whether the rest of the
dialog reads as "a promotion that runs by itself" or "a code a shopper types",
so it cannot sit further down.
Status on the list is derived, never `isActive` alone. A discount that expired
last week or burned its last use is still `isActive: true`, and a screen that
reports a promotion as running when it is not is worse than no screen.
The engine's purity is what makes the money maths trustworthy: twelve tests
cover flooring, over-discounting, compounding, determinism and empty carts
without touching Postgres.
The cost is that some fields are meaningless for some triggers — an automatic
promotion has no `code`. That is enforced in validation rather than by the
schema, which is the usual trade for avoiding two near-identical tables. The
editor disables the field rather than hiding it, so the rule is visible instead
of mysterious.
Retiring is a soft delete, because `DiscountRedemption.discount` is
`onDelete: Restrict`: an order that received a discount must keep pointing at
the thing it received. So a retired discount stops applying rather than ceasing
to exist, and the admin says "Retire" rather than "Delete" for that reason.
Per-customer limits are absent: they need customer identity, which arrives with
M8. The column is deliberately not there yet rather than present and ignored.
## Alternatives considered
**Separate `Promotion` and `Coupon` tables.** Clearer names, two engines that
must agree forever. Rejected — the agreement is the hard part, and it does not
hold.
**A rules DSL stored as JSON.** Maximum flexibility, no type safety, and every
rule change becomes a data migration nobody can review. Rejected as premature.
**Computing discounts on the client.** Instant feedback, and it makes the total
a negotiation. Every price in this system is the API's to decide (ADR-0011).
@@ -0,0 +1,102 @@
# ADR-0021: A review is anchored to an order line
- **Status:** Accepted
- **Date:** 2026-08-13
## Context
Product reviews are worth having only if they are worth believing. The usual
shape — an open form on the product page, with a "verified purchase" badge
awarded to reviews the system can match back to an order — gets this backwards.
The badge becomes the exception, unverified reviews become the bulk of the
content, and moderation becomes a full-time job rather than a screen someone
checks.
The badge is also weaker than it looks. Matching on product + email means
anyone who guesses a customer's address can post as them, and anyone who bought
once can review every colourway.
Meanwhile this store has no customer accounts yet (M8). A design that depends
on login would mean no reviews until then.
## Decision
**`Review.orderLineId` is unique and required.** A review hangs off the exact
purchased item, not off a product plus a claim about who is writing.
This makes "verified purchase" **structural**. There is no unverified review
because there is no row to put one in. `isVerifiedPurchase` is sent to the
storefront as a constant `true` — not because the check is skipped, but because
the schema already made it unfalsifiable.
The unique constraint gives **one review per item purchased** for free. Buying
the same jacket twice earns two reviews; buying it once earns one.
**Authorisation is the order id plus the email on that order.** The same bar
the guest order lookup already sets, and the same capability URL the
confirmation page uses (ADR-0019's neighbour: the id is a UUIDv7, unguessable,
and holding the link is the authorisation). No account required, which is what
lets reviews ship before M8.
Wrong email and unknown order return the **same 404**, so the endpoint cannot
be used to test whether an order exists.
**Everything starts `PENDING`.** Publication requires a decision. The
alternative — publish then moderate — means the first person to read abuse on a
product page is a customer.
**The rating aggregate lives on `products` as `rating_sum` + `rating_count`,**
and is **recomputed**, never incremented. Two integers rather than a stored
average, because approving one more 4-star review is `sum + 4, count + 1`:
exact, and reversible. See Consequences.
## Consequences
A shopper cannot review a product they own but bought elsewhere. That is the
correct trade here: the alternative is an open submission endpoint, which is a
spam surface needing a moderation _team_ rather than a moderation _screen_.
Reviews work for guests today and keep working when accounts arrive — an
`orderLine` already reaches a `customerId` through its order when there is one.
Recompute-not-increment is what makes a moderator's decision reversible. Every
incremental scheme needs a compensating delta per path (approve, reject,
un-reject, edit), and the first missed path leaves a product displaying a
rating no review supports, with nothing to notice it by. Recomputing from the
approved rows is one statement that is correct from any starting state — and it
is exercised: rejecting an approved review takes its stars back out.
Moderation drops the whole catalog cache. `products.rating_*` is part of the
read model, `cache-keys.ts` states the contract as "invalidated on write, TTL
as a safety net", and without this a moderator approves a review, reloads the
product page, sees the old figure for up to five minutes and concludes the
button is broken.
The distribution is sent alongside the average because they answer different
questions. A 3.0 of straight 3s is a mediocre product; a 3.0 of 5s and 1s is a
product with a sizing problem. Only the bars distinguish them.
Ratings are constrained `BETWEEN 1 AND 5` in the database as well as in Zod.
The column feeds a stored SUM, so one bad row skews a product's average
silently and permanently.
## Alternatives considered
**Open reviews with a verified badge.** More content, most of it unverifiable,
and a badge that a determined poster can forge by guessing an email.
**Require an account.** Cleanest identity story, and it would have meant no
reviews at all until M8 — while making guest buyers, who are most of them,
second-class.
**Store the average directly.** One column instead of two, and it must be
recomputed from scratch to stay honest anyway — at which point it is strictly
worse than the pair, since it also accumulates floating-point drift.
**Publish immediately, moderate later.** Faster for the honest majority, and it
puts the store's name under whatever the first bad actor writes.
**Gate on delivery ("only review what arrived").** Correct in principle and
currently unknowable: the store has no shipping integration until M10, so
"delivered" is not a fact the system holds. Cancelled orders are refused; the
rest are allowed, and the gate tightens when the data exists.
@@ -0,0 +1,102 @@
# ADR-0022: Editorial content is Markdown, not a page builder
- **Status:** Accepted
- **Date:** 2026-08-13
## Context
The store needs a journal and a handful of static pages — returns policy,
shipping, about. That is a small requirement with a large gravitational pull:
every CMS starts as "just some pages" and ends as a block tree, because the
first time an editor wants two columns, someone adds a two-column block.
Once that happens the storefront's design stops being code. Layout decisions
move into the database, the React components become a rendering engine for
whatever an editor assembled, and the careful type scale and spacing this
project has spent seven milestones establishing become suggestions. That is the
outcome this entire project exists to avoid — it is why it is not WooCommerce.
## Decision
**One `ContentEntry` with a `type` of `PAGE` or `POST`,** rather than two
tables. The shared surface — per-locale slug, title, body, SEO, publish state,
soft delete — is nearly all of it. What differs is placement: a POST is listed
in a feed newest-first and carries an excerpt and a cover; a PAGE is addressed
directly and never listed. Same reasoning as ADR-0020.
**The body is one Markdown column.** No blocks, no tree, no layout. An editor
chooses _what it says_; the storefront decides _what it looks like_.
**Markdown is rendered to React elements, never to an HTML string.**
`react-markdown` parses to a component tree, so `dangerouslySetInnerHTML`
appears nowhere in this path. A `<script>` in a body is escaped text because it
never becomes a tag — injection is structurally impossible rather than
sanitised away, which is the same move as anchoring reviews to order lines
(ADR-0021).
**Every element is mapped explicitly.** Unstyled `<h2>`/`<p>` inheriting
browser defaults is precisely how a CMS page ends up looking like a different
website. Body `<h1>` is demoted to `<h2>` because the page already renders the
title as `<h1>`, and internal links go through the locale-aware `Link` — a raw
`<a href="/men">` in a Vietnamese post drops the locale prefix, a bug already
fixed three times elsewhere in this storefront.
**Images in bodies are dropped.** They would bypass the media library, hotlink
to arbitrary hosts, and arrive without dimensions — a layout shift on every
page they appear on. Posts get one cover image, from the media library, with
known dimensions.
**`publishedAt` is stamped once, on first publish, and never rewritten.**
Re-stamping on save would jump a post to the top of the feed because somebody
fixed a typo, and would silently change a date a reader may already have cited.
**Slugs are per-locale**, like products, because `/en/blog/how-to-layer` and
`/vi/blog/cach-phoi-do` are the SEO surface. A slug from any locale resolves,
then redirects to the canonical one for the locale being browsed.
**Pages live at `/pages/[slug]`.** Not at the locale root: a catch-all there
would compete with `/men`, `/cart` and `/search`, and make every genuine 404
ambiguous. `/pages/returns` is uglier than `/returns` and cannot silently
shadow a real route.
## Consequences
An editor writes in Markdown. That is a real constraint on non-technical staff,
and the correct one at this size — the alternative is a rich-text editor whose
output must then be sanitised, which is a larger surface than the whole feature.
Publishing is one decision with two buttons: _Save draft_ and _Publish_. A
status dropdown plus Save reads as neither, and publishing is the consequential
action.
Deleting is soft, and also unpublishes. A soft-deleted row still marked
`PUBLISHED` is one forgotten `deletedAt: null` away from being live again.
Translations are replaced wholesale on save rather than upserted per locale, so
removing the English version of a post actually stops `/en/blog/<slug>`
resolving instead of serving the copy the editor just deleted.
The `/content/{posts,pages}/slugs` endpoints return titles alongside slugs.
They serve three callers — `generateStaticParams`, the footer's page list, and
a sitemap when one is built — which is cheaper than three endpoints returning
the same rows.
Homepage blocks are **not** built. The module's original sketch mentioned them;
they are the exact feature that turns this into a page builder, and the
homepage is better served by code until there is a concrete editorial need.
## Alternatives considered
**A block/section tree.** Maximum editorial flexibility, and it hands layout to
the database. Rejected — see Context.
**MDX with embedded components.** Lets a post drop in a product carousel, and
makes content executable code that must be built and deployed. Rejected for
content authored through an admin UI at runtime.
**HTML stored directly with sanitisation.** Familiar to editors, and every
sanitiser is a denylist that someone eventually gets past. Rendering to React
elements needs no denylist.
**Separate `pages` and `blog_posts` tables.** Clearer names, two admin screens,
and two sets of publishing rules that must agree forever.
+3
View File
@@ -28,6 +28,9 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted | | [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
| [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted | | [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted |
| [0019](./0019-order-placement-is-idempotent-by-client-key.md) | Order placement is idempotent by client key | Accepted | | [0019](./0019-order-placement-is-idempotent-by-client-key.md) | Order placement is idempotent by client key | Accepted |
| [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted |
| [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted |
| [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted |
## Decisions deliberately NOT recorded yet ## Decisions deliberately NOT recorded yet
+26
View File
@@ -9,7 +9,23 @@ import {
type CommerceResource, type CommerceResource,
type OrdersAdminResource, type OrdersAdminResource,
} from './resources/commerce'; } from './resources/commerce';
import {
createContentAdminResource,
createContentResource,
type ContentAdminResource,
type ContentResource,
} from './resources/content';
import { createHealthResource, type HealthResource } from './resources/health'; import { createHealthResource, type HealthResource } from './resources/health';
import {
createPromotionsAdminResource,
type PromotionsAdminResource,
} from './resources/promotions-admin';
import {
createReviewsAdminResource,
createReviewsResource,
type ReviewsAdminResource,
type ReviewsResource,
} from './resources/reviews';
/** /**
* Resource modules are added here as the backend grows — one file per bounded * Resource modules are added here as the backend grows — one file per bounded
@@ -26,6 +42,11 @@ export interface ApiClient {
readonly catalogAdmin: CatalogAdminResource; readonly catalogAdmin: CatalogAdminResource;
readonly commerce: CommerceResource; readonly commerce: CommerceResource;
readonly ordersAdmin: OrdersAdminResource; readonly ordersAdmin: OrdersAdminResource;
readonly promotionsAdmin: PromotionsAdminResource;
readonly reviews: ReviewsResource;
readonly reviewsAdmin: ReviewsAdminResource;
readonly content: ContentResource;
readonly contentAdmin: ContentAdminResource;
} }
export function createApiClient(options: HttpClientOptions): ApiClient { export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -40,5 +61,10 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
catalogAdmin: createCatalogAdminResource(http), catalogAdmin: createCatalogAdminResource(http),
commerce: createCommerceResource(http), commerce: createCommerceResource(http),
ordersAdmin: createOrdersAdminResource(http), ordersAdmin: createOrdersAdminResource(http),
promotionsAdmin: createPromotionsAdminResource(http),
reviews: createReviewsResource(http),
reviewsAdmin: createReviewsAdminResource(http),
content: createContentResource(http),
contentAdmin: createContentAdminResource(http),
}; };
} }
+22
View File
@@ -36,4 +36,26 @@ export type {
OrdersAdminResource, OrdersAdminResource,
PlaceOrderInput, PlaceOrderInput,
} from './resources/commerce'; } from './resources/commerce';
export type {
DiscountListParams,
DiscountPayload,
PromotionsAdminResource,
} from './resources/promotions-admin';
export type {
AdminReviewListParams,
ProductReviews,
ReviewCredentials,
ReviewListParams,
ReviewsAdminResource,
ReviewsResource,
SubmitReviewPayload,
} from './resources/reviews';
export type {
AdminContentListParams,
ContentAdminResource,
ContentEntryPayload,
ContentResource,
ContentTranslationPayload,
PostListParams,
} from './resources/content';
export type { ApiClient } from './create-client'; export type { ApiClient } from './create-client';
@@ -52,6 +52,8 @@ export interface CommerceResource {
options?: RequestOptions, options?: RequestOptions,
): Promise<Cart>; ): Promise<Cart>;
removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>; removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>;
applyDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
removeDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>; getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>;
/** /**
@@ -119,6 +121,15 @@ export function createCommerceResource(http: HttpClient): CommerceResource {
cartOptions(locale, options), cartOptions(locale, options),
), ),
applyDiscountCode: (locale, code, options) =>
http.post<Cart>('/cart/discounts', { code }, cartOptions(locale, options)),
removeDiscountCode: (locale, code, options) =>
http.delete<Cart>(
`/cart/discounts/${encodeURIComponent(code)}`,
cartOptions(locale, options),
),
getCheckoutQuote: (locale, options) => getCheckoutQuote: (locale, options) =>
http.get<Cart>('/checkout/quote', cartOptions(locale, options)), http.get<Cart>('/checkout/quote', cartOptions(locale, options)),
@@ -0,0 +1,116 @@
import type {
AdminContentEntry,
ContentDetail,
ContentStatus,
ContentSummary,
ContentType,
Locale,
OffsetPaginated,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
export interface PostListParams {
page?: number;
perPage?: number;
}
export interface AdminContentListParams {
page?: number;
perPage?: number;
type?: ContentType;
status?: ContentStatus;
q?: string;
}
export interface ContentTranslationPayload {
slug: string;
title: string;
excerpt?: string | null;
body: string;
metaTitle?: string | null;
metaDescription?: string | null;
}
export interface ContentEntryPayload {
type: ContentType;
status?: ContentStatus;
coverImageId?: string | null;
translations: Record<string, ContentTranslationPayload>;
}
/** Editorial content: pages and posts. Published entries only. */
export interface ContentResource {
listPosts(
locale: Locale,
params?: PostListParams,
options?: RequestOptions,
): Promise<OffsetPaginated<ContentSummary>>;
getPost(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
getPage(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
listPostSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
listPageSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
}
export interface ContentAdminResource {
list(params?: AdminContentListParams): Promise<OffsetPaginated<AdminContentEntry>>;
getById(id: string): Promise<AdminContentEntry>;
create(payload: ContentEntryPayload): Promise<AdminContentEntry>;
update(id: string, payload: ContentEntryPayload): Promise<AdminContentEntry>;
remove(id: string): Promise<void>;
}
export function createContentResource(http: HttpClient): ContentResource {
return {
listPosts: (locale, params = {}, options = {}) =>
http.get<OffsetPaginated<ContentSummary>>('/content/posts', {
...options,
query: { locale, ...params },
}),
getPost: (locale, slug, options = {}) =>
http.get<ContentDetail>(`/content/posts/${encodeURIComponent(slug)}`, {
...options,
query: { locale },
}),
getPage: (locale, slug, options = {}) =>
http.get<ContentDetail>(`/content/pages/${encodeURIComponent(slug)}`, {
...options,
query: { locale },
}),
listPostSlugs: (locale, options = {}) =>
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/posts/slugs', {
...options,
query: { locale },
}),
listPageSlugs: (locale, options = {}) =>
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/pages/slugs', {
...options,
query: { locale },
}),
};
}
export function createContentAdminResource(http: HttpClient): ContentAdminResource {
// Never cached: an editor who just saved a draft must see the draft.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminContentEntry>>('/admin/content', {
...uncached,
query: { ...params },
}),
getById: (id) =>
http.get<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, uncached),
create: (payload) => http.post<AdminContentEntry>('/admin/content', payload, uncached),
update: (id, payload) =>
http.patch<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, payload, uncached),
remove: (id) => http.delete<void>(`/admin/content/${encodeURIComponent(id)}`, uncached),
};
}
@@ -0,0 +1,70 @@
import type { AdminDiscount, OffsetPaginated } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface DiscountListParams {
page?: number;
perPage?: number;
q?: string;
trigger?: 'AUTOMATIC' | 'CODE';
}
/**
* A discount as the admin form submits it.
*
* Deliberately not `DiscountInput` from `@sport/validation`: that is the type
* *after* parsing, with defaults already applied, so using it here would force
* every caller to supply fields the schema is perfectly happy to fill in.
*/
export interface DiscountPayload {
code?: string | null;
trigger: 'AUTOMATIC' | 'CODE';
type: 'PERCENTAGE' | 'FIXED_AMOUNT';
scope?: 'ORDER' | 'PRODUCT';
value: number;
translations: Record<string, { name: string; description?: string | null }>;
minSubtotalAmount?: number | null;
startsAt?: string | null;
endsAt?: string | null;
isActive?: boolean;
usageLimit?: number | null;
stackable?: boolean;
priority?: number;
productIds?: string[];
collectionIds?: string[];
}
/**
* Promotions and coupons, which are one resource.
*
* There is no `coupons` resource next to this one, and that is the point: a
* coupon is a promotion that needs a code typed (ADR-0020). Two client
* resources would reintroduce the split the data model exists to avoid.
*/
export interface PromotionsAdminResource {
list(params?: DiscountListParams): Promise<OffsetPaginated<AdminDiscount>>;
getById(id: string): Promise<AdminDiscount>;
create(payload: DiscountPayload): Promise<AdminDiscount>;
update(id: string, payload: DiscountPayload): Promise<AdminDiscount>;
remove(id: string): Promise<void>;
}
export function createPromotionsAdminResource(http: HttpClient): PromotionsAdminResource {
// Never cached. An operator who just switched a promotion off must see it
// off — a stale list here is a discount they believe they stopped giving.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminDiscount>>('/admin/discounts', {
...uncached,
query: { ...params },
}),
getById: (id) =>
http.get<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
create: (payload) => http.post<AdminDiscount>('/admin/discounts', payload, uncached),
update: (id, payload) =>
http.patch<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, payload, uncached),
remove: (id) => http.delete<void>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
};
}
@@ -0,0 +1,108 @@
import type {
AdminReview,
Locale,
OffsetPaginated,
ReviewSummary,
ReviewableItem,
StorefrontReview,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
export interface ReviewListParams {
page?: number;
perPage?: number;
sort?: 'newest' | 'rating_desc' | 'rating_asc';
}
export type ProductReviews = OffsetPaginated<StorefrontReview> & { summary: ReviewSummary };
/** Proof of purchase: the order id from the confirmation URL plus its email. */
export interface ReviewCredentials {
orderId: string;
email: string;
}
export interface SubmitReviewPayload extends ReviewCredentials {
orderLineId: string;
rating: number;
title?: string | null;
body?: string | null;
authorName: string;
}
export interface AdminReviewListParams {
page?: number;
perPage?: number;
status?: 'PENDING' | 'APPROVED' | 'REJECTED';
q?: string;
}
export interface ReviewsResource {
/** Approved reviews for a product, with the summary the header needs. */
listForProduct(
productId: string,
params?: ReviewListParams,
options?: RequestOptions,
): Promise<ProductReviews>;
/**
* What an order entitles its buyer to review.
*
* A POST because the email is proof of ownership — in a query string it would
* land in server logs, browser history, and the Referer header sent with
* every image on the page.
*/
listReviewable(
credentials: ReviewCredentials,
locale?: Locale,
): Promise<readonly ReviewableItem[]>;
submit(payload: SubmitReviewPayload, locale?: Locale): Promise<readonly ReviewableItem[]>;
}
export interface ReviewsAdminResource {
list(params?: AdminReviewListParams): Promise<OffsetPaginated<AdminReview>>;
moderate(
id: string,
decision: { status: 'APPROVED' | 'REJECTED'; note?: string | null },
): Promise<AdminReview>;
}
export function createReviewsResource(http: HttpClient): ReviewsResource {
return {
listForProduct: (productId, params = {}, options = {}) =>
http.get<ProductReviews>(`/reviews/product/${encodeURIComponent(productId)}`, {
...options,
query: { ...params },
}),
listReviewable: (credentials, locale) =>
http.post<readonly ReviewableItem[]>('/reviews/reviewable', credentials, {
cache: 'no-store',
query: locale ? { locale } : undefined,
}),
submit: (payload, locale) =>
http.post<readonly ReviewableItem[]>('/reviews', payload, {
cache: 'no-store',
query: locale ? { locale } : undefined,
}),
};
}
export function createReviewsAdminResource(http: HttpClient): ReviewsAdminResource {
// A moderation queue must never be served from cache: two operators working
// the same queue would otherwise both see, and both action, the same review.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminReview>>('/admin/reviews', {
...uncached,
query: { ...params },
}),
moderate: (id, decision) =>
http.patch<AdminReview>(`/admin/reviews/${encodeURIComponent(id)}`, decision, uncached),
};
}
+5 -1
View File
@@ -43,8 +43,12 @@ export const PERMISSIONS = {
CUSTOMER_DELETE: 'customer.delete', CUSTOMER_DELETE: 'customer.delete',
// Marketing // Marketing
//
// There is no separate `coupon.manage`. A coupon is a promotion that needs a
// code typed (ADR-0020) — one entity, one permission. A second permission
// would imply a separation the data model deliberately does not have, and
// would let a role manage half of one screen.
PROMOTION_MANAGE: 'promotion.manage', PROMOTION_MANAGE: 'promotion.manage',
COUPON_MANAGE: 'coupon.manage',
REVIEW_MODERATE: 'review.moderate', REVIEW_MODERATE: 'review.moderate',
// Content // Content
+6
View File
@@ -1,5 +1,7 @@
import type { Id, Money, Nullable } from '../primitives'; import type { Id, Money, Nullable } from '../primitives';
import type { AppliedDiscount, RejectedDiscount } from './discount';
/** /**
* The cart as the storefront sees it. * The cart as the storefront sees it.
* *
@@ -14,6 +16,10 @@ export interface Cart {
readonly totals: CartTotals; readonly totals: CartTotals;
/** Lines dropped since the cart was last seen, so the UI can explain itself. */ /** Lines dropped since the cart was last seen, so the UI can explain itself. */
readonly notices: readonly CartNotice[]; readonly notices: readonly CartNotice[];
/** Discounts currently taking money off, automatic and coded alike. */
readonly discounts: readonly AppliedDiscount[];
/** Codes the shopper entered that did not apply, and why. */
readonly rejectedDiscounts: readonly RejectedDiscount[];
readonly updatedAt: string; readonly updatedAt: string;
} }
+71
View File
@@ -0,0 +1,71 @@
import type { Id, Money, Nullable } from '../primitives';
export const DISCOUNT_TRIGGERS = { AUTOMATIC: 'AUTOMATIC', CODE: 'CODE' } as const;
export type DiscountTrigger = (typeof DISCOUNT_TRIGGERS)[keyof typeof DISCOUNT_TRIGGERS];
export const DISCOUNT_TYPES = {
PERCENTAGE: 'PERCENTAGE',
FIXED_AMOUNT: 'FIXED_AMOUNT',
} as const;
export type DiscountType = (typeof DISCOUNT_TYPES)[keyof typeof DISCOUNT_TYPES];
export const DISCOUNT_SCOPES = { ORDER: 'ORDER', PRODUCT: 'PRODUCT' } as const;
export type DiscountScope = (typeof DISCOUNT_SCOPES)[keyof typeof DISCOUNT_SCOPES];
/**
* A discount as the shopper sees it once applied.
*
* `amount` is what this discount actually took off *this* cart — computed by
* the API, never by the client, and re-computed at checkout. The rule that
* produced it is deliberately not exposed: a shopper needs the name and the
* saving, not the engine's reasoning.
*/
export interface AppliedDiscount {
readonly id: Id;
readonly code: Nullable<string>;
readonly name: string;
readonly amount: Money;
}
/** Why a code the shopper typed did not work. */
export const DISCOUNT_REJECTIONS = {
NOT_FOUND: 'NOT_FOUND',
EXPIRED: 'EXPIRED',
NOT_STARTED: 'NOT_STARTED',
USAGE_LIMIT_REACHED: 'USAGE_LIMIT_REACHED',
MINIMUM_NOT_MET: 'MINIMUM_NOT_MET',
NOTHING_ELIGIBLE: 'NOTHING_ELIGIBLE',
NOT_COMBINABLE: 'NOT_COMBINABLE',
} as const;
export type DiscountRejectionReason =
(typeof DISCOUNT_REJECTIONS)[keyof typeof DISCOUNT_REJECTIONS];
export interface RejectedDiscount {
readonly code: string;
readonly reason: DiscountRejectionReason;
/** Present for MINIMUM_NOT_MET, so the UI can say how much more is needed. */
readonly minimumSubtotal: Nullable<Money>;
}
/** Admin view. Everything the engine reads, plus its redemption count. */
export interface AdminDiscount {
readonly id: Id;
readonly code: Nullable<string>;
readonly trigger: DiscountTrigger;
readonly type: DiscountType;
readonly scope: DiscountScope;
readonly value: number;
readonly translations: Readonly<Record<string, { name: string; description: Nullable<string> }>>;
readonly minSubtotalAmount: Nullable<number>;
readonly startsAt: Nullable<string>;
readonly endsAt: Nullable<string>;
readonly isActive: boolean;
readonly usageLimit: Nullable<number>;
readonly usageCount: number;
readonly stackable: boolean;
readonly priority: number;
readonly productIds: readonly Id[];
readonly collectionIds: readonly Id[];
readonly createdAt: string;
}
+89
View File
@@ -0,0 +1,89 @@
import type { Id, Nullable, Slug } from '../primitives';
export const REVIEW_STATUSES = {
PENDING: 'PENDING',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
} as const;
export type ReviewStatus = (typeof REVIEW_STATUSES)[keyof typeof REVIEW_STATUSES];
export const REVIEW_RATING_MIN = 1;
export const REVIEW_RATING_MAX = 5;
/** A published review, as a shopper sees it. */
export interface StorefrontReview {
readonly id: Id;
readonly rating: number;
readonly title: Nullable<string>;
readonly body: Nullable<string>;
readonly authorName: string;
readonly createdAt: string;
/**
* Always true, and sent anyway.
*
* Every review in this system is anchored to an order line, so there is no
* such thing as an unverified one. The field exists because the badge is
* worth showing, and because a future import of legacy reviews would need to
* say so honestly rather than by omission.
*/
readonly isVerifiedPurchase: boolean;
}
/**
* How a product's ratings are distributed.
*
* Sent alongside the average because they answer different questions: a 3.0
* average made of straight 3s is a mediocre product, and a 3.0 made of 5s and
* 1s is a product with a sizing problem. The bar chart is what makes that
* visible, and it cannot be reconstructed from the average.
*/
export interface ReviewDistribution {
readonly rating: number;
readonly count: number;
}
export interface ReviewSummary {
readonly average: number;
readonly count: number;
readonly distribution: readonly ReviewDistribution[];
}
/**
* An item a shopper is entitled to review, resolved from one order.
*
* `reviewId` non-null means they already did — the form renders as a thank-you
* rather than disappearing, so the action they took is still visible.
*/
export interface ReviewableItem {
readonly orderLineId: Id;
readonly productId: Id;
readonly productSlug: Slug;
readonly productName: string;
readonly variantTitle: string;
readonly imageUrl: Nullable<string>;
readonly reviewId: Nullable<Id>;
readonly reviewStatus: Nullable<ReviewStatus>;
readonly rating: Nullable<number>;
}
/** A review in the moderation queue, with everything needed to judge it. */
export interface AdminReview {
readonly id: Id;
readonly status: ReviewStatus;
readonly rating: number;
readonly title: Nullable<string>;
readonly body: Nullable<string>;
readonly authorName: string;
readonly productId: Id;
readonly productName: string;
readonly productSlug: Slug;
readonly variantTitle: string;
/** The order it came from — the moderator's link back to the purchase. */
readonly orderNumber: number;
readonly orderId: Id;
readonly moderationNote: Nullable<string>;
readonly moderatedAt: Nullable<string>;
readonly moderatedByName: Nullable<string>;
readonly createdAt: string;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Locale } from '../i18n/locale';
import type { Id, Nullable, Slug } from '../primitives';
export const CONTENT_TYPES = {
PAGE: 'PAGE',
POST: 'POST',
} as const;
export type ContentType = (typeof CONTENT_TYPES)[keyof typeof CONTENT_TYPES];
export const CONTENT_STATUSES = {
DRAFT: 'DRAFT',
PUBLISHED: 'PUBLISHED',
} as const;
export type ContentStatus = (typeof CONTENT_STATUSES)[keyof typeof CONTENT_STATUSES];
/** A post as it appears in the feed — no body, because a list does not need one. */
export interface ContentSummary {
readonly id: Id;
readonly type: ContentType;
readonly slug: Slug;
readonly title: string;
readonly excerpt: Nullable<string>;
readonly coverImageUrl: Nullable<string>;
readonly publishedAt: Nullable<string>;
}
/** A page or post as a reader sees it. */
export interface ContentDetail extends ContentSummary {
/** Markdown. Rendered to React elements, never injected as HTML. */
readonly body: string;
readonly authorName: Nullable<string>;
readonly seo: {
readonly metaTitle: Nullable<string>;
readonly metaDescription: Nullable<string>;
};
/**
* This entry's slug in every locale.
*
* Same reason products carry them: without these the language switcher
* 404s, because `/en/blog/<vi-slug>` is not a page.
*/
readonly alternateSlugs: Readonly<Partial<Record<Locale, Slug>>>;
}
export interface ContentTranslationFields {
readonly slug: string;
readonly title: string;
readonly excerpt: Nullable<string>;
readonly body: string;
readonly metaTitle: Nullable<string>;
readonly metaDescription: Nullable<string>;
}
/** A page or post as an operator edits it — every locale, published or not. */
export interface AdminContentEntry {
readonly id: Id;
readonly type: ContentType;
readonly status: ContentStatus;
readonly publishedAt: Nullable<string>;
readonly coverImageId: Nullable<Id>;
readonly coverImageUrl: Nullable<string>;
readonly authorName: Nullable<string>;
readonly translations: Readonly<Record<string, ContentTranslationFields>>;
readonly createdAt: string;
readonly updatedAt: string;
}
+3
View File
@@ -26,4 +26,7 @@ export * from './catalog/admin';
export * from './i18n/locale'; export * from './i18n/locale';
export * from './inventory/stock'; export * from './inventory/stock';
export * from './commerce/cart'; export * from './commerce/cart';
export * from './commerce/discount';
export * from './commerce/order'; export * from './commerce/order';
export * from './commerce/review';
export * from './content/entry';
+114
View File
@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { LOCALES } from '@sport/types';
import { anyIdSchema } from './common'; import { anyIdSchema } from './common';
import { offsetPageQuerySchema } from './pagination'; import { offsetPageQuerySchema } from './pagination';
@@ -65,3 +67,115 @@ export type ShippingAddressInput = z.output<typeof shippingAddressSchema>;
export type PlaceOrderInput = z.output<typeof placeOrderSchema>; export type PlaceOrderInput = z.output<typeof placeOrderSchema>;
export type OrderListQuery = z.output<typeof orderListQuerySchema>; export type OrderListQuery = z.output<typeof orderListQuerySchema>;
export type UpdateOrderStatusInput = z.output<typeof updateOrderStatusSchema>; export type UpdateOrderStatusInput = z.output<typeof updateOrderStatusSchema>;
/** A discount code as typed. Normalised to uppercase before it is stored. */
export const applyDiscountCodeSchema = z.object({
code: z.string().trim().min(1).max(40),
});
const discountTranslationSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullish(),
});
/**
* A discount as an operator authors it.
*
* `value` means different things per `type` — whole percent, or minor units —
* so it is range-checked against the type rather than in isolation. A 500%
* discount is a typo, and catching it here is cheaper than in the ledger.
*/
export const discountInputSchema = z
.object({
code: z
.string()
.trim()
.toUpperCase()
.min(3)
.max(40)
.regex(/^[A-Z0-9_-]+$/, 'Use letters, digits, hyphens and underscores')
.nullish(),
trigger: z.enum(['AUTOMATIC', 'CODE']),
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT']),
scope: z.enum(['ORDER', 'PRODUCT']).default('ORDER'),
value: z.int().min(1),
translations: z.partialRecord(z.enum(LOCALES), discountTranslationSchema),
minSubtotalAmount: z.int().min(0).nullish(),
startsAt: z.iso.datetime().nullish(),
endsAt: z.iso.datetime().nullish(),
isActive: z.boolean().default(true),
usageLimit: z.int().min(1).nullish(),
stackable: z.boolean().default(false),
priority: z.int().min(0).max(1000).default(100),
productIds: z.array(anyIdSchema).default([]),
collectionIds: z.array(anyIdSchema).default([]),
})
.refine((value) => value.type !== 'PERCENTAGE' || value.value <= 100, {
message: 'A percentage discount cannot exceed 100',
path: ['value'],
})
.refine((value) => value.trigger !== 'CODE' || Boolean(value.code), {
message: 'A coupon needs a code',
path: ['code'],
})
.refine(
(value) =>
!value.startsAt || !value.endsAt || new Date(value.startsAt) < new Date(value.endsAt),
{ message: 'The end date must be after the start date', path: ['endsAt'] },
);
export const discountListQuerySchema = offsetPageQuerySchema.extend({
q: z.string().trim().max(120).optional(),
trigger: z.enum(['AUTOMATIC', 'CODE']).optional(),
});
export type ApplyDiscountCodeInput = z.output<typeof applyDiscountCodeSchema>;
export type DiscountInput = z.output<typeof discountInputSchema>;
export type DiscountListQuery = z.output<typeof discountListQuerySchema>;
/**
* A review as a shopper submits it.
*
* `orderLineId` is the whole authorisation story: it names the exact purchased
* item being reviewed. Paired with the order id and the email on that order
* (checked server-side), it is the same proof that already lets a guest look up
* their order — no account required, and no way to review something you did not
* buy.
*/
export const submitReviewSchema = z.object({
orderId: anyIdSchema,
/** The email on the order. Proof, not contact details — never stored here. */
email: z.email().max(255),
orderLineId: anyIdSchema,
rating: z.int().min(1).max(5),
title: z.string().trim().max(140).nullish(),
body: z.string().trim().max(2000).nullish(),
authorName: z.string().trim().min(1).max(120),
});
export const reviewListQuerySchema = offsetPageQuerySchema.extend({
/** Newest first by default; `helpful` is deliberately absent until votes are. */
sort: z.enum(['newest', 'rating_desc', 'rating_asc']).default('newest'),
});
export const adminReviewListQuerySchema = offsetPageQuerySchema.extend({
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
q: z.string().trim().max(120).optional(),
});
/**
* A moderation decision.
*
* `PENDING` is not an accepted target: moderation moves a review out of the
* queue, and letting an operator put one back would make "how long has this
* been waiting" unanswerable.
*/
export const moderateReviewSchema = z.object({
status: z.enum(['APPROVED', 'REJECTED']),
note: z.string().trim().max(500).nullish(),
});
export type SubmitReviewInput = z.output<typeof submitReviewSchema>;
export type ReviewListQuery = z.output<typeof reviewListQuerySchema>;
export type AdminReviewListQuery = z.output<typeof adminReviewListQuerySchema>;
export type ModerateReviewInput = z.output<typeof moderateReviewSchema>;
+52
View File
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { LOCALES } from '@sport/types';
import { anyIdSchema, slugSchema } from './common';
import { offsetPageQuerySchema } from './pagination';
const contentTranslationSchema = z.object({
slug: slugSchema,
title: z.string().trim().min(1).max(255),
excerpt: z.string().trim().max(500).nullish(),
/**
* Markdown, and required.
*
* A page with a title and no body is a broken link with extra steps — it
* renders, gets indexed, and tells a reader nothing.
*/
body: z.string().trim().min(1),
metaTitle: z.string().trim().max(255).nullish(),
metaDescription: z.string().trim().max(500).nullish(),
});
/**
* A page or post as an operator authors it.
*
* `translations` is a partial record: publishing an English-only post is a
* legitimate thing to do, and requiring every locale would mean inventing
* Vietnamese copy to get anything live.
*/
export const contentEntryInputSchema = z
.object({
type: z.enum(['PAGE', 'POST']),
status: z.enum(['DRAFT', 'PUBLISHED']).default('DRAFT'),
coverImageId: anyIdSchema.nullish(),
translations: z.partialRecord(z.enum(LOCALES), contentTranslationSchema),
})
.refine((value) => Object.keys(value.translations).length > 0, {
message: 'Write the entry in at least one language',
path: ['translations'],
});
export const contentListQuerySchema = offsetPageQuerySchema.extend({
type: z.enum(['PAGE', 'POST']).optional(),
status: z.enum(['DRAFT', 'PUBLISHED']).optional(),
q: z.string().trim().max(120).optional(),
});
export const postListQuerySchema = offsetPageQuerySchema;
export type ContentEntryInput = z.output<typeof contentEntryInputSchema>;
export type ContentListQuery = z.output<typeof contentListQuerySchema>;
export type PostListQuery = z.output<typeof postListQuerySchema>;
+1
View File
@@ -18,3 +18,4 @@ export * from './catalog';
export * from './users'; export * from './users';
export * from './catalog-admin'; export * from './catalog-admin';
export * from './commerce'; export * from './commerce';
export * from './content';
+874 -4
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -23,5 +23,7 @@ catalog:
react: 19.2.8 react: 19.2.8
react-dom: 19.2.8 react-dom: 19.2.8
next: 16.3.0 next: 16.3.0
react-markdown: 10.1.0
remark-gfm: 4.0.1
tailwindcss: 4.3.3 tailwindcss: 4.3.3
"@tailwindcss/postcss": 4.3.3 "@tailwindcss/postcss": 4.3.3