Stage M7
This commit is contained in:
@@ -158,7 +158,7 @@ sport-store/
|
||||
│
|
||||
├── docs/
|
||||
│ ├── architecture.md Boundaries, conventions, risks — read this first
|
||||
│ └── adr/ 19 decision records
|
||||
│ └── adr/ 22 decision records
|
||||
│
|
||||
├── docker-compose.yml Backing services; `--profile full` runs everything
|
||||
├── turbo.json pnpm-workspace.yaml package.json
|
||||
@@ -253,11 +253,11 @@ locale-in-path would buy nothing.
|
||||
| **M4** ✅ | Storefront catalog — listings, PDP, variant selector, filters, sort control, load-more pagination and a mobile filter sheet |
|
||||
| **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle |
|
||||
| **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results |
|
||||
| **M7** | Promotions, coupons, reviews, CMS |
|
||||
| **M7** ◐ | Discounts end-to-end: one engine, one admin screen, stacking, windows, limits, redemptions. Reviews and CMS remain |
|
||||
| **M8** | Customer account |
|
||||
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
|
||||
|
||||
**Recommended next step: M7 (promotions, coupons, reviews, CMS) or M9 (payments).** The store can
|
||||
**Recommended next step: M8 (customer accounts).** The store can
|
||||
now be browsed, searched, filled into a bag and checked out, and every order moves stock through a
|
||||
ledger. What it still cannot do is take money — which is the one gap between this and a shop that
|
||||
trades.
|
||||
@@ -270,9 +270,9 @@ Everything below was run, not assumed:
|
||||
|
||||
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass;
|
||||
`pnpm format:check` clean
|
||||
- 7 migrations, 35 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
|
||||
- 10 migrations, 43 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
|
||||
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
|
||||
- **60 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
|
||||
- **78 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the
|
||||
variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory
|
||||
list's variant-driven projection, the two admin-schema defects that caused silent data loss, and
|
||||
order-number round-tripping
|
||||
@@ -303,6 +303,24 @@ through Chrome with the console and network panel open:
|
||||
media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG
|
||||
landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table
|
||||
wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request
|
||||
- **Discounts (M7):** an automatic promotion and a coupon stack to −150.000 ₫ on a 1.290.000 ₫
|
||||
bag; a lowercase code is accepted; a fully-claimed code is refused with a reason and the bag
|
||||
falls back to the automatic offer; a two-use limit allows exactly two orders; cancelling an
|
||||
order returns its use; two simultaneous redemptions of the last use produce one discounted
|
||||
order and one clear refusal; a bogus code reports once and is not remembered
|
||||
- **The discount admin (M7):** a promotion authored at 09:00 local stores as 02:00Z and reopens
|
||||
at 09:00, not shifted; a discount scheduled for next week reads "Scheduled" and stays out of
|
||||
the bag; switching one off in admin drops it from a shopper's bag on the next load; retiring
|
||||
one soft-deletes it, leaving redemptions and the audit trail intact
|
||||
- **Content (M7):** a post published in one language still lists on the other locale's journal
|
||||
rather than vanishing; a body containing `<script>` and `<img onerror>` renders as visible text
|
||||
and executes nothing; a Markdown link to `/men` keeps its locale prefix; a published page
|
||||
appears in the footer; a draft is not reachable from the storefront
|
||||
- **Reviews (M7):** a review can only be written against a line on an order whose email matches,
|
||||
and a wrong email 404s exactly like an unknown order; the same item cannot be reviewed twice;
|
||||
a submitted review stays invisible until approved; approving updates the product's rating
|
||||
immediately rather than after the cache expires; rejecting an approved review takes its stars
|
||||
back out; a rejected review never reaches the storefront
|
||||
- **Checkout is idempotent:** a request without an `Idempotency-Key` is refused; the same key
|
||||
twice returns the _same_ order rather than a second one; two simultaneous requests with one key
|
||||
yield one order and one clear refusal — total order count grows by exactly one in every case
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -75,6 +75,10 @@ model User {
|
||||
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[]
|
||||
@@ -957,6 +977,7 @@ model Order {
|
||||
|
||||
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])
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
<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>;
|
||||
|
||||
@@ -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]`. */
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# ADR-0020: Promotions and coupons are one entity with one engine
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-12
|
||||
|
||||
## Context
|
||||
|
||||
A storefront needs two things that look different and are not: promotions that
|
||||
apply themselves ("20% off jackets this week") and coupons that apply when
|
||||
someone types a code ("SUMMER20").
|
||||
|
||||
Modelled separately they get separate tables, separate admin screens and
|
||||
separate rule evaluation. Then both have to answer the same questions — does
|
||||
this stack with that, what happens when the total would go negative, which wins
|
||||
when two apply, how is a percentage rounded — and the two answers drift. The
|
||||
drift is not caught by tests, because each engine is self-consistent. It is
|
||||
caught by a customer whose total is wrong.
|
||||
|
||||
## Decision
|
||||
|
||||
**One `Discount` entity with a `trigger`.** `AUTOMATIC` applies on its own;
|
||||
`CODE` requires a code. Everything else — type, scope, value, window, usage
|
||||
limit, stacking, priority — is shared, because it genuinely is.
|
||||
|
||||
**The arithmetic is a pure function.** `discount-engine.ts` takes a snapshot of
|
||||
candidates and lines and returns amounts. No database, no clock, no I/O. This is
|
||||
the one place in the system where a rounding mistake is a financial one, so it
|
||||
is the one place that can be exhaustively tested without a database — and it is.
|
||||
|
||||
**Eligibility is resolved outside it.** Whether a discount is live, within its
|
||||
window, or has uses left depends on state the engine deliberately cannot see.
|
||||
`PromotionsService` answers those and hands the engine a decided list.
|
||||
|
||||
**Stacking is `stackable` plus `priority`.** Ascending priority, then id, so the
|
||||
outcome never depends on the order rows came back in. A non-stackable discount
|
||||
that applies ends evaluation; one that would apply after another already has is
|
||||
rejected as `NOT_COMBINABLE`.
|
||||
|
||||
**Percentages floor, never round.** Rounding up hands out a fraction of a đồng
|
||||
the merchant never agreed to, on every order.
|
||||
|
||||
**Each discount applies to what is left, not the original subtotal.** Two 50%
|
||||
offers take 75%, not 100%.
|
||||
|
||||
**Usage limits are claimed with a conditional UPDATE**, guarded on the limit —
|
||||
the same shape as stock reservation (ADR forthcoming in §15 of architecture.md).
|
||||
Two shoppers redeeming the last use simultaneously must not both win.
|
||||
|
||||
**Redemptions are recorded with a snapshot amount**, and cancelling an order
|
||||
hands its uses back.
|
||||
|
||||
## Consequences
|
||||
|
||||
One admin screen, one permission, one set of rules to reason about. A coupon is
|
||||
a promotion that needs a code typed, and the data model says so.
|
||||
|
||||
`coupon.manage` was deleted from the permission catalog rather than left
|
||||
unused. A permission nobody checks is worse than no permission: it reads as a
|
||||
capability a role can be granted, and the first person to grant it will expect
|
||||
it to do something.
|
||||
|
||||
The one place the merge is visible as a compromise is the editor, where
|
||||
`trigger` is the first control on the form — it decides whether the rest of the
|
||||
dialog reads as "a promotion that runs by itself" or "a code a shopper types",
|
||||
so it cannot sit further down.
|
||||
|
||||
Status on the list is derived, never `isActive` alone. A discount that expired
|
||||
last week or burned its last use is still `isActive: true`, and a screen that
|
||||
reports a promotion as running when it is not is worse than no screen.
|
||||
|
||||
The engine's purity is what makes the money maths trustworthy: twelve tests
|
||||
cover flooring, over-discounting, compounding, determinism and empty carts
|
||||
without touching Postgres.
|
||||
|
||||
The cost is that some fields are meaningless for some triggers — an automatic
|
||||
promotion has no `code`. That is enforced in validation rather than by the
|
||||
schema, which is the usual trade for avoiding two near-identical tables. The
|
||||
editor disables the field rather than hiding it, so the rule is visible instead
|
||||
of mysterious.
|
||||
|
||||
Retiring is a soft delete, because `DiscountRedemption.discount` is
|
||||
`onDelete: Restrict`: an order that received a discount must keep pointing at
|
||||
the thing it received. So a retired discount stops applying rather than ceasing
|
||||
to exist, and the admin says "Retire" rather than "Delete" for that reason.
|
||||
|
||||
Per-customer limits are absent: they need customer identity, which arrives with
|
||||
M8. The column is deliberately not there yet rather than present and ignored.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Separate `Promotion` and `Coupon` tables.** Clearer names, two engines that
|
||||
must agree forever. Rejected — the agreement is the hard part, and it does not
|
||||
hold.
|
||||
|
||||
**A rules DSL stored as JSON.** Maximum flexibility, no type safety, and every
|
||||
rule change becomes a data migration nobody can review. Rejected as premature.
|
||||
|
||||
**Computing discounts on the client.** Instant feedback, and it makes the total
|
||||
a negotiation. Every price in this system is the API's to decide (ADR-0011).
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR-0021: A review is anchored to an order line
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
|
||||
## Context
|
||||
|
||||
Product reviews are worth having only if they are worth believing. The usual
|
||||
shape — an open form on the product page, with a "verified purchase" badge
|
||||
awarded to reviews the system can match back to an order — gets this backwards.
|
||||
The badge becomes the exception, unverified reviews become the bulk of the
|
||||
content, and moderation becomes a full-time job rather than a screen someone
|
||||
checks.
|
||||
|
||||
The badge is also weaker than it looks. Matching on product + email means
|
||||
anyone who guesses a customer's address can post as them, and anyone who bought
|
||||
once can review every colourway.
|
||||
|
||||
Meanwhile this store has no customer accounts yet (M8). A design that depends
|
||||
on login would mean no reviews until then.
|
||||
|
||||
## Decision
|
||||
|
||||
**`Review.orderLineId` is unique and required.** A review hangs off the exact
|
||||
purchased item, not off a product plus a claim about who is writing.
|
||||
|
||||
This makes "verified purchase" **structural**. There is no unverified review
|
||||
because there is no row to put one in. `isVerifiedPurchase` is sent to the
|
||||
storefront as a constant `true` — not because the check is skipped, but because
|
||||
the schema already made it unfalsifiable.
|
||||
|
||||
The unique constraint gives **one review per item purchased** for free. Buying
|
||||
the same jacket twice earns two reviews; buying it once earns one.
|
||||
|
||||
**Authorisation is the order id plus the email on that order.** The same bar
|
||||
the guest order lookup already sets, and the same capability URL the
|
||||
confirmation page uses (ADR-0019's neighbour: the id is a UUIDv7, unguessable,
|
||||
and holding the link is the authorisation). No account required, which is what
|
||||
lets reviews ship before M8.
|
||||
|
||||
Wrong email and unknown order return the **same 404**, so the endpoint cannot
|
||||
be used to test whether an order exists.
|
||||
|
||||
**Everything starts `PENDING`.** Publication requires a decision. The
|
||||
alternative — publish then moderate — means the first person to read abuse on a
|
||||
product page is a customer.
|
||||
|
||||
**The rating aggregate lives on `products` as `rating_sum` + `rating_count`,**
|
||||
and is **recomputed**, never incremented. Two integers rather than a stored
|
||||
average, because approving one more 4-star review is `sum + 4, count + 1`:
|
||||
exact, and reversible. See Consequences.
|
||||
|
||||
## Consequences
|
||||
|
||||
A shopper cannot review a product they own but bought elsewhere. That is the
|
||||
correct trade here: the alternative is an open submission endpoint, which is a
|
||||
spam surface needing a moderation _team_ rather than a moderation _screen_.
|
||||
|
||||
Reviews work for guests today and keep working when accounts arrive — an
|
||||
`orderLine` already reaches a `customerId` through its order when there is one.
|
||||
|
||||
Recompute-not-increment is what makes a moderator's decision reversible. Every
|
||||
incremental scheme needs a compensating delta per path (approve, reject,
|
||||
un-reject, edit), and the first missed path leaves a product displaying a
|
||||
rating no review supports, with nothing to notice it by. Recomputing from the
|
||||
approved rows is one statement that is correct from any starting state — and it
|
||||
is exercised: rejecting an approved review takes its stars back out.
|
||||
|
||||
Moderation drops the whole catalog cache. `products.rating_*` is part of the
|
||||
read model, `cache-keys.ts` states the contract as "invalidated on write, TTL
|
||||
as a safety net", and without this a moderator approves a review, reloads the
|
||||
product page, sees the old figure for up to five minutes and concludes the
|
||||
button is broken.
|
||||
|
||||
The distribution is sent alongside the average because they answer different
|
||||
questions. A 3.0 of straight 3s is a mediocre product; a 3.0 of 5s and 1s is a
|
||||
product with a sizing problem. Only the bars distinguish them.
|
||||
|
||||
Ratings are constrained `BETWEEN 1 AND 5` in the database as well as in Zod.
|
||||
The column feeds a stored SUM, so one bad row skews a product's average
|
||||
silently and permanently.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Open reviews with a verified badge.** More content, most of it unverifiable,
|
||||
and a badge that a determined poster can forge by guessing an email.
|
||||
|
||||
**Require an account.** Cleanest identity story, and it would have meant no
|
||||
reviews at all until M8 — while making guest buyers, who are most of them,
|
||||
second-class.
|
||||
|
||||
**Store the average directly.** One column instead of two, and it must be
|
||||
recomputed from scratch to stay honest anyway — at which point it is strictly
|
||||
worse than the pair, since it also accumulates floating-point drift.
|
||||
|
||||
**Publish immediately, moderate later.** Faster for the honest majority, and it
|
||||
puts the store's name under whatever the first bad actor writes.
|
||||
|
||||
**Gate on delivery ("only review what arrived").** Correct in principle and
|
||||
currently unknowable: the store has no shipping integration until M10, so
|
||||
"delivered" is not a fact the system holds. Cancelled orders are refused; the
|
||||
rest are allowed, and the gate tightens when the data exists.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR-0022: Editorial content is Markdown, not a page builder
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
|
||||
## Context
|
||||
|
||||
The store needs a journal and a handful of static pages — returns policy,
|
||||
shipping, about. That is a small requirement with a large gravitational pull:
|
||||
every CMS starts as "just some pages" and ends as a block tree, because the
|
||||
first time an editor wants two columns, someone adds a two-column block.
|
||||
|
||||
Once that happens the storefront's design stops being code. Layout decisions
|
||||
move into the database, the React components become a rendering engine for
|
||||
whatever an editor assembled, and the careful type scale and spacing this
|
||||
project has spent seven milestones establishing become suggestions. That is the
|
||||
outcome this entire project exists to avoid — it is why it is not WooCommerce.
|
||||
|
||||
## Decision
|
||||
|
||||
**One `ContentEntry` with a `type` of `PAGE` or `POST`,** rather than two
|
||||
tables. The shared surface — per-locale slug, title, body, SEO, publish state,
|
||||
soft delete — is nearly all of it. What differs is placement: a POST is listed
|
||||
in a feed newest-first and carries an excerpt and a cover; a PAGE is addressed
|
||||
directly and never listed. Same reasoning as ADR-0020.
|
||||
|
||||
**The body is one Markdown column.** No blocks, no tree, no layout. An editor
|
||||
chooses _what it says_; the storefront decides _what it looks like_.
|
||||
|
||||
**Markdown is rendered to React elements, never to an HTML string.**
|
||||
`react-markdown` parses to a component tree, so `dangerouslySetInnerHTML`
|
||||
appears nowhere in this path. A `<script>` in a body is escaped text because it
|
||||
never becomes a tag — injection is structurally impossible rather than
|
||||
sanitised away, which is the same move as anchoring reviews to order lines
|
||||
(ADR-0021).
|
||||
|
||||
**Every element is mapped explicitly.** Unstyled `<h2>`/`<p>` inheriting
|
||||
browser defaults is precisely how a CMS page ends up looking like a different
|
||||
website. Body `<h1>` is demoted to `<h2>` because the page already renders the
|
||||
title as `<h1>`, and internal links go through the locale-aware `Link` — a raw
|
||||
`<a href="/men">` in a Vietnamese post drops the locale prefix, a bug already
|
||||
fixed three times elsewhere in this storefront.
|
||||
|
||||
**Images in bodies are dropped.** They would bypass the media library, hotlink
|
||||
to arbitrary hosts, and arrive without dimensions — a layout shift on every
|
||||
page they appear on. Posts get one cover image, from the media library, with
|
||||
known dimensions.
|
||||
|
||||
**`publishedAt` is stamped once, on first publish, and never rewritten.**
|
||||
Re-stamping on save would jump a post to the top of the feed because somebody
|
||||
fixed a typo, and would silently change a date a reader may already have cited.
|
||||
|
||||
**Slugs are per-locale**, like products, because `/en/blog/how-to-layer` and
|
||||
`/vi/blog/cach-phoi-do` are the SEO surface. A slug from any locale resolves,
|
||||
then redirects to the canonical one for the locale being browsed.
|
||||
|
||||
**Pages live at `/pages/[slug]`.** Not at the locale root: a catch-all there
|
||||
would compete with `/men`, `/cart` and `/search`, and make every genuine 404
|
||||
ambiguous. `/pages/returns` is uglier than `/returns` and cannot silently
|
||||
shadow a real route.
|
||||
|
||||
## Consequences
|
||||
|
||||
An editor writes in Markdown. That is a real constraint on non-technical staff,
|
||||
and the correct one at this size — the alternative is a rich-text editor whose
|
||||
output must then be sanitised, which is a larger surface than the whole feature.
|
||||
|
||||
Publishing is one decision with two buttons: _Save draft_ and _Publish_. A
|
||||
status dropdown plus Save reads as neither, and publishing is the consequential
|
||||
action.
|
||||
|
||||
Deleting is soft, and also unpublishes. A soft-deleted row still marked
|
||||
`PUBLISHED` is one forgotten `deletedAt: null` away from being live again.
|
||||
|
||||
Translations are replaced wholesale on save rather than upserted per locale, so
|
||||
removing the English version of a post actually stops `/en/blog/<slug>`
|
||||
resolving instead of serving the copy the editor just deleted.
|
||||
|
||||
The `/content/{posts,pages}/slugs` endpoints return titles alongside slugs.
|
||||
They serve three callers — `generateStaticParams`, the footer's page list, and
|
||||
a sitemap when one is built — which is cheaper than three endpoints returning
|
||||
the same rows.
|
||||
|
||||
Homepage blocks are **not** built. The module's original sketch mentioned them;
|
||||
they are the exact feature that turns this into a page builder, and the
|
||||
homepage is better served by code until there is a concrete editorial need.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A block/section tree.** Maximum editorial flexibility, and it hands layout to
|
||||
the database. Rejected — see Context.
|
||||
|
||||
**MDX with embedded components.** Lets a post drop in a product carousel, and
|
||||
makes content executable code that must be built and deployed. Rejected for
|
||||
content authored through an admin UI at runtime.
|
||||
|
||||
**HTML stored directly with sanitisation.** Familiar to editors, and every
|
||||
sanitiser is a denylist that someone eventually gets past. Rendering to React
|
||||
elements needs no denylist.
|
||||
|
||||
**Separate `pages` and `blog_posts` tables.** Clearer names, two admin screens,
|
||||
and two sets of publishing rules that must agree forever.
|
||||
@@ -28,6 +28,9 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
||||
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
|
||||
| [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted |
|
||||
| [0019](./0019-order-placement-is-idempotent-by-client-key.md) | Order placement is idempotent by client key | Accepted |
|
||||
| [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted |
|
||||
| [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted |
|
||||
| [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted |
|
||||
|
||||
## Decisions deliberately NOT recorded yet
|
||||
|
||||
|
||||
@@ -9,7 +9,23 @@ import {
|
||||
type CommerceResource,
|
||||
type OrdersAdminResource,
|
||||
} from './resources/commerce';
|
||||
import {
|
||||
createContentAdminResource,
|
||||
createContentResource,
|
||||
type ContentAdminResource,
|
||||
type ContentResource,
|
||||
} from './resources/content';
|
||||
import { createHealthResource, type HealthResource } from './resources/health';
|
||||
import {
|
||||
createPromotionsAdminResource,
|
||||
type PromotionsAdminResource,
|
||||
} from './resources/promotions-admin';
|
||||
import {
|
||||
createReviewsAdminResource,
|
||||
createReviewsResource,
|
||||
type ReviewsAdminResource,
|
||||
type ReviewsResource,
|
||||
} from './resources/reviews';
|
||||
|
||||
/**
|
||||
* Resource modules are added here as the backend grows — one file per bounded
|
||||
@@ -26,6 +42,11 @@ export interface ApiClient {
|
||||
readonly catalogAdmin: CatalogAdminResource;
|
||||
readonly commerce: CommerceResource;
|
||||
readonly ordersAdmin: OrdersAdminResource;
|
||||
readonly promotionsAdmin: PromotionsAdminResource;
|
||||
readonly reviews: ReviewsResource;
|
||||
readonly reviewsAdmin: ReviewsAdminResource;
|
||||
readonly content: ContentResource;
|
||||
readonly contentAdmin: ContentAdminResource;
|
||||
}
|
||||
|
||||
export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
@@ -40,5 +61,10 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
catalogAdmin: createCatalogAdminResource(http),
|
||||
commerce: createCommerceResource(http),
|
||||
ordersAdmin: createOrdersAdminResource(http),
|
||||
promotionsAdmin: createPromotionsAdminResource(http),
|
||||
reviews: createReviewsResource(http),
|
||||
reviewsAdmin: createReviewsAdminResource(http),
|
||||
content: createContentResource(http),
|
||||
contentAdmin: createContentAdminResource(http),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,4 +36,26 @@ export type {
|
||||
OrdersAdminResource,
|
||||
PlaceOrderInput,
|
||||
} from './resources/commerce';
|
||||
export type {
|
||||
DiscountListParams,
|
||||
DiscountPayload,
|
||||
PromotionsAdminResource,
|
||||
} from './resources/promotions-admin';
|
||||
export type {
|
||||
AdminReviewListParams,
|
||||
ProductReviews,
|
||||
ReviewCredentials,
|
||||
ReviewListParams,
|
||||
ReviewsAdminResource,
|
||||
ReviewsResource,
|
||||
SubmitReviewPayload,
|
||||
} from './resources/reviews';
|
||||
export type {
|
||||
AdminContentListParams,
|
||||
ContentAdminResource,
|
||||
ContentEntryPayload,
|
||||
ContentResource,
|
||||
ContentTranslationPayload,
|
||||
PostListParams,
|
||||
} from './resources/content';
|
||||
export type { ApiClient } from './create-client';
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface CommerceResource {
|
||||
options?: RequestOptions,
|
||||
): Promise<Cart>;
|
||||
removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>;
|
||||
applyDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
|
||||
removeDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
|
||||
|
||||
getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>;
|
||||
/**
|
||||
@@ -119,6 +121,15 @@ export function createCommerceResource(http: HttpClient): CommerceResource {
|
||||
cartOptions(locale, options),
|
||||
),
|
||||
|
||||
applyDiscountCode: (locale, code, options) =>
|
||||
http.post<Cart>('/cart/discounts', { code }, cartOptions(locale, options)),
|
||||
|
||||
removeDiscountCode: (locale, code, options) =>
|
||||
http.delete<Cart>(
|
||||
`/cart/discounts/${encodeURIComponent(code)}`,
|
||||
cartOptions(locale, options),
|
||||
),
|
||||
|
||||
getCheckoutQuote: (locale, options) =>
|
||||
http.get<Cart>('/checkout/quote', cartOptions(locale, options)),
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
AdminContentEntry,
|
||||
ContentDetail,
|
||||
ContentStatus,
|
||||
ContentSummary,
|
||||
ContentType,
|
||||
Locale,
|
||||
OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
|
||||
import type { HttpClient, RequestOptions } from '../http-client';
|
||||
|
||||
export interface PostListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export interface AdminContentListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
type?: ContentType;
|
||||
status?: ContentStatus;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface ContentTranslationPayload {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string | null;
|
||||
body: string;
|
||||
metaTitle?: string | null;
|
||||
metaDescription?: string | null;
|
||||
}
|
||||
|
||||
export interface ContentEntryPayload {
|
||||
type: ContentType;
|
||||
status?: ContentStatus;
|
||||
coverImageId?: string | null;
|
||||
translations: Record<string, ContentTranslationPayload>;
|
||||
}
|
||||
|
||||
/** Editorial content: pages and posts. Published entries only. */
|
||||
export interface ContentResource {
|
||||
listPosts(
|
||||
locale: Locale,
|
||||
params?: PostListParams,
|
||||
options?: RequestOptions,
|
||||
): Promise<OffsetPaginated<ContentSummary>>;
|
||||
getPost(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
|
||||
getPage(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
|
||||
listPostSlugs(
|
||||
locale: Locale,
|
||||
options?: RequestOptions,
|
||||
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
|
||||
listPageSlugs(
|
||||
locale: Locale,
|
||||
options?: RequestOptions,
|
||||
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
|
||||
}
|
||||
|
||||
export interface ContentAdminResource {
|
||||
list(params?: AdminContentListParams): Promise<OffsetPaginated<AdminContentEntry>>;
|
||||
getById(id: string): Promise<AdminContentEntry>;
|
||||
create(payload: ContentEntryPayload): Promise<AdminContentEntry>;
|
||||
update(id: string, payload: ContentEntryPayload): Promise<AdminContentEntry>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createContentResource(http: HttpClient): ContentResource {
|
||||
return {
|
||||
listPosts: (locale, params = {}, options = {}) =>
|
||||
http.get<OffsetPaginated<ContentSummary>>('/content/posts', {
|
||||
...options,
|
||||
query: { locale, ...params },
|
||||
}),
|
||||
getPost: (locale, slug, options = {}) =>
|
||||
http.get<ContentDetail>(`/content/posts/${encodeURIComponent(slug)}`, {
|
||||
...options,
|
||||
query: { locale },
|
||||
}),
|
||||
getPage: (locale, slug, options = {}) =>
|
||||
http.get<ContentDetail>(`/content/pages/${encodeURIComponent(slug)}`, {
|
||||
...options,
|
||||
query: { locale },
|
||||
}),
|
||||
listPostSlugs: (locale, options = {}) =>
|
||||
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/posts/slugs', {
|
||||
...options,
|
||||
query: { locale },
|
||||
}),
|
||||
listPageSlugs: (locale, options = {}) =>
|
||||
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/pages/slugs', {
|
||||
...options,
|
||||
query: { locale },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentAdminResource(http: HttpClient): ContentAdminResource {
|
||||
// Never cached: an editor who just saved a draft must see the draft.
|
||||
const uncached = { cache: 'no-store' } as const;
|
||||
|
||||
return {
|
||||
list: (params = {}) =>
|
||||
http.get<OffsetPaginated<AdminContentEntry>>('/admin/content', {
|
||||
...uncached,
|
||||
query: { ...params },
|
||||
}),
|
||||
getById: (id) =>
|
||||
http.get<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, uncached),
|
||||
create: (payload) => http.post<AdminContentEntry>('/admin/content', payload, uncached),
|
||||
update: (id, payload) =>
|
||||
http.patch<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, payload, uncached),
|
||||
remove: (id) => http.delete<void>(`/admin/content/${encodeURIComponent(id)}`, uncached),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AdminDiscount, OffsetPaginated } from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface DiscountListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
q?: string;
|
||||
trigger?: 'AUTOMATIC' | 'CODE';
|
||||
}
|
||||
|
||||
/**
|
||||
* A discount as the admin form submits it.
|
||||
*
|
||||
* Deliberately not `DiscountInput` from `@sport/validation`: that is the type
|
||||
* *after* parsing, with defaults already applied, so using it here would force
|
||||
* every caller to supply fields the schema is perfectly happy to fill in.
|
||||
*/
|
||||
export interface DiscountPayload {
|
||||
code?: string | null;
|
||||
trigger: 'AUTOMATIC' | 'CODE';
|
||||
type: 'PERCENTAGE' | 'FIXED_AMOUNT';
|
||||
scope?: 'ORDER' | 'PRODUCT';
|
||||
value: number;
|
||||
translations: Record<string, { name: string; description?: string | null }>;
|
||||
minSubtotalAmount?: number | null;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
isActive?: boolean;
|
||||
usageLimit?: number | null;
|
||||
stackable?: boolean;
|
||||
priority?: number;
|
||||
productIds?: string[];
|
||||
collectionIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotions and coupons, which are one resource.
|
||||
*
|
||||
* There is no `coupons` resource next to this one, and that is the point: a
|
||||
* coupon is a promotion that needs a code typed (ADR-0020). Two client
|
||||
* resources would reintroduce the split the data model exists to avoid.
|
||||
*/
|
||||
export interface PromotionsAdminResource {
|
||||
list(params?: DiscountListParams): Promise<OffsetPaginated<AdminDiscount>>;
|
||||
getById(id: string): Promise<AdminDiscount>;
|
||||
create(payload: DiscountPayload): Promise<AdminDiscount>;
|
||||
update(id: string, payload: DiscountPayload): Promise<AdminDiscount>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPromotionsAdminResource(http: HttpClient): PromotionsAdminResource {
|
||||
// Never cached. An operator who just switched a promotion off must see it
|
||||
// off — a stale list here is a discount they believe they stopped giving.
|
||||
const uncached = { cache: 'no-store' } as const;
|
||||
|
||||
return {
|
||||
list: (params = {}) =>
|
||||
http.get<OffsetPaginated<AdminDiscount>>('/admin/discounts', {
|
||||
...uncached,
|
||||
query: { ...params },
|
||||
}),
|
||||
getById: (id) =>
|
||||
http.get<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
|
||||
create: (payload) => http.post<AdminDiscount>('/admin/discounts', payload, uncached),
|
||||
update: (id, payload) =>
|
||||
http.patch<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, payload, uncached),
|
||||
remove: (id) => http.delete<void>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
AdminReview,
|
||||
Locale,
|
||||
OffsetPaginated,
|
||||
ReviewSummary,
|
||||
ReviewableItem,
|
||||
StorefrontReview,
|
||||
} from '@sport/types';
|
||||
|
||||
import type { HttpClient, RequestOptions } from '../http-client';
|
||||
|
||||
export interface ReviewListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sort?: 'newest' | 'rating_desc' | 'rating_asc';
|
||||
}
|
||||
|
||||
export type ProductReviews = OffsetPaginated<StorefrontReview> & { summary: ReviewSummary };
|
||||
|
||||
/** Proof of purchase: the order id from the confirmation URL plus its email. */
|
||||
export interface ReviewCredentials {
|
||||
orderId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SubmitReviewPayload extends ReviewCredentials {
|
||||
orderLineId: string;
|
||||
rating: number;
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
authorName: string;
|
||||
}
|
||||
|
||||
export interface AdminReviewListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface ReviewsResource {
|
||||
/** Approved reviews for a product, with the summary the header needs. */
|
||||
listForProduct(
|
||||
productId: string,
|
||||
params?: ReviewListParams,
|
||||
options?: RequestOptions,
|
||||
): Promise<ProductReviews>;
|
||||
|
||||
/**
|
||||
* What an order entitles its buyer to review.
|
||||
*
|
||||
* A POST because the email is proof of ownership — in a query string it would
|
||||
* land in server logs, browser history, and the Referer header sent with
|
||||
* every image on the page.
|
||||
*/
|
||||
listReviewable(
|
||||
credentials: ReviewCredentials,
|
||||
locale?: Locale,
|
||||
): Promise<readonly ReviewableItem[]>;
|
||||
|
||||
submit(payload: SubmitReviewPayload, locale?: Locale): Promise<readonly ReviewableItem[]>;
|
||||
}
|
||||
|
||||
export interface ReviewsAdminResource {
|
||||
list(params?: AdminReviewListParams): Promise<OffsetPaginated<AdminReview>>;
|
||||
moderate(
|
||||
id: string,
|
||||
decision: { status: 'APPROVED' | 'REJECTED'; note?: string | null },
|
||||
): Promise<AdminReview>;
|
||||
}
|
||||
|
||||
export function createReviewsResource(http: HttpClient): ReviewsResource {
|
||||
return {
|
||||
listForProduct: (productId, params = {}, options = {}) =>
|
||||
http.get<ProductReviews>(`/reviews/product/${encodeURIComponent(productId)}`, {
|
||||
...options,
|
||||
query: { ...params },
|
||||
}),
|
||||
|
||||
listReviewable: (credentials, locale) =>
|
||||
http.post<readonly ReviewableItem[]>('/reviews/reviewable', credentials, {
|
||||
cache: 'no-store',
|
||||
query: locale ? { locale } : undefined,
|
||||
}),
|
||||
|
||||
submit: (payload, locale) =>
|
||||
http.post<readonly ReviewableItem[]>('/reviews', payload, {
|
||||
cache: 'no-store',
|
||||
query: locale ? { locale } : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createReviewsAdminResource(http: HttpClient): ReviewsAdminResource {
|
||||
// A moderation queue must never be served from cache: two operators working
|
||||
// the same queue would otherwise both see, and both action, the same review.
|
||||
const uncached = { cache: 'no-store' } as const;
|
||||
|
||||
return {
|
||||
list: (params = {}) =>
|
||||
http.get<OffsetPaginated<AdminReview>>('/admin/reviews', {
|
||||
...uncached,
|
||||
query: { ...params },
|
||||
}),
|
||||
moderate: (id, decision) =>
|
||||
http.patch<AdminReview>(`/admin/reviews/${encodeURIComponent(id)}`, decision, uncached),
|
||||
};
|
||||
}
|
||||
@@ -43,8 +43,12 @@ export const PERMISSIONS = {
|
||||
CUSTOMER_DELETE: 'customer.delete',
|
||||
|
||||
// Marketing
|
||||
//
|
||||
// There is no separate `coupon.manage`. A coupon is a promotion that needs a
|
||||
// code typed (ADR-0020) — one entity, one permission. A second permission
|
||||
// would imply a separation the data model deliberately does not have, and
|
||||
// would let a role manage half of one screen.
|
||||
PROMOTION_MANAGE: 'promotion.manage',
|
||||
COUPON_MANAGE: 'coupon.manage',
|
||||
REVIEW_MODERATE: 'review.moderate',
|
||||
|
||||
// Content
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Id, Money, Nullable } from '../primitives';
|
||||
|
||||
import type { AppliedDiscount, RejectedDiscount } from './discount';
|
||||
|
||||
/**
|
||||
* The cart as the storefront sees it.
|
||||
*
|
||||
@@ -14,6 +16,10 @@ export interface Cart {
|
||||
readonly totals: CartTotals;
|
||||
/** Lines dropped since the cart was last seen, so the UI can explain itself. */
|
||||
readonly notices: readonly CartNotice[];
|
||||
/** Discounts currently taking money off, automatic and coded alike. */
|
||||
readonly discounts: readonly AppliedDiscount[];
|
||||
/** Codes the shopper entered that did not apply, and why. */
|
||||
readonly rejectedDiscounts: readonly RejectedDiscount[];
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Id, Money, Nullable } from '../primitives';
|
||||
|
||||
export const DISCOUNT_TRIGGERS = { AUTOMATIC: 'AUTOMATIC', CODE: 'CODE' } as const;
|
||||
export type DiscountTrigger = (typeof DISCOUNT_TRIGGERS)[keyof typeof DISCOUNT_TRIGGERS];
|
||||
|
||||
export const DISCOUNT_TYPES = {
|
||||
PERCENTAGE: 'PERCENTAGE',
|
||||
FIXED_AMOUNT: 'FIXED_AMOUNT',
|
||||
} as const;
|
||||
export type DiscountType = (typeof DISCOUNT_TYPES)[keyof typeof DISCOUNT_TYPES];
|
||||
|
||||
export const DISCOUNT_SCOPES = { ORDER: 'ORDER', PRODUCT: 'PRODUCT' } as const;
|
||||
export type DiscountScope = (typeof DISCOUNT_SCOPES)[keyof typeof DISCOUNT_SCOPES];
|
||||
|
||||
/**
|
||||
* A discount as the shopper sees it once applied.
|
||||
*
|
||||
* `amount` is what this discount actually took off *this* cart — computed by
|
||||
* the API, never by the client, and re-computed at checkout. The rule that
|
||||
* produced it is deliberately not exposed: a shopper needs the name and the
|
||||
* saving, not the engine's reasoning.
|
||||
*/
|
||||
export interface AppliedDiscount {
|
||||
readonly id: Id;
|
||||
readonly code: Nullable<string>;
|
||||
readonly name: string;
|
||||
readonly amount: Money;
|
||||
}
|
||||
|
||||
/** Why a code the shopper typed did not work. */
|
||||
export const DISCOUNT_REJECTIONS = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
EXPIRED: 'EXPIRED',
|
||||
NOT_STARTED: 'NOT_STARTED',
|
||||
USAGE_LIMIT_REACHED: 'USAGE_LIMIT_REACHED',
|
||||
MINIMUM_NOT_MET: 'MINIMUM_NOT_MET',
|
||||
NOTHING_ELIGIBLE: 'NOTHING_ELIGIBLE',
|
||||
NOT_COMBINABLE: 'NOT_COMBINABLE',
|
||||
} as const;
|
||||
|
||||
export type DiscountRejectionReason =
|
||||
(typeof DISCOUNT_REJECTIONS)[keyof typeof DISCOUNT_REJECTIONS];
|
||||
|
||||
export interface RejectedDiscount {
|
||||
readonly code: string;
|
||||
readonly reason: DiscountRejectionReason;
|
||||
/** Present for MINIMUM_NOT_MET, so the UI can say how much more is needed. */
|
||||
readonly minimumSubtotal: Nullable<Money>;
|
||||
}
|
||||
|
||||
/** Admin view. Everything the engine reads, plus its redemption count. */
|
||||
export interface AdminDiscount {
|
||||
readonly id: Id;
|
||||
readonly code: Nullable<string>;
|
||||
readonly trigger: DiscountTrigger;
|
||||
readonly type: DiscountType;
|
||||
readonly scope: DiscountScope;
|
||||
readonly value: number;
|
||||
readonly translations: Readonly<Record<string, { name: string; description: Nullable<string> }>>;
|
||||
readonly minSubtotalAmount: Nullable<number>;
|
||||
readonly startsAt: Nullable<string>;
|
||||
readonly endsAt: Nullable<string>;
|
||||
readonly isActive: boolean;
|
||||
readonly usageLimit: Nullable<number>;
|
||||
readonly usageCount: number;
|
||||
readonly stackable: boolean;
|
||||
readonly priority: number;
|
||||
readonly productIds: readonly Id[];
|
||||
readonly collectionIds: readonly Id[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Id, Nullable, Slug } from '../primitives';
|
||||
|
||||
export const REVIEW_STATUSES = {
|
||||
PENDING: 'PENDING',
|
||||
APPROVED: 'APPROVED',
|
||||
REJECTED: 'REJECTED',
|
||||
} as const;
|
||||
|
||||
export type ReviewStatus = (typeof REVIEW_STATUSES)[keyof typeof REVIEW_STATUSES];
|
||||
|
||||
export const REVIEW_RATING_MIN = 1;
|
||||
export const REVIEW_RATING_MAX = 5;
|
||||
|
||||
/** A published review, as a shopper sees it. */
|
||||
export interface StorefrontReview {
|
||||
readonly id: Id;
|
||||
readonly rating: number;
|
||||
readonly title: Nullable<string>;
|
||||
readonly body: Nullable<string>;
|
||||
readonly authorName: string;
|
||||
readonly createdAt: string;
|
||||
/**
|
||||
* Always true, and sent anyway.
|
||||
*
|
||||
* Every review in this system is anchored to an order line, so there is no
|
||||
* such thing as an unverified one. The field exists because the badge is
|
||||
* worth showing, and because a future import of legacy reviews would need to
|
||||
* say so honestly rather than by omission.
|
||||
*/
|
||||
readonly isVerifiedPurchase: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a product's ratings are distributed.
|
||||
*
|
||||
* Sent alongside the average because they answer different questions: a 3.0
|
||||
* average made of straight 3s is a mediocre product, and a 3.0 made of 5s and
|
||||
* 1s is a product with a sizing problem. The bar chart is what makes that
|
||||
* visible, and it cannot be reconstructed from the average.
|
||||
*/
|
||||
export interface ReviewDistribution {
|
||||
readonly rating: number;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
readonly average: number;
|
||||
readonly count: number;
|
||||
readonly distribution: readonly ReviewDistribution[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An item a shopper is entitled to review, resolved from one order.
|
||||
*
|
||||
* `reviewId` non-null means they already did — the form renders as a thank-you
|
||||
* rather than disappearing, so the action they took is still visible.
|
||||
*/
|
||||
export interface ReviewableItem {
|
||||
readonly orderLineId: Id;
|
||||
readonly productId: Id;
|
||||
readonly productSlug: Slug;
|
||||
readonly productName: string;
|
||||
readonly variantTitle: string;
|
||||
readonly imageUrl: Nullable<string>;
|
||||
readonly reviewId: Nullable<Id>;
|
||||
readonly reviewStatus: Nullable<ReviewStatus>;
|
||||
readonly rating: Nullable<number>;
|
||||
}
|
||||
|
||||
/** A review in the moderation queue, with everything needed to judge it. */
|
||||
export interface AdminReview {
|
||||
readonly id: Id;
|
||||
readonly status: ReviewStatus;
|
||||
readonly rating: number;
|
||||
readonly title: Nullable<string>;
|
||||
readonly body: Nullable<string>;
|
||||
readonly authorName: string;
|
||||
readonly productId: Id;
|
||||
readonly productName: string;
|
||||
readonly productSlug: Slug;
|
||||
readonly variantTitle: string;
|
||||
/** The order it came from — the moderator's link back to the purchase. */
|
||||
readonly orderNumber: number;
|
||||
readonly orderId: Id;
|
||||
readonly moderationNote: Nullable<string>;
|
||||
readonly moderatedAt: Nullable<string>;
|
||||
readonly moderatedByName: Nullable<string>;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Locale } from '../i18n/locale';
|
||||
import type { Id, Nullable, Slug } from '../primitives';
|
||||
|
||||
export const CONTENT_TYPES = {
|
||||
PAGE: 'PAGE',
|
||||
POST: 'POST',
|
||||
} as const;
|
||||
|
||||
export type ContentType = (typeof CONTENT_TYPES)[keyof typeof CONTENT_TYPES];
|
||||
|
||||
export const CONTENT_STATUSES = {
|
||||
DRAFT: 'DRAFT',
|
||||
PUBLISHED: 'PUBLISHED',
|
||||
} as const;
|
||||
|
||||
export type ContentStatus = (typeof CONTENT_STATUSES)[keyof typeof CONTENT_STATUSES];
|
||||
|
||||
/** A post as it appears in the feed — no body, because a list does not need one. */
|
||||
export interface ContentSummary {
|
||||
readonly id: Id;
|
||||
readonly type: ContentType;
|
||||
readonly slug: Slug;
|
||||
readonly title: string;
|
||||
readonly excerpt: Nullable<string>;
|
||||
readonly coverImageUrl: Nullable<string>;
|
||||
readonly publishedAt: Nullable<string>;
|
||||
}
|
||||
|
||||
/** A page or post as a reader sees it. */
|
||||
export interface ContentDetail extends ContentSummary {
|
||||
/** Markdown. Rendered to React elements, never injected as HTML. */
|
||||
readonly body: string;
|
||||
readonly authorName: Nullable<string>;
|
||||
readonly seo: {
|
||||
readonly metaTitle: Nullable<string>;
|
||||
readonly metaDescription: Nullable<string>;
|
||||
};
|
||||
/**
|
||||
* This entry's slug in every locale.
|
||||
*
|
||||
* Same reason products carry them: without these the language switcher
|
||||
* 404s, because `/en/blog/<vi-slug>` is not a page.
|
||||
*/
|
||||
readonly alternateSlugs: Readonly<Partial<Record<Locale, Slug>>>;
|
||||
}
|
||||
|
||||
export interface ContentTranslationFields {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly excerpt: Nullable<string>;
|
||||
readonly body: string;
|
||||
readonly metaTitle: Nullable<string>;
|
||||
readonly metaDescription: Nullable<string>;
|
||||
}
|
||||
|
||||
/** A page or post as an operator edits it — every locale, published or not. */
|
||||
export interface AdminContentEntry {
|
||||
readonly id: Id;
|
||||
readonly type: ContentType;
|
||||
readonly status: ContentStatus;
|
||||
readonly publishedAt: Nullable<string>;
|
||||
readonly coverImageId: Nullable<Id>;
|
||||
readonly coverImageUrl: Nullable<string>;
|
||||
readonly authorName: Nullable<string>;
|
||||
readonly translations: Readonly<Record<string, ContentTranslationFields>>;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
@@ -26,4 +26,7 @@ export * from './catalog/admin';
|
||||
export * from './i18n/locale';
|
||||
export * from './inventory/stock';
|
||||
export * from './commerce/cart';
|
||||
export * from './commerce/discount';
|
||||
export * from './commerce/order';
|
||||
export * from './commerce/review';
|
||||
export * from './content/entry';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCALES } from '@sport/types';
|
||||
|
||||
import { anyIdSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
@@ -65,3 +67,115 @@ export type ShippingAddressInput = z.output<typeof shippingAddressSchema>;
|
||||
export type PlaceOrderInput = z.output<typeof placeOrderSchema>;
|
||||
export type OrderListQuery = z.output<typeof orderListQuerySchema>;
|
||||
export type UpdateOrderStatusInput = z.output<typeof updateOrderStatusSchema>;
|
||||
|
||||
/** A discount code as typed. Normalised to uppercase before it is stored. */
|
||||
export const applyDiscountCodeSchema = z.object({
|
||||
code: z.string().trim().min(1).max(40),
|
||||
});
|
||||
|
||||
const discountTranslationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A discount as an operator authors it.
|
||||
*
|
||||
* `value` means different things per `type` — whole percent, or minor units —
|
||||
* so it is range-checked against the type rather than in isolation. A 500%
|
||||
* discount is a typo, and catching it here is cheaper than in the ledger.
|
||||
*/
|
||||
export const discountInputSchema = z
|
||||
.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.min(3)
|
||||
.max(40)
|
||||
.regex(/^[A-Z0-9_-]+$/, 'Use letters, digits, hyphens and underscores')
|
||||
.nullish(),
|
||||
trigger: z.enum(['AUTOMATIC', 'CODE']),
|
||||
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT']),
|
||||
scope: z.enum(['ORDER', 'PRODUCT']).default('ORDER'),
|
||||
value: z.int().min(1),
|
||||
translations: z.partialRecord(z.enum(LOCALES), discountTranslationSchema),
|
||||
minSubtotalAmount: z.int().min(0).nullish(),
|
||||
startsAt: z.iso.datetime().nullish(),
|
||||
endsAt: z.iso.datetime().nullish(),
|
||||
isActive: z.boolean().default(true),
|
||||
usageLimit: z.int().min(1).nullish(),
|
||||
stackable: z.boolean().default(false),
|
||||
priority: z.int().min(0).max(1000).default(100),
|
||||
productIds: z.array(anyIdSchema).default([]),
|
||||
collectionIds: z.array(anyIdSchema).default([]),
|
||||
})
|
||||
.refine((value) => value.type !== 'PERCENTAGE' || value.value <= 100, {
|
||||
message: 'A percentage discount cannot exceed 100',
|
||||
path: ['value'],
|
||||
})
|
||||
.refine((value) => value.trigger !== 'CODE' || Boolean(value.code), {
|
||||
message: 'A coupon needs a code',
|
||||
path: ['code'],
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.startsAt || !value.endsAt || new Date(value.startsAt) < new Date(value.endsAt),
|
||||
{ message: 'The end date must be after the start date', path: ['endsAt'] },
|
||||
);
|
||||
|
||||
export const discountListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
trigger: z.enum(['AUTOMATIC', 'CODE']).optional(),
|
||||
});
|
||||
|
||||
export type ApplyDiscountCodeInput = z.output<typeof applyDiscountCodeSchema>;
|
||||
export type DiscountInput = z.output<typeof discountInputSchema>;
|
||||
export type DiscountListQuery = z.output<typeof discountListQuerySchema>;
|
||||
|
||||
/**
|
||||
* A review as a shopper submits it.
|
||||
*
|
||||
* `orderLineId` is the whole authorisation story: it names the exact purchased
|
||||
* item being reviewed. Paired with the order id and the email on that order
|
||||
* (checked server-side), it is the same proof that already lets a guest look up
|
||||
* their order — no account required, and no way to review something you did not
|
||||
* buy.
|
||||
*/
|
||||
export const submitReviewSchema = z.object({
|
||||
orderId: anyIdSchema,
|
||||
/** The email on the order. Proof, not contact details — never stored here. */
|
||||
email: z.email().max(255),
|
||||
orderLineId: anyIdSchema,
|
||||
rating: z.int().min(1).max(5),
|
||||
title: z.string().trim().max(140).nullish(),
|
||||
body: z.string().trim().max(2000).nullish(),
|
||||
authorName: z.string().trim().min(1).max(120),
|
||||
});
|
||||
|
||||
export const reviewListQuerySchema = offsetPageQuerySchema.extend({
|
||||
/** Newest first by default; `helpful` is deliberately absent until votes are. */
|
||||
sort: z.enum(['newest', 'rating_desc', 'rating_asc']).default('newest'),
|
||||
});
|
||||
|
||||
export const adminReviewListQuerySchema = offsetPageQuerySchema.extend({
|
||||
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
|
||||
q: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A moderation decision.
|
||||
*
|
||||
* `PENDING` is not an accepted target: moderation moves a review out of the
|
||||
* queue, and letting an operator put one back would make "how long has this
|
||||
* been waiting" unanswerable.
|
||||
*/
|
||||
export const moderateReviewSchema = z.object({
|
||||
status: z.enum(['APPROVED', 'REJECTED']),
|
||||
note: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
export type SubmitReviewInput = z.output<typeof submitReviewSchema>;
|
||||
export type ReviewListQuery = z.output<typeof reviewListQuerySchema>;
|
||||
export type AdminReviewListQuery = z.output<typeof adminReviewListQuerySchema>;
|
||||
export type ModerateReviewInput = z.output<typeof moderateReviewSchema>;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCALES } from '@sport/types';
|
||||
|
||||
import { anyIdSchema, slugSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
const contentTranslationSchema = z.object({
|
||||
slug: slugSchema,
|
||||
title: z.string().trim().min(1).max(255),
|
||||
excerpt: z.string().trim().max(500).nullish(),
|
||||
/**
|
||||
* Markdown, and required.
|
||||
*
|
||||
* A page with a title and no body is a broken link with extra steps — it
|
||||
* renders, gets indexed, and tells a reader nothing.
|
||||
*/
|
||||
body: z.string().trim().min(1),
|
||||
metaTitle: z.string().trim().max(255).nullish(),
|
||||
metaDescription: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A page or post as an operator authors it.
|
||||
*
|
||||
* `translations` is a partial record: publishing an English-only post is a
|
||||
* legitimate thing to do, and requiring every locale would mean inventing
|
||||
* Vietnamese copy to get anything live.
|
||||
*/
|
||||
export const contentEntryInputSchema = z
|
||||
.object({
|
||||
type: z.enum(['PAGE', 'POST']),
|
||||
status: z.enum(['DRAFT', 'PUBLISHED']).default('DRAFT'),
|
||||
coverImageId: anyIdSchema.nullish(),
|
||||
translations: z.partialRecord(z.enum(LOCALES), contentTranslationSchema),
|
||||
})
|
||||
.refine((value) => Object.keys(value.translations).length > 0, {
|
||||
message: 'Write the entry in at least one language',
|
||||
path: ['translations'],
|
||||
});
|
||||
|
||||
export const contentListQuerySchema = offsetPageQuerySchema.extend({
|
||||
type: z.enum(['PAGE', 'POST']).optional(),
|
||||
status: z.enum(['DRAFT', 'PUBLISHED']).optional(),
|
||||
q: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
export const postListQuerySchema = offsetPageQuerySchema;
|
||||
|
||||
export type ContentEntryInput = z.output<typeof contentEntryInputSchema>;
|
||||
export type ContentListQuery = z.output<typeof contentListQuerySchema>;
|
||||
export type PostListQuery = z.output<typeof postListQuerySchema>;
|
||||
@@ -18,3 +18,4 @@ export * from './catalog';
|
||||
export * from './users';
|
||||
export * from './catalog-admin';
|
||||
export * from './commerce';
|
||||
export * from './content';
|
||||
|
||||
Generated
+874
-4
File diff suppressed because it is too large
Load Diff
@@ -23,5 +23,7 @@ catalog:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8
|
||||
next: 16.3.0
|
||||
react-markdown: 10.1.0
|
||||
remark-gfm: 4.0.1
|
||||
tailwindcss: 4.3.3
|
||||
"@tailwindcss/postcss": 4.3.3
|
||||
|
||||
Reference in New Issue
Block a user