Stage M7
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user