This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import type { ContentType } from '@sport/types';
import { ContentEditor } from '@/features/cms/content-editor';
type PageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ type?: string }>;
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.cms');
return { title: t('title') };
}
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params;
const { type } = await searchParams;
const t = await getTranslations('cms');
// `?type=` only seeds a new entry; an existing one carries its own type, and
// trusting the query string there would let a URL flip a page into a post.
const initialType: ContentType = type === 'PAGE' ? 'PAGE' : 'POST';
return (
<div className="max-w-4xl space-y-6 p-8">
<Link href="/cms" className="text-ink-500 hover:text-ink-950 text-xs">
← {t('backToList')}
</Link>
<ContentEditor entryId={id} initialType={initialType} />
</div>
);
}
+10 -8
View File
@@ -1,22 +1,24 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { ContentTable } from '@/features/cms/content-table';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.cms');
return { title: t('title') };
}
export default async function CmsPage() {
export default async function Page() {
const t = await getTranslations('pages.cms');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="cms.read"
milestone="M7 — content"
/>
<div className="space-y-6 p-8">
<header>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
</header>
<ContentTable />
</div>
);
}
@@ -1,22 +0,0 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.coupons');
return { title: t('title') };
}
export default async function CouponsPage() {
const t = await getTranslations('pages.coupons');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="coupon.manage"
milestone="M7 — marketing"
/>
);
}
@@ -0,0 +1,24 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { DiscountsTable } from '@/features/discounts/discounts-table';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.discounts');
return { title: t('title') };
}
export default async function Page() {
const t = await getTranslations('pages.discounts');
return (
<div className="space-y-6 p-8">
<header>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
</header>
<DiscountsTable />
</div>
);
}
@@ -1,22 +0,0 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.promotions');
return { title: t('title') };
}
export default async function PromotionsPage() {
const t = await getTranslations('pages.promotions');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="promotion.manage"
milestone="M7 — marketing"
/>
);
}
@@ -1,22 +1,24 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { ReviewsQueue } from '@/features/reviews/reviews-queue';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.reviews');
return { title: t('title') };
}
export default async function ReviewsPage() {
export default async function Page() {
const t = await getTranslations('pages.reviews');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="review.moderate"
milestone="M7 — marketing"
/>
<div className="space-y-6 p-8">
<header>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
</header>
<ReviewsQueue />
</div>
);
}
@@ -0,0 +1,361 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState, type FormEvent } from 'react';
import { isApiClientError, type ContentEntryPayload } from '@sport/api-client';
import {
DEFAULT_LOCALE,
LOCALES,
type AdminContentEntry,
type ContentStatus,
type ContentType,
type Locale,
} from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
interface Fields {
slug: string;
title: string;
excerpt: string;
body: string;
metaTitle: string;
metaDescription: string;
}
interface Draft {
type: ContentType;
status: ContentStatus;
translations: Record<Locale, Fields>;
}
const EMPTY: Fields = {
slug: '',
title: '',
excerpt: '',
body: '',
metaTitle: '',
metaDescription: '',
};
function emptyDraft(type: ContentType): Draft {
return {
type,
status: 'DRAFT',
translations: Object.fromEntries(
LOCALES.map((locale) => [locale, { ...EMPTY }]),
) as Draft['translations'],
};
}
function toDraft(entry: AdminContentEntry): Draft {
return {
type: entry.type,
status: entry.status,
translations: Object.fromEntries(
LOCALES.map((locale) => {
const source = entry.translations[locale];
return [
locale,
source
? {
slug: source.slug,
title: source.title,
excerpt: source.excerpt ?? '',
body: source.body,
metaTitle: source.metaTitle ?? '',
metaDescription: source.metaDescription ?? '',
}
: { ...EMPTY },
];
}),
) as Draft['translations'],
};
}
/**
* Writes one page or post, in every language.
*
* A full page rather than a dialog: the body is long-form Markdown, and a
* modal that scrolls internally is a poor place to write anything longer than
* a sentence.
*/
export function ContentEditor({
entryId,
initialType,
}: {
entryId: string;
initialType: ContentType;
}) {
const t = useTranslations('cms');
const router = useRouter();
const isNew = entryId === 'new';
const [draft, setDraft] = useState<Draft | null>(() => (isNew ? emptyDraft(initialType) : null));
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
useEffect(() => {
if (isNew) return;
let cancelled = false;
browserApi.contentAdmin
.getById(entryId)
.then((entry) => {
if (!cancelled) setDraft(toDraft(entry));
})
.catch((caught: unknown) => {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [entryId, isNew, t]);
function setField<K extends keyof Fields>(key: K, value: string) {
setDraft((current) =>
current
? {
...current,
translations: {
...current.translations,
[locale]: { ...current.translations[locale], [key]: value },
},
}
: current,
);
}
async function save(event: FormEvent, status: ContentStatus) {
event.preventDefault();
if (!draft) return;
setError(null);
setSaving(true);
try {
const payload: ContentEntryPayload = {
type: draft.type,
status,
// Only languages actually written. Sending an empty title would fail
// the schema and block saving a post drafted in one language, which is
// how most of them start.
translations: Object.fromEntries(
LOCALES.filter((key) => draft.translations[key].title.trim()).map((key) => {
const fields = draft.translations[key];
return [
key,
{
slug: fields.slug.trim(),
title: fields.title.trim(),
excerpt: fields.excerpt.trim() || null,
body: fields.body,
metaTitle: fields.metaTitle.trim() || null,
metaDescription: fields.metaDescription.trim() || null,
},
];
}),
),
};
const saved = isNew
? await browserApi.contentAdmin.create(payload)
: await browserApi.contentAdmin.update(entryId, payload);
setDraft(toDraft(saved));
// Replace rather than push: going "back" from a freshly created entry
// should reach the list, not an empty create form that would make a
// second copy on save.
if (isNew) router.replace(`/cms/${saved.id}`);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
async function remove() {
setSaving(true);
try {
await browserApi.contentAdmin.remove(entryId);
router.push('/cms');
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
setConfirmingDelete(false);
setSaving(false);
}
}
if (error && !draft) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!draft) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const fields = draft.translations[locale];
return (
<form onSubmit={(event) => void save(event, draft.status)} className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">{t(`type.${draft.type}`)}</Badge>
<Badge variant={draft.status === 'PUBLISHED' ? 'success' : 'neutral'}>
{t(`status.${draft.status}`)}
</Badge>
<div className="border-ink-200 rounded-card ml-auto flex overflow-hidden border">
{LOCALES.map((key) => (
<button
key={key}
type="button"
onClick={() => setLocale(key)}
aria-pressed={locale === key}
className={cn(
'px-3 py-1.5 text-xs font-semibold uppercase tracking-widest transition-colors',
locale === key ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
// A language with no title yet is not written — worth showing,
// because an operator switching tabs needs to know which
// versions will actually be saved.
!draft.translations[key].title.trim() && locale !== key ? 'text-ink-400' : '',
)}
>
{key}
{draft.translations[key].title.trim() ? '' : ' ·'}
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('title')}</span>
<Input value={fields.title} onChange={(event) => setField('title', event.target.value)} />
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('slug')}</span>
<Input
value={fields.slug}
onChange={(event) => setField('slug', event.target.value)}
placeholder="how-to-layer"
/>
<span className="text-ink-400 text-xs">{t('slugHint')}</span>
</label>
</div>
{draft.type === 'POST' ? (
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('excerpt')}</span>
<Input
value={fields.excerpt}
onChange={(event) => setField('excerpt', event.target.value)}
/>
<span className="text-ink-400 text-xs">{t('excerptHint')}</span>
</label>
) : null}
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('body')}</span>
<textarea
value={fields.body}
onChange={(event) => setField('body', event.target.value)}
rows={22}
spellCheck
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 font-mono text-sm leading-relaxed outline-none"
/>
<span className="text-ink-400 text-xs">{t('bodyHint')}</span>
</label>
<details className="border-ink-200 border p-4">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-widest">
{t('seo')}
</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('metaTitle')}
</span>
<Input
value={fields.metaTitle}
onChange={(event) => setField('metaTitle', event.target.value)}
placeholder={fields.title}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('metaDescription')}
</span>
<Input
value={fields.metaDescription}
onChange={(event) => setField('metaDescription', event.target.value)}
placeholder={fields.excerpt}
/>
</label>
</div>
</details>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<div className="border-ink-200 flex flex-wrap items-center gap-2 border-t pt-4">
<Button type="submit" variant="outline" disabled={saving}>
{saving ? t('saving') : t('saveDraft')}
</Button>
{/*
Publish and save are separate buttons rather than a status dropdown
plus save. "Publish" is the consequential action and should say so —
picking PUBLISHED from a select and pressing Save reads as neither.
*/}
<Button type="button" disabled={saving} onClick={(event) => void save(event, 'PUBLISHED')}>
{draft.status === 'PUBLISHED' ? t('update') : t('publish')}
</Button>
{draft.status === 'PUBLISHED' ? (
<Button
type="button"
variant="ghost"
disabled={saving}
onClick={(event) => void save(event, 'DRAFT')}
>
{t('unpublish')}
</Button>
) : null}
{!isNew ? (
<Button
type="button"
variant={confirmingDelete ? 'destructive' : 'ghost'}
disabled={saving}
className="ml-auto"
onClick={() => (confirmingDelete ? void remove() : setConfirmingDelete(true))}
>
{confirmingDelete ? t('deleteConfirm') : t('delete')}
</Button>
) : null}
</div>
</form>
);
}
@@ -0,0 +1,190 @@
'use client';
import { FileText, Newspaper, Plus } from 'lucide-react';
import Link from 'next/link';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminContentEntry, type ContentType } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
type TypeFilter = ContentType | 'ALL';
export function ContentTable() {
const t = useTranslations('cms');
const format = useFormatter();
const { can } = useSession();
const [entries, setEntries] = useState<AdminContentEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [type, setType] = useState<TypeFilter>('ALL');
const load = useCallback(async () => {
try {
const result = await browserApi.contentAdmin.list({
perPage: 100,
q: query.trim() || undefined,
type: type === 'ALL' ? undefined : type,
});
setEntries([...result.items]);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, type, t]);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
if (!cancelled) void load();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [load]);
if (!can(PERMISSIONS.CMS_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
const canManage = can(PERMISSIONS.CMS_MANAGE);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{(['ALL', 'POST', 'PAGE'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={type === value}
onClick={() => setType(value)}
className={cn(
'px-3 py-2 text-sm transition-colors',
type === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{t(`filter.${value}`)}
</button>
))}
</div>
{canManage ? (
<div className="ml-auto flex gap-2">
<Button variant="outline" asChild>
<Link href="/cms/new?type=PAGE">
<Plus className="size-4" />
{t('newPage')}
</Link>
</Button>
<Button asChild>
<Link href="/cms/new?type=POST">
<Plus className="size-4" />
{t('newPost')}
</Link>
</Button>
</div>
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!entries ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : entries.length === 0 ? (
<p className="text-ink-500 border-ink-200 border border-dashed p-8 text-center text-sm">
{t('empty')}
</p>
) : (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('table.title')}</th>
<th className="px-4 py-3 font-semibold">{t('table.languages')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3 font-semibold">{t('table.updated')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{entries.map((entry) => {
const languages = Object.keys(entry.translations);
const primary = entry.translations.en ?? entry.translations.vi;
return (
<tr key={entry.id} className="hover:bg-ink-50/60">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{entry.type === 'POST' ? (
<Newspaper className="text-ink-400 size-4 shrink-0" />
) : (
<FileText className="text-ink-400 size-4 shrink-0" />
)}
<div>
<div className="font-medium">{primary?.title ?? t('untitled')}</div>
<div className="text-ink-500 font-mono text-xs">
/{primary?.slug ?? '—'}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{languages.map((language) => (
<Badge key={language} variant="outline">
{language.toUpperCase()}
</Badge>
))}
</div>
</td>
<td className="px-4 py-3">
<Badge variant={entry.status === 'PUBLISHED' ? 'success' : 'neutral'}>
{t(`status.${entry.status}`)}
</Badge>
</td>
<td className="text-ink-500 px-4 py-3 text-xs">
{formatDateTime(entry.updatedAt, format)}
</td>
<td className="px-4 py-3 text-right">
<Button variant="outline" size="sm" asChild>
<Link href={`/cms/${entry.id}`}>{t('edit')}</Link>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,536 @@
'use client';
import { useTranslations } from 'next-intl';
import { useId, useState, type FormEvent } from 'react';
import { isApiClientError, type DiscountPayload } from '@sport/api-client';
import { DEFAULT_LOCALE, LOCALES, type AdminDiscount, type Locale } from '@sport/types';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
cn,
} from '@sport/ui';
import { browserApi } from '@/lib/api';
type Trigger = 'AUTOMATIC' | 'CODE';
type Type = 'PERCENTAGE' | 'FIXED_AMOUNT';
type Scope = 'ORDER' | 'PRODUCT';
interface Draft {
code: string;
trigger: Trigger;
type: Type;
scope: Scope;
value: string;
translations: Record<Locale, { name: string; description: string }>;
minSubtotalAmount: string;
startsAt: string;
endsAt: string;
isActive: boolean;
usageLimit: string;
stackable: boolean;
priority: string;
}
function emptyDraft(): Draft {
return {
code: '',
trigger: 'CODE',
type: 'PERCENTAGE',
scope: 'ORDER',
value: '',
translations: Object.fromEntries(
LOCALES.map((locale) => [locale, { name: '', description: '' }]),
) as Draft['translations'],
minSubtotalAmount: '',
startsAt: '',
endsAt: '',
isActive: true,
usageLimit: '',
stackable: false,
priority: '100',
};
}
function toDraft(discount: AdminDiscount): Draft {
return {
code: discount.code ?? '',
trigger: discount.trigger,
type: discount.type,
scope: discount.scope,
value: String(discount.value),
translations: Object.fromEntries(
LOCALES.map((locale) => [
locale,
{
name: discount.translations[locale]?.name ?? '',
description: discount.translations[locale]?.description ?? '',
},
]),
) as Draft['translations'],
minSubtotalAmount:
discount.minSubtotalAmount === null ? '' : String(discount.minSubtotalAmount),
startsAt: toLocalInput(discount.startsAt),
endsAt: toLocalInput(discount.endsAt),
isActive: discount.isActive,
usageLimit: discount.usageLimit === null ? '' : String(discount.usageLimit),
stackable: discount.stackable,
priority: String(discount.priority),
};
}
/**
* Creates and edits a discount.
*
* One dialog for both promotions and coupons, because they are one entity
* (ADR-0020). `trigger` is the first control on the form for exactly that
* reason: it is the choice that decides whether the rest reads as "a promotion
* that runs by itself" or "a code a shopper types".
*/
export function DiscountDialog({
discount,
open,
onOpenChange,
onSaved,
}: {
/** null = create. */
discount: AdminDiscount | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}) {
const t = useTranslations('discounts');
const [draft, setDraft] = useState<Draft>(() => (discount ? toDraft(discount) : emptyDraft()));
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
async function handleDelete() {
if (!discount) return;
setError(null);
setSaving(true);
try {
await browserApi.promotionsAdmin.remove(discount.id);
onSaved();
onOpenChange(false);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
setConfirmingDelete(false);
} finally {
setSaving(false);
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSaving(true);
try {
await save(draft, discount?.id ?? null);
onSaved();
onOpenChange(false);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setSaving(false);
}
}
const isCode = draft.trigger === 'CODE';
const isPercentage = draft.type === 'PERCENTAGE';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{discount ? t('editTitle') : t('createTitle')}</DialogTitle>
<DialogDescription>{t('dialogHint')}</DialogDescription>
</DialogHeader>
<form id="discount-form" onSubmit={handleSubmit} className="space-y-6">
{/* ---- What kind of discount ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<Choice
label={t('trigger')}
value={draft.trigger}
onChange={(value) => set('trigger', value as Trigger)}
options={[
{ value: 'CODE', label: t('triggerCode') },
{ value: 'AUTOMATIC', label: t('triggerAutomatic') },
]}
/>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('code')}</span>
<Input
value={draft.code}
onChange={(event) => set('code', event.target.value.toUpperCase())}
disabled={!isCode}
placeholder={isCode ? 'SUMMER20' : t('codeNotApplicable')}
// Uppercased as it is typed so what the operator sees is what
// the shopper must type — the API uppercases too, but a field
// that silently rewrites itself on save reads as a bug.
autoComplete="off"
/>
</label>
</div>
{/* ---- How much ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<Choice
label={t('type')}
value={draft.type}
onChange={(value) => set('type', value as Type)}
options={[
{ value: 'PERCENTAGE', label: t('typePercentage') },
{ value: 'FIXED_AMOUNT', label: t('typeFixed') },
]}
/>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{isPercentage ? t('valuePercent') : t('valueAmount')}
</span>
<Input
type="number"
min={1}
max={isPercentage ? 100 : undefined}
value={draft.value}
onChange={(event) => set('value', event.target.value)}
required
/>
<span className="text-ink-400 text-xs">
{isPercentage ? t('valuePercentHint') : t('valueAmountHint')}
</span>
</label>
</div>
<Choice
label={t('scope')}
value={draft.scope}
onChange={(value) => set('scope', value as Scope)}
options={[
{ value: 'ORDER', label: t('scopeOrder') },
{ value: 'PRODUCT', label: t('scopeProduct') },
]}
hint={draft.scope === 'PRODUCT' ? t('scopeProductHint') : undefined}
/>
{/* ---- Names, per locale ---- */}
<div className="space-y-3">
<div className="border-ink-200 flex gap-1 border-b">
{LOCALES.map((key) => (
<button
key={key}
type="button"
onClick={() => setLocale(key)}
className={cn(
'-mb-px border-b-2 px-3 py-1.5 text-xs font-semibold uppercase tracking-widest',
locale === key
? 'border-ink-950 text-ink-950'
: 'text-ink-400 hover:text-ink-600 border-transparent',
)}
>
{key}
</button>
))}
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('name')}</span>
<Input
value={draft.translations[locale].name}
onChange={(event) =>
setDraft((current) => ({
...current,
translations: {
...current.translations,
[locale]: { ...current.translations[locale], name: event.target.value },
},
}))
}
placeholder={t('namePlaceholder')}
/>
<span className="text-ink-400 text-xs">{t('nameHint')}</span>
</label>
</div>
{/* ---- When and how often ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('startsAt')}
</span>
<Input
type="datetime-local"
value={draft.startsAt}
onChange={(event) => set('startsAt', event.target.value)}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('endsAt')}</span>
<Input
type="datetime-local"
value={draft.endsAt}
onChange={(event) => set('endsAt', event.target.value)}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('minSubtotal')}
</span>
<Input
type="number"
min={0}
value={draft.minSubtotalAmount}
onChange={(event) => set('minSubtotalAmount', event.target.value)}
placeholder={t('noMinimum')}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('usageLimit')}
</span>
<Input
type="number"
min={discount ? Math.max(1, discount.usageCount) : 1}
value={draft.usageLimit}
onChange={(event) => set('usageLimit', event.target.value)}
placeholder={t('unlimited')}
/>
{discount ? (
<span className="text-ink-400 text-xs">
{t('alreadyUsed', { count: discount.usageCount })}
</span>
) : null}
</label>
</div>
{/* ---- Stacking ---- */}
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('priority')}
</span>
<Input
type="number"
min={0}
max={1000}
value={draft.priority}
onChange={(event) => set('priority', event.target.value)}
/>
<span className="text-ink-400 text-xs">{t('priorityHint')}</span>
</label>
<div className="space-y-3 pt-6">
<Toggle
label={t('stackable')}
hint={t('stackableHint')}
checked={draft.stackable}
onChange={(checked) => set('stackable', checked)}
/>
<Toggle
label={t('active')}
hint={t('activeHint')}
checked={draft.isActive}
onChange={(checked) => set('isActive', checked)}
/>
</div>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
</form>
<DialogFooter className="sm:justify-between">
{discount ? (
/**
* Two-step rather than a `confirm()` dialog: retiring a discount
* that orders already redeemed is not something to do on a stray
* click, and a native confirm cannot be styled, translated, or
* tested. The second press is the commitment.
*/
<Button
type="button"
variant={confirmingDelete ? 'destructive' : 'ghost'}
disabled={saving}
onClick={() => {
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void handleDelete();
}}
>
{confirmingDelete ? t('deleteConfirm') : t('delete')}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button type="submit" form="discount-form" disabled={saving}>
{saving ? t('saving') : t('save')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Choice({
label,
value,
onChange,
options,
hint,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
hint?: string;
}) {
return (
<div className="space-y-1.5">
<span className="block text-xs font-semibold uppercase tracking-widest">{label}</span>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
'flex-1 px-3 py-2 text-sm transition-colors',
value === option.value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{option.label}
</button>
))}
</div>
{hint ? <span className="text-ink-400 block text-xs">{hint}</span> : null}
</div>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
// `useId` rather than wrapping the input in the label: the hint text sits
// inside the label too, and a screen reader would otherwise announce the
// whole paragraph as the checkbox's name.
const id = useId();
return (
<div className="flex items-start gap-2.5">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
/>
<div className="space-y-0.5">
<label htmlFor={id} className="block text-sm font-medium">
{label}
</label>
<span className="text-ink-400 block text-xs">{hint}</span>
</div>
</div>
);
}
/**
* `datetime-local` wants `YYYY-MM-DDTHH:mm` in *local* time, and an ISO string
* from the API is UTC. Slicing the ISO string instead would silently shift
* every window by the timezone offset — a promotion set to start at 9am would
* start at 2am in Vietnam.
*/
function toLocalInput(iso: string | null): string {
if (!iso) return '';
const date = new Date(iso);
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
function toIso(local: string): string | null {
return local ? new Date(local).toISOString() : null;
}
function optionalInt(value: string): number | null {
const trimmed = value.trim();
return trimmed === '' ? null : Number.parseInt(trimmed, 10);
}
function save(draft: Draft, id: string | null): Promise<AdminDiscount> {
const payload: DiscountPayload = {
// Sent as null rather than omitted: switching a coupon to automatic must
// actively clear the code, not leave the old one attached to a promotion
// that no longer asks for it.
code: draft.trigger === 'CODE' ? draft.code.trim().toUpperCase() : null,
trigger: draft.trigger,
type: draft.type,
scope: draft.scope,
value: Number.parseInt(draft.value, 10),
// Only locales the operator actually filled in. An empty name would fail
// the schema's min(1) and block saving a discount they only named in one
// language, which is a legitimate thing to do.
translations: Object.fromEntries(
LOCALES.filter((locale) => draft.translations[locale].name.trim()).map((locale) => [
locale,
{
name: draft.translations[locale].name.trim(),
description: draft.translations[locale].description.trim() || null,
},
]),
),
minSubtotalAmount: optionalInt(draft.minSubtotalAmount),
startsAt: toIso(draft.startsAt),
endsAt: toIso(draft.endsAt),
isActive: draft.isActive,
usageLimit: optionalInt(draft.usageLimit),
stackable: draft.stackable,
priority: Number.parseInt(draft.priority, 10) || 100,
};
return id
? browserApi.promotionsAdmin.update(id, payload)
: browserApi.promotionsAdmin.create(payload);
}
@@ -0,0 +1,296 @@
'use client';
import { Plus, Tag, Ticket } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminDiscount } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatDateTime, formatMoney } from '@/lib/format';
import { DiscountDialog } from './discount-dialog';
type TriggerFilter = 'ALL' | 'AUTOMATIC' | 'CODE';
export function DiscountsTable() {
const t = useTranslations('discounts');
const format = useFormatter();
const { can } = useSession();
const [discounts, setDiscounts] = useState<AdminDiscount[] | null>(null);
/**
* The clock, sampled when the list arrives.
*
* Read during render it would be an impure call, and worse, each row would
* sample it separately — so a list straddling an expiry could show one
* discount live and the next expired in the same paint.
*/
const [loadedAt, setLoadedAt] = useState(() => Date.now());
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [trigger, setTrigger] = useState<TriggerFilter>('ALL');
/**
* `editing` distinguishes three states, which is why it is not a boolean:
* closed, creating (null), and editing a specific discount. Collapsing it
* into `open` + `selected` lets the two disagree, and the failure mode is an
* "edit" dialog that saves a new discount.
*/
const [editing, setEditing] = useState<{ discount: AdminDiscount | null } | null>(null);
const load = useCallback(async () => {
try {
const result = await browserApi.promotionsAdmin.list({
perPage: 100,
q: query.trim() || undefined,
trigger: trigger === 'ALL' ? undefined : trigger,
});
setDiscounts([...result.items]);
setLoadedAt(Date.now());
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, trigger, t]);
useEffect(() => {
let cancelled = false;
// Debounced so typing in the search box does not fire a request per
// keystroke; the cleanup also discards a response whose query is stale.
const timer = setTimeout(() => {
if (!cancelled) void load();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [load]);
if (!can(PERMISSIONS.PROMOTION_MANAGE)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{(['ALL', 'CODE', 'AUTOMATIC'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={trigger === value}
onClick={() => setTrigger(value)}
className={cn(
'px-3 py-2 text-sm transition-colors',
trigger === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{t(`filter.${value}`)}
</button>
))}
</div>
<Button className="ml-auto" onClick={() => setEditing({ discount: null })}>
<Plus className="size-4" />
{t('create')}
</Button>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!discounts ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : discounts.length === 0 ? (
<p className="text-ink-500 border-ink-200 border border-dashed p-8 text-center text-sm">
{t('empty')}
</p>
) : (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('table.name')}</th>
<th className="px-4 py-3 font-semibold">{t('table.value')}</th>
<th className="px-4 py-3 font-semibold">{t('table.window')}</th>
<th className="px-4 py-3 font-semibold">{t('table.usage')}</th>
<th className="px-4 py-3 font-semibold">{t('table.stacking')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{discounts.map((discount) => (
<Row
key={discount.id}
discount={discount}
onEdit={() => setEditing({ discount })}
now={loadedAt}
t={t}
format={format}
/>
))}
</tbody>
</table>
</div>
)}
{editing ? (
<DiscountDialog
// Remounts per target, so the draft state is rebuilt from the right
// discount instead of inheriting whichever row was opened first.
key={editing.discount?.id ?? 'new'}
discount={editing.discount}
open
onOpenChange={(open) => !open && setEditing(null)}
onSaved={() => void load()}
/>
) : null}
</div>
);
}
function Row({
discount,
onEdit,
now,
t,
format,
}: {
discount: AdminDiscount;
onEdit: () => void;
now: number;
t: ReturnType<typeof useTranslations<'discounts'>>;
format: ReturnType<typeof useFormatter>;
}) {
const name =
discount.translations.en?.name ?? discount.translations.vi?.name ?? discount.code ?? '—';
return (
<tr className="hover:bg-ink-50/60">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{discount.trigger === 'CODE' ? (
<Ticket className="text-ink-400 size-4 shrink-0" />
) : (
<Tag className="text-ink-400 size-4 shrink-0" />
)}
<div>
<div className="font-medium">{name}</div>
<div className="text-ink-500 text-xs">
{discount.code ? (
<span className="font-mono">{discount.code}</span>
) : (
t('triggerAutomatic')
)}
{discount.scope === 'PRODUCT' ? ` · ${t('scopeProduct')}` : null}
</div>
</div>
</div>
</td>
<td className="px-4 py-3 font-medium">
{discount.type === 'PERCENTAGE'
? `${discount.value}%`
: formatMoney({ amount: discount.value, currency: 'VND' }, format)}
</td>
<td className="text-ink-600 px-4 py-3 text-xs">
<Window discount={discount} t={t} format={format} />
</td>
<td className="px-4 py-3">
{discount.usageLimit === null
? `${discount.usageCount} · ${t('unlimited')}`
: `${discount.usageCount} / ${discount.usageLimit}`}
</td>
<td className="text-ink-600 px-4 py-3 text-xs">
{discount.stackable ? t('stackable') : t('exclusive')} · {t('priorityShort')}{' '}
{discount.priority}
</td>
<td className="px-4 py-3">
<StatusBadge discount={discount} now={now} t={t} />
</td>
<td className="px-4 py-3 text-right">
<Button variant="outline" size="sm" onClick={onEdit}>
{t('edit')}
</Button>
</td>
</tr>
);
}
function Window({
discount,
t,
format,
}: {
discount: AdminDiscount;
t: ReturnType<typeof useTranslations<'discounts'>>;
format: ReturnType<typeof useFormatter>;
}) {
if (!discount.startsAt && !discount.endsAt) return <>{t('always')}</>;
return (
<>
{discount.startsAt ? formatDateTime(discount.startsAt, format) : t('always')}
{' → '}
{discount.endsAt ? formatDateTime(discount.endsAt, format) : t('noEnd')}
</>
);
}
/**
* Status is derived, not stored.
*
* `isActive` alone would show "Active" on a discount that expired last week or
* has burned through its last use — a screen that tells an operator a promotion
* is running when it is not is worse than no screen.
*/
function StatusBadge({
discount,
now,
t,
}: {
discount: AdminDiscount;
now: number;
t: ReturnType<typeof useTranslations<'discounts'>>;
}) {
if (!discount.isActive) return <Badge variant="neutral">{t('status.paused')}</Badge>;
if (discount.usageLimit !== null && discount.usageCount >= discount.usageLimit) {
return <Badge variant="neutral">{t('status.exhausted')}</Badge>;
}
if (discount.endsAt && new Date(discount.endsAt).getTime() < now) {
return <Badge variant="neutral">{t('status.expired')}</Badge>;
}
if (discount.startsAt && new Date(discount.startsAt).getTime() > now) {
return <Badge variant="warning">{t('status.scheduled')}</Badge>;
}
return <Badge variant="success">{t('status.live')}</Badge>;
}
@@ -0,0 +1,251 @@
'use client';
import { Star } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminReview, type ReviewStatus } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
type StatusFilter = ReviewStatus | 'ALL';
const STATUS_VARIANT = {
PENDING: 'warning',
APPROVED: 'success',
REJECTED: 'neutral',
} as const;
export function ReviewsQueue() {
const t = useTranslations('reviews');
const format = useFormatter();
const { can } = useSession();
const [reviews, setReviews] = useState<AdminReview[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
// Pending by default: this screen exists to empty a queue, not to browse.
const [status, setStatus] = useState<StatusFilter>('PENDING');
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const result = await browserApi.reviewsAdmin.list({
perPage: 50,
q: query.trim() || undefined,
status: status === 'ALL' ? undefined : status,
});
setReviews([...result.items]);
setError(null);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}, [query, status, t]);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
if (!cancelled) void load();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [load]);
async function decide(id: string, decision: 'APPROVED' | 'REJECTED', note?: string) {
setBusyId(id);
setError(null);
try {
await browserApi.reviewsAdmin.moderate(id, { status: decision, note: note ?? null });
await load();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('moderateFailed'));
} finally {
setBusyId(null);
}
}
if (!can(PERMISSIONS.REVIEW_MODERATE)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
<div className="border-ink-200 rounded-card flex overflow-hidden border">
{(['PENDING', 'APPROVED', 'REJECTED', 'ALL'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={status === value}
onClick={() => setStatus(value)}
className={cn(
'px-3 py-2 text-sm transition-colors',
status === value ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
>
{t(`filter.${value}`)}
</button>
))}
</div>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!reviews ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
) : reviews.length === 0 ? (
<p className="text-ink-500 border-ink-200 border border-dashed p-8 text-center text-sm">
{status === 'PENDING' ? t('emptyQueue') : t('empty')}
</p>
) : (
<ul className="space-y-3">
{reviews.map((review) => (
<ReviewCard
key={review.id}
review={review}
busy={busyId === review.id}
onDecide={decide}
t={t}
format={format}
/>
))}
</ul>
)}
</div>
);
}
function ReviewCard({
review,
busy,
onDecide,
t,
format,
}: {
review: AdminReview;
busy: boolean;
onDecide: (id: string, decision: 'APPROVED' | 'REJECTED', note?: string) => void;
t: ReturnType<typeof useTranslations<'reviews'>>;
format: ReturnType<typeof useFormatter>;
}) {
const [rejecting, setRejecting] = useState(false);
const [note, setNote] = useState('');
return (
<li className="border-ink-200 space-y-3 border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Stars rating={review.rating} />
<span className="text-sm font-semibold">{review.title || t('untitled')}</span>
</div>
<p className="text-ink-500 text-xs">
{review.productName} · {review.variantTitle} ·{' '}
<span className="font-mono">#{review.orderNumber}</span>
</p>
</div>
<Badge variant={STATUS_VARIANT[review.status]}>{t(`status.${review.status}`)}</Badge>
</div>
{review.body ? (
<p className="text-ink-700 whitespace-pre-line text-sm">{review.body}</p>
) : (
<p className="text-ink-400 text-sm italic">{t('noBody')}</p>
)}
<p className="text-ink-500 text-xs">
{t('by', { name: review.authorName })} · {formatDateTime(review.createdAt, format)}
{review.moderatedByName ? ` · ${t('moderatedBy', { name: review.moderatedByName })}` : null}
</p>
{review.moderationNote ? (
<p className="border-ink-200 text-ink-500 border-l-2 pl-3 text-xs">
{t('note')}: {review.moderationNote}
</p>
) : null}
{rejecting ? (
<div className="flex flex-wrap items-center gap-2">
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={t('notePlaceholder')}
className="max-w-sm"
aria-label={t('notePlaceholder')}
/>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => onDecide(review.id, 'REJECTED', note.trim() || undefined)}
>
{t('confirmReject')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRejecting(false)}>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex gap-2">
{/*
Both actions stay available on an already-moderated review: a
decision made in haste has to be reversible, and the aggregate is
recomputed from scratch on every decision so reversing one is safe.
*/}
<Button
size="sm"
disabled={busy || review.status === 'APPROVED'}
onClick={() => onDecide(review.id, 'APPROVED')}
>
{t('approve')}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || review.status === 'REJECTED'}
onClick={() => setRejecting(true)}
>
{t('reject')}
</Button>
</div>
)}
</li>
);
}
function Stars({ rating }: { rating: number }) {
return (
<span className="flex items-center gap-0.5" aria-label={`${rating}/5`}>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
aria-hidden
className={cn('size-3.5', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
))}
</span>
);
}
+2 -2
View File
@@ -47,8 +47,8 @@ export const NAVIGATION: NavSection[] = [
{
titleKey: 'marketing',
items: [
{ href: '/promotions', labelKey: 'promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
{ href: '/coupons', labelKey: 'coupons', permission: PERMISSIONS.COUPON_MANAGE },
// One entry, not "Promotions" + "Coupons" — see ADR-0020.
{ href: '/discounts', labelKey: 'discounts', permission: PERMISSIONS.PROMOTION_MANAGE },
{ href: '/reviews', labelKey: 'reviews', permission: PERMISSIONS.REVIEW_MODERATE },
{ href: '/cms', labelKey: 'cms', permission: PERMISSIONS.CMS_READ },
],
+158 -10
View File
@@ -65,21 +65,13 @@
"title": "Customers",
"body": "Customer records, order history and addresses."
},
"promotions": {
"title": "Promotions",
"body": "Automatic cart-level discount rules."
},
"coupons": {
"title": "Coupons",
"body": "Coupon codes, usage limits and redemption reporting."
},
"reviews": {
"title": "Reviews",
"body": "Review moderation queue."
"body": "Every review comes from a verified purchase. Approve or reject before it appears on the storefront."
},
"cms": {
"title": "Content",
"body": "Homepage blocks, banners, blog posts and static pages."
"body": "Journal posts and static pages. Written in Markdown, published per language."
},
"users": {
"title": "Users",
@@ -88,6 +80,10 @@
"roles": {
"title": "Roles",
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
},
"discounts": {
"title": "Discounts",
"body": "Promotions and coupon codes. Both run on the same rules — a coupon is simply a promotion that needs a code typed."
}
},
"settings": {
@@ -281,5 +277,157 @@
"total": "Total",
"placed": "Placed"
}
},
"discounts": {
"noPermission": "You do not have permission to manage discounts.",
"loadFailed": "Could not load discounts.",
"saveFailed": "Could not save this discount.",
"searchPlaceholder": "Search by name or code",
"create": "New discount",
"edit": "Edit",
"empty": "No discounts yet. Create one to start running an offer.",
"createTitle": "New discount",
"editTitle": "Edit discount",
"dialogHint": "A coupon is a promotion that needs a code typed. Both use the same rules.",
"filter": {
"ALL": "All",
"CODE": "Coupons",
"AUTOMATIC": "Automatic"
},
"trigger": "Applies",
"triggerCode": "When a code is entered",
"triggerAutomatic": "Automatically",
"code": "Code",
"codeNotApplicable": "Not used for automatic promotions",
"type": "Discount",
"typePercentage": "Percentage",
"typeFixed": "Fixed amount",
"valuePercent": "Percent off",
"valueAmount": "Amount off",
"valuePercentHint": "Rounded down, never up.",
"valueAmountHint": "In đồng, e.g. 100000 for 100.000 ₫.",
"scope": "Applies to",
"scopeOrder": "Whole order",
"scopeProduct": "Selected products",
"scopeProductHint": "Product targeting is set from the product and collection screens.",
"name": "Name",
"namePlaceholder": "Summer sale",
"nameHint": "Shown to the shopper in their bag and on the order.",
"startsAt": "Starts",
"endsAt": "Ends",
"minSubtotal": "Minimum subtotal",
"noMinimum": "No minimum",
"usageLimit": "Usage limit",
"unlimited": "Unlimited",
"alreadyUsed": "Already used {count} time(s).",
"priority": "Priority",
"priorityShort": "Priority",
"priorityHint": "Lower runs first when several apply.",
"stackable": "Stackable",
"stackableHint": "Can combine with other discounts.",
"exclusive": "Exclusive",
"active": "Active",
"activeHint": "Switch off to stop it applying without deleting it.",
"always": "Always",
"noEnd": "No end",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving…",
"status": {
"live": "Live",
"paused": "Paused",
"scheduled": "Scheduled",
"expired": "Expired",
"exhausted": "Used up"
},
"table": {
"name": "Name",
"value": "Value",
"window": "Window",
"usage": "Used",
"stacking": "Stacking",
"status": "Status"
},
"delete": "Retire",
"deleteConfirm": "Confirm retire",
"deleteFailed": "Could not retire this discount."
},
"reviews": {
"noPermission": "You do not have permission to moderate reviews.",
"loadFailed": "Could not load reviews.",
"moderateFailed": "Could not save that decision.",
"searchPlaceholder": "Search text or author",
"filter": {
"PENDING": "Pending",
"APPROVED": "Approved",
"REJECTED": "Rejected",
"ALL": "All"
},
"status": {
"PENDING": "Pending",
"APPROVED": "Approved",
"REJECTED": "Rejected"
},
"empty": "No reviews match this filter.",
"emptyQueue": "Nothing waiting. The queue is clear.",
"untitled": "(no title)",
"noBody": "No written review — a rating only.",
"by": "By {name}",
"moderatedBy": "Handled by {name}",
"note": "Note",
"notePlaceholder": "Why (internal, never shown to the shopper)",
"approve": "Approve",
"reject": "Reject",
"confirmReject": "Confirm reject",
"cancel": "Cancel"
},
"cms": {
"noPermission": "You do not have permission to manage content.",
"loadFailed": "Could not load content.",
"saveFailed": "Could not save this entry.",
"deleteFailed": "Could not retire this entry.",
"searchPlaceholder": "Search by title",
"filter": {
"ALL": "All",
"POST": "Posts",
"PAGE": "Pages"
},
"type": {
"POST": "Post",
"PAGE": "Page"
},
"status": {
"DRAFT": "Draft",
"PUBLISHED": "Published"
},
"table": {
"title": "Title",
"languages": "Languages",
"status": "Status",
"updated": "Updated"
},
"newPost": "New post",
"newPage": "New page",
"empty": "Nothing written yet.",
"untitled": "(untitled)",
"edit": "Edit",
"backToList": "Back to content",
"title": "Title",
"slug": "URL slug",
"slugHint": "Lowercase, hyphens. Different per language.",
"excerpt": "Excerpt",
"excerptHint": "The summary shown on the journal listing.",
"body": "Body",
"bodyHint": "Markdown. Headings, lists, links, bold and tables.",
"seo": "Search engine listing",
"metaTitle": "Meta title",
"metaDescription": "Meta description",
"saveDraft": "Save draft",
"saving": "Saving…",
"publish": "Publish",
"update": "Update",
"unpublish": "Unpublish",
"delete": "Retire",
"deleteConfirm": "Confirm retire"
}
}
+158 -10
View File
@@ -65,21 +65,13 @@
"title": "Khách hàng",
"body": "Hồ sơ khách hàng, lịch sử đơn và địa chỉ."
},
"promotions": {
"title": "Khuyến mãi",
"body": "Quy tắc giảm giá tự động ở cấp giỏ hàng."
},
"coupons": {
"title": "Mã giảm giá",
"body": "Mã giảm giá, giới hạn sử dụng và báo cáo quy đổi."
},
"reviews": {
"title": "Đánh giá",
"body": "Hàng đợi kiểm duyệt đánh giá."
"body": "Mọi đánh giá đều đến từ đơn hàng đã mua. Duyệt hoặc từ chối trước khi hiển thị trên cửa hàng."
},
"cms": {
"title": "Nội dung",
"body": "Khối trang chủ, banner, bài viết và trang tĩnh."
"body": "Bài viết và trang tĩnh. Soạn bằng Markdown, đăng theo từng ngôn ngữ."
},
"users": {
"title": "Người dùng",
@@ -88,6 +80,10 @@
"roles": {
"title": "Vai trò",
"body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại."
},
"discounts": {
"title": "Khuyến mãi",
"body": "Chương trình khuyến mãi và mã giảm giá. Cả hai dùng chung bộ quy tắc — mã giảm giá chỉ là khuyến mãi cần nhập mã."
}
},
"settings": {
@@ -281,5 +277,157 @@
"total": "Tổng tiền",
"placed": "Đặt lúc"
}
},
"discounts": {
"noPermission": "Bạn không có quyền quản lý khuyến mãi.",
"loadFailed": "Không tải được danh sách khuyến mãi.",
"saveFailed": "Không lưu được khuyến mãi này.",
"searchPlaceholder": "Tìm theo tên hoặc mã",
"create": "Tạo khuyến mãi",
"edit": "Sửa",
"empty": "Chưa có khuyến mãi nào. Tạo một chương trình để bắt đầu.",
"createTitle": "Khuyến mãi mới",
"editTitle": "Sửa khuyến mãi",
"dialogHint": "Mã giảm giá là khuyến mãi cần nhập mã. Cả hai dùng chung một bộ quy tắc.",
"filter": {
"ALL": "Tất cả",
"CODE": "Mã giảm giá",
"AUTOMATIC": "Tự động"
},
"trigger": "Áp dụng",
"triggerCode": "Khi nhập mã",
"triggerAutomatic": "Tự động",
"code": "Mã",
"codeNotApplicable": "Không dùng cho khuyến mãi tự động",
"type": "Kiểu giảm",
"typePercentage": "Phần trăm",
"typeFixed": "Số tiền cố định",
"valuePercent": "Giảm (%)",
"valueAmount": "Số tiền giảm",
"valuePercentHint": "Luôn làm tròn xuống.",
"valueAmountHint": "Đơn vị đồng, ví dụ 100000 cho 100.000 ₫.",
"scope": "Phạm vi",
"scopeOrder": "Toàn đơn hàng",
"scopeProduct": "Sản phẩm đã chọn",
"scopeProductHint": "Chọn sản phẩm áp dụng ở màn hình sản phẩm và bộ sưu tập.",
"name": "Tên",
"namePlaceholder": "Khuyến mãi hè",
"nameHint": "Hiển thị cho khách trong giỏ hàng và trên đơn hàng.",
"startsAt": "Bắt đầu",
"endsAt": "Kết thúc",
"minSubtotal": "Giá trị tối thiểu",
"noMinimum": "Không yêu cầu",
"usageLimit": "Giới hạn lượt dùng",
"unlimited": "Không giới hạn",
"alreadyUsed": "Đã dùng {count} lượt.",
"priority": "Thứ tự ưu tiên",
"priorityShort": "Ưu tiên",
"priorityHint": "Số nhỏ hơn được áp dụng trước.",
"stackable": "Cộng dồn",
"stackableHint": "Có thể dùng chung với khuyến mãi khác.",
"exclusive": "Riêng lẻ",
"active": "Đang bật",
"activeHint": "Tắt để ngừng áp dụng mà không xoá.",
"always": "Luôn luôn",
"noEnd": "Không giới hạn",
"cancel": "Huỷ",
"save": "Lưu",
"saving": "Đang lưu…",
"status": {
"live": "Đang chạy",
"paused": "Tạm dừng",
"scheduled": "Đã lên lịch",
"expired": "Hết hạn",
"exhausted": "Hết lượt"
},
"table": {
"name": "Tên",
"value": "Giá trị",
"window": "Thời gian",
"usage": "Đã dùng",
"stacking": "Cộng dồn",
"status": "Trạng thái"
},
"delete": "Ngừng dùng",
"deleteConfirm": "Xác nhận ngừng",
"deleteFailed": "Không ngừng được khuyến mãi này."
},
"reviews": {
"noPermission": "Bạn không có quyền kiểm duyệt đánh giá.",
"loadFailed": "Không tải được đánh giá.",
"moderateFailed": "Không lưu được quyết định này.",
"searchPlaceholder": "Tìm nội dung hoặc người viết",
"filter": {
"PENDING": "Chờ duyệt",
"APPROVED": "Đã duyệt",
"REJECTED": "Từ chối",
"ALL": "Tất cả"
},
"status": {
"PENDING": "Chờ duyệt",
"APPROVED": "Đã duyệt",
"REJECTED": "Từ chối"
},
"empty": "Không có đánh giá nào khớp bộ lọc.",
"emptyQueue": "Không còn đánh giá nào chờ duyệt.",
"untitled": "(không có tiêu đề)",
"noBody": "Chỉ chấm sao, không có nhận xét.",
"by": "Bởi {name}",
"moderatedBy": "Xử lý bởi {name}",
"note": "Ghi chú",
"notePlaceholder": "Lý do (nội bộ, khách không nhìn thấy)",
"approve": "Duyệt",
"reject": "Từ chối",
"confirmReject": "Xác nhận từ chối",
"cancel": "Huỷ"
},
"cms": {
"noPermission": "Bạn không có quyền quản lý nội dung.",
"loadFailed": "Không tải được nội dung.",
"saveFailed": "Không lưu được nội dung này.",
"deleteFailed": "Không ngừng được nội dung này.",
"searchPlaceholder": "Tìm theo tiêu đề",
"filter": {
"ALL": "Tất cả",
"POST": "Bài viết",
"PAGE": "Trang"
},
"type": {
"POST": "Bài viết",
"PAGE": "Trang"
},
"status": {
"DRAFT": "Bản nháp",
"PUBLISHED": "Đã đăng"
},
"table": {
"title": "Tiêu đề",
"languages": "Ngôn ngữ",
"status": "Trạng thái",
"updated": "Cập nhật"
},
"newPost": "Bài viết mới",
"newPage": "Trang mới",
"empty": "Chưa có nội dung nào.",
"untitled": "(chưa có tiêu đề)",
"edit": "Sửa",
"backToList": "Quay lại danh sách",
"title": "Tiêu đề",
"slug": "Đường dẫn",
"slugHint": "Chữ thường, gạch nối. Khác nhau theo ngôn ngữ.",
"excerpt": "Tóm tắt",
"excerptHint": "Đoạn tóm tắt hiển thị ở danh sách bài viết.",
"body": "Nội dung",
"bodyHint": "Markdown. Tiêu đề, danh sách, liên kết, in đậm và bảng.",
"seo": "Hiển thị trên công cụ tìm kiếm",
"metaTitle": "Meta title",
"metaDescription": "Meta description",
"saveDraft": "Lưu nháp",
"saving": "Đang lưu…",
"publish": "Đăng",
"update": "Cập nhật",
"unpublish": "Gỡ xuống",
"delete": "Ngừng dùng",
"deleteConfirm": "Xác nhận ngừng"
}
}