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
@@ -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 { 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> {
const t = await getTranslations('pages.cms');
return { title: t('title') };
}
export default async function CmsPage() {
export default async function Page() {
const t = await getTranslations('pages.cms');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="cms.read"
milestone="M7 — content"
/>
<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>
<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 { 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> {
const t = await getTranslations('pages.reviews');
return { title: t('title') };
}
export default async function ReviewsPage() {
export default async function Page() {
const t = await getTranslations('pages.reviews');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="review.moderate"
milestone="M7 — marketing"
/>
<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>
<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',
items: [
{ href: '/promotions', labelKey: 'promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
{ href: '/coupons', labelKey: 'coupons', permission: PERMISSIONS.COUPON_MANAGE },
// One entry, not "Promotions" + "Coupons" — see ADR-0020.
{ href: '/discounts', labelKey: 'discounts', permission: PERMISSIONS.PROMOTION_MANAGE },
{ href: '/reviews', labelKey: 'reviews', permission: PERMISSIONS.REVIEW_MODERATE },
{ href: '/cms', labelKey: 'cms', permission: PERMISSIONS.CMS_READ },
],
+158 -10
View File
@@ -65,21 +65,13 @@
"title": "Customers",
"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": {
"title": "Reviews",
"body": "Review moderation queue."
"body": "Every review comes from a verified purchase. Approve or reject before it appears on the storefront."
},
"cms": {
"title": "Content",
"body": "Homepage blocks, banners, blog posts and static pages."
"body": "Journal posts and static pages. Written in Markdown, published per language."
},
"users": {
"title": "Users",
@@ -88,6 +80,10 @@
"roles": {
"title": "Roles",
"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": {
@@ -281,5 +277,157 @@
"total": "Total",
"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",
"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": {
"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": {
"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": {
"title": "Người dùng",
@@ -88,6 +80,10 @@
"roles": {
"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."
},
"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": {
@@ -281,5 +277,157 @@
"total": "Tổng tiền",
"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)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[]
sessions Session[]
customer Customer?
auditLogs AuditLog[]
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[]
sessions Session[]
customer Customer?
auditLogs AuditLog[]
/// Reviews this operator approved or rejected.
moderatedReviews Review[]
/// Pages and posts this operator wrote.
authoredContent ContentEntry[]
@@index([type, status])
@@index([createdAt])
@@ -281,6 +285,7 @@ model MediaAsset {
collectionBanners Collection[] @relation("CollectionBanner")
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
userAvatars User[] @relation("UserAvatar")
contentCovers ContentEntry[] @relation("ContentCover")
@@index([kind, createdAt])
@@map("media_assets")
@@ -397,6 +402,7 @@ model Collection {
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
products ProductCollection[]
translations CollectionTranslation[]
discounts DiscountCollection[]
@@index([isActive, startsAt, endsAt])
@@map("collections")
@@ -501,11 +507,25 @@ model Product {
isOnSale Boolean @default(false) @map("is_on_sale")
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)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
searchDocuments SearchDocument[]
reviews Review[]
discounts DiscountProduct[]
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
options ProductOption[]
@@ -935,15 +955,15 @@ model Order {
totalAmount Int @map("total_amount")
/// ---- Shipping address, snapshot ----------------------------------------
shipFullName String @map("ship_full_name") @db.VarChar(160)
shipPhone String @map("ship_phone") @db.VarChar(20)
shipLine1 String @map("ship_line1") @db.VarChar(255)
shipLine2 String? @map("ship_line2") @db.VarChar(255)
shipWard String? @map("ship_ward") @db.VarChar(120)
shipDistrict String? @map("ship_district") @db.VarChar(120)
shipProvince String @map("ship_province") @db.VarChar(120)
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
shipFullName String @map("ship_full_name") @db.VarChar(160)
shipPhone String @map("ship_phone") @db.VarChar(20)
shipLine1 String @map("ship_line1") @db.VarChar(255)
shipLine2 String? @map("ship_line2") @db.VarChar(255)
shipWard String? @map("ship_ward") @db.VarChar(120)
shipDistrict String? @map("ship_district") @db.VarChar(120)
shipProvince String @map("ship_province") @db.VarChar(120)
shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2)
shipPostalCode String? @map("ship_postal_code") @db.VarChar(20)
customerNote String? @map("customer_note") @db.VarChar(1000)
@@ -955,8 +975,9 @@ model Order {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
lines OrderLine[]
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
lines OrderLine[]
redemptions DiscountRedemption[]
@@index([customerId])
@@index([email])
@@ -987,6 +1008,7 @@ model OrderLine {
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
review Review?
@@index([orderId])
@@index([variantId])
@@ -1013,11 +1035,11 @@ model SearchDocument {
locale Locale
/// 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.
keywords String
/// Weight C — descriptions. Long, low-signal, still worth matching.
body String
body String
/// Maintained by Postgres from the columns above. Declared here only so
/// Prisma knows it exists and leaves it alone; it is read and written with
@@ -1031,3 +1053,296 @@ model SearchDocument {
@@id([productId, locale])
@@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 {
addCartLineSchema,
applyDiscountCodeSchema,
updateCartLineSchema,
type AddCartLineInput,
type ApplyDiscountCodeInput,
type UpdateCartLineInput,
} from '@sport/validation';
@@ -88,6 +90,28 @@ export class CartsController {
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.
*
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MediaUrlModule } from '@/common/media/media.module';
import { PromotionsModule } from '@/modules/promotions/promotions.module';
import { CartsController } from './carts.controller';
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.
*/
@Module({
imports: [MediaUrlModule],
imports: [MediaUrlModule, PromotionsModule],
controllers: [CartsController],
providers: [CartsService],
exports: [CartsService],
+89 -9
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import {
CART_NOTICE_REASONS,
DISCOUNT_REJECTIONS,
type Cart,
type CartLine,
type CartNotice,
@@ -19,6 +20,7 @@ import { MediaUrlService } from '@/common/media/media-url.service';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { PromotionsService, type EvaluationLine } from '@/modules/promotions/public';
/**
* What actually lives in Redis.
@@ -37,6 +39,14 @@ interface StoredLine {
interface StoredCart {
id: string;
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;
}
@@ -48,6 +58,7 @@ export class CartsService {
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly mediaUrl: MediaUrlService,
private readonly promotions: PromotionsService,
) {}
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);
}
/** 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> {
await this.redis.delete(CACHE_KEYS.guestCart(cartToken));
}
@@ -125,7 +177,10 @@ export class CartsService {
private async read(cartToken: string): Promise<StoredCart> {
const stored = await this.redis.get<StoredCart>(CACHE_KEYS.guestCart(cartToken));
return stored ?? { id: cartToken, lines: [], updatedAt: new Date().toISOString() };
// `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> {
@@ -176,6 +231,7 @@ export class CartsService {
},
},
},
productId: true,
product: {
select: {
name: true,
@@ -194,6 +250,7 @@ export class CartsService {
const byId = new Map(variants.map((variant) => [variant.id, variant]));
const lines: CartLine[] = [];
const evaluationLines: EvaluationLine[] = [];
const notices: CartNotice[] = [];
const keep: StoredLine[] = [];
@@ -245,7 +302,7 @@ export class CartsService {
const currency = variant.currency as CurrencyCode;
const unit = variant.salePriceAmount ?? variant.priceAmount;
lines.push({
const cartLine: CartLine = {
variantId: variant.id,
productName,
productSlug: coalesceRequired(translation?.slug, variant.product.slug),
@@ -260,9 +317,15 @@ export class CartsService {
quantity,
lineTotal: { amount: unit * quantity, currency },
maxQuantity: Math.min(available, MAX_LINE_QUANTITY),
});
};
lines.push(cartLine);
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
@@ -281,16 +344,26 @@ export class CartsService {
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 {
id: cartToken,
lines,
totals: this.totals(lines),
totals: this.totals(lines, discounts.totalDiscount),
notices,
discounts: discounts.applied,
rejectedDiscounts: discounts.rejected,
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 subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0);
const money = (amount: number): Money => ({ amount, currency });
@@ -298,12 +371,14 @@ export class CartsService {
return {
itemCount: lines.reduce((count, line) => count + line.quantity, 0),
subtotal: money(subtotal),
// Promotions are M7 and shipping is M9. Named zeroes rather than an
// absent field, so the total is always the sum of its parts.
discount: money(0),
discount: money(discount),
// Shipping is M9. A named zero rather than an absent field, so the total
// is always the sum of parts a shopper can see.
shipping: 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),
},
notices: [],
discounts: [],
rejectedDiscounts: [],
updatedAt,
};
}
@@ -351,3 +428,6 @@ function variantTitle(variant: {
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 { OrdersModule } from '@/modules/orders/orders.module';
import { PromotionsModule } from '@/modules/promotions/promotions.module';
import { CheckoutController } from './checkout.controller';
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.
*/
@Module({
imports: [CartsModule, OrdersModule],
imports: [CartsModule, OrdersModule, PromotionsModule],
controllers: [CheckoutController],
providers: [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 { CartsService } from '@/modules/carts/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. */
interface IdempotencyRecord {
@@ -36,6 +37,7 @@ export class CheckoutService {
private readonly redis: RedisService,
private readonly carts: CartsService,
private readonly orders: OrdersService,
private readonly promotions: PromotionsService,
) {}
/** 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({
data: {
email: input.email,
phone: input.shippingAddress.phone,
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,
shippingAmount: cart.totals.shipping.amount,
taxAmount: cart.totals.tax.amount,
@@ -194,6 +208,8 @@ export class CheckoutService {
select: { id: true, number: true },
});
await this.promotions.redeem(tx, order.id, cart.discounts);
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 { 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.
*
* Anatomy once implemented (see ../README.md):
* cms.module.ts wiring only
* 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
* 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,
* and the design would stop being code — which is the thing this project exists
* to avoid.
*/
@Module({})
@Module({
imports: [MediaUrlModule],
controllers: [CmsController, CmsAdminController],
providers: [CmsService, CmsRepository],
exports: [CmsService],
})
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
* else — repository, DTOs, internal services — is private, and the ESLint
* 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 { PromotionsModule } from '@/modules/promotions/promotions.module';
import { OrdersAdminController } from './orders.controller';
import { OrdersMapper } from './orders.mapper';
import { OrdersRepository } from './orders.repository';
@@ -16,6 +18,7 @@ import { OrdersService } from './orders.service';
* EXTRACTION CANDIDATE.
*/
@Module({
imports: [PromotionsModule],
controllers: [OrdersAdminController],
providers: [OrdersService, OrdersRepository, OrdersMapper],
exports: [OrdersService],
@@ -15,6 +15,7 @@ import type { OrderListQuery, UpdateOrderStatusInput } from '@sport/validation';
import { AuditService } from '@/common/audit/audit.service';
import { AppException } from '@/common/errors/app.exception';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { PromotionsService } from '@/modules/promotions/public';
import { OrdersMapper, parseOrderNumber } from './orders.mapper';
import { OrdersRepository } from './orders.repository';
@@ -42,6 +43,7 @@ export class OrdersService {
private readonly repository: OrdersRepository,
private readonly mapper: OrdersMapper,
private readonly audit: AuditService,
private readonly promotions: PromotionsService,
) {}
async getById(id: string): Promise<Order> {
@@ -165,6 +167,9 @@ export class OrdersService {
if (to === ORDER_STATUSES.CANCELLED) {
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) {
@@ -17,6 +17,7 @@ import {
type StorefrontProduct,
type StorefrontVariant,
type VariantAvailability,
type ProductRatingSummary,
} from '@sport/types';
import { coalesce, coalesceRequired, pickTranslation } from '@/common/i18n';
@@ -50,8 +51,7 @@ export class ProductsMapper {
priceRange: this.priceRangeOf(row.variants),
isOnSale: row.isOnSale,
colorSwatches: this.colorSwatchesOf(row.options, locale),
// Reviews land in M7; the field exists so the card layout is final now.
rating: null,
rating: ratingOf(row),
};
}
@@ -158,7 +158,7 @@ export class ProductsMapper {
}),
variants,
priceRange: this.priceRangeOf(row.variants),
rating: null,
rating: ratingOf(row),
breadcrumbs,
alternateSlugs: Object.fromEntries(
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 {
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,
slug: 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,
brand: { select: { id: true, name: true, translations: true } },
images: { orderBy: { position: 'asc' }, take: 4, select: imageSelect },
@@ -64,6 +68,8 @@ const detailSelect = {
id: true,
name: true,
slug: true,
ratingSum: true,
ratingCount: true,
description: true,
shortDescription: 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 { 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.
*
* Anatomy once implemented (see ../README.md):
* 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
* The arithmetic lives in `discount-engine.ts` as a pure function: no clock, no
* database. This service resolves eligibility — which is precisely the part
* that needs both — and hands a snapshot to the engine.
*/
@Module({})
@Module({
controllers: [PromotionsAdminController],
providers: [PromotionsService, PromotionsAdminService],
exports: [PromotionsService],
})
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.
*/
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.
*/
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 { 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.
*
* Anatomy once implemented (see ../README.md):
* reviews.module.ts wiring only
* reviews.controller.ts HTTP surface, no logic
* reviews.service.ts business rules
* reviews.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* 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
* ProductsService is the only writer of the price projection. The alternative,
* computing an average per page view, is a scan of every review on the busiest
* query in the catalog.
*/
@Module({})
@Module({
controllers: [ReviewsController, ReviewsAdminController],
providers: [ReviewsService, ReviewsRepository],
exports: [ReviewsService],
})
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",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "catalog:",
"remark-gfm": "catalog:",
"tw-animate-css": "1.4.0",
"zod": "catalog:",
"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 { 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 }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'placeholder.blog' });
return { title: t('title') };
const t = await getTranslations({ locale, namespace: 'blog' });
return { title: t('title'), description: t('intro') };
}
export default async function BlogPage({ params }: PageProps) {
const { locale } = await params;
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 { ProductDetail } from '@/components/commerce/product-detail';
import { ProductReviews } from '@/components/commerce/product-reviews';
import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { fetchProduct } from '@/lib/catalog';
@@ -130,6 +131,8 @@ export default async function ProductPage({ params }: PageProps) {
</nav>
<ProductDetail product={product} />
<ProductReviews productId={product.id} />
</div>
);
}
@@ -12,6 +12,8 @@ import { Link } from '@/i18n/navigation';
import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
import { DiscountForm } from './discount-form';
/**
* The bag.
*
@@ -151,8 +153,18 @@ export function CartView() {
<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>
<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">
<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
of parts a shopper can see, even before shipping exists (M9). */}
<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>
<dd>{formatMoney(cart.totals.subtotal, format)}</dd>
</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">
<dt className="text-ink-500">{t('shipping')}</dt>
<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 { routes } from '@/lib/routes';
import { ReviewForm } from './review-form';
/**
* Order confirmation, fetched by number + email.
*
@@ -131,6 +133,8 @@ export function OrderConfirmation({ orderId }: { orderId: string }) {
</address>
</section>
<ReviewForm orderId={orderId} email={order.email} />
<div className="text-center">
<Button variant="outline" asChild>
<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 { getServerApi } from '@/lib/api';
import { SPORT_NAV, routes } from '@/lib/routes';
export async function SiteFooter({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('footer');
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 (
<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>
</div>
<div className="border-ink-800 text-ink-500 border-t py-6 text-center text-xs">
{t('rights', { year: new Date().getFullYear() })}
<div className="border-ink-800 border-t py-6">
{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>
</footer>
);
@@ -16,6 +16,9 @@ interface CartState {
addLine: (variantId: string, quantity?: number) => Promise<boolean>;
setQuantity: (variantId: string, quantity: number) => 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>;
}
@@ -55,6 +58,28 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
}, []);
/** 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 () => {
try {
setCart(await browserApi.commerce.getCart(locale));
@@ -103,9 +128,13 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
removeLine: async (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,
}),
[cart, loading, pending, error, locale, run, refresh],
[cart, loading, pending, error, locale, run, runReturning, refresh],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+2
View File
@@ -28,6 +28,8 @@ export const routes = {
accountWishlist: () => '/account/wishlist',
blog: () => '/blog',
post: (slug: string) => `/blog/${slug}`,
page: (slug: string) => `/pages/${slug}`,
} as const;
/** The sport facets that back `/sports/[sport]`. */
+46 -2
View File
@@ -106,7 +106,8 @@
"orders": "Orders",
"wishlist": "Wishlist",
"journal": "Journal",
"rights": "© {year} Sport Store."
"rights": "© {year} Sport Store.",
"policies": "Information"
},
"account": {
"title": "Account",
@@ -173,6 +174,21 @@
"reduced": "{name} was reduced to {quantity} — that is all we have left.",
"soldOut": "{name} sold out and was removed from your bag.",
"unavailable": "An item is no longer available and was removed from your bag."
},
"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": {
@@ -198,7 +214,8 @@
"failed": "We could not place your order.",
"loading": "Loading your bag…",
"emptyBag": "There is nothing in your bag.",
"backToShop": "Back to shopping"
"backToShop": "Back to shopping",
"discount": "Discount"
},
"confirmation": {
"title": "Order placed",
@@ -210,5 +227,32 @@
"keepShopping": "Keep shopping",
"notFound": "We could not find that order.",
"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",
"wishlist": "Yêu thích",
"journal": "Bài viết",
"rights": "© {year} Sport Store."
"rights": "© {year} Sport Store.",
"policies": "Thông tin"
},
"account": {
"title": "Tài khoản",
@@ -173,6 +174,21 @@
"reduced": "{name} đã giảm còn {quantity} — đó là số hàng còn lại.",
"soldOut": "{name} đã hết hàng và được bỏ khỏi giỏ.",
"unavailable": "Một sản phẩm không còn bán và đã được bỏ khỏi giỏ."
},
"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": {
@@ -198,7 +214,8 @@
"failed": "Không đặt được đơn hàng.",
"loading": "Đang tải giỏ hàng…",
"emptyBag": "Giỏ hàng của bạn đang trống.",
"backToShop": "Quay lại mua sắm"
"backToShop": "Quay lại mua sắm",
"discount": "Giảm giá"
},
"confirmation": {
"title": "Đặt hàng thành công",
@@ -210,5 +227,32 @@
"keepShopping": "Tiếp tục mua sắm",
"notFound": "Không tìm thấy đơn hàng.",
"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}"
}
}