Finish admin page
This commit is contained in:
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { BrandsPanel } from '@/features/taxonomy/panels';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.brands');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function BrandsPage() {
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('pages.brands');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="brand.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<BrandsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { CategoriesPanel } from '@/features/taxonomy/panels';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.categories');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('pages.categories');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="category.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<CategoriesPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { CollectionsPanel } from '@/features/taxonomy/panels';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.collections');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CollectionsPage() {
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('pages.collections');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="collection.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<CollectionsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
title={LOCALE_LABELS[locale]}
|
||||
className={cn(
|
||||
'rounded-card px-1.5 py-1 text-xs font-semibold uppercase tracking-widest transition-colors',
|
||||
locale === activeLocale ? 'bg-ink-100 text-ink-950' : 'text-ink-400 hover:text-ink-950',
|
||||
locale === activeLocale ? 'bg-ink-100 text-ink-950' : 'text-ink-500 hover:text-ink-950',
|
||||
)}
|
||||
>
|
||||
{locale}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AdminSidebar() {
|
||||
<nav className="space-y-6 p-5">
|
||||
{sections.map((section) => (
|
||||
<div key={section.titleKey}>
|
||||
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
<h2 className="text-ink-500 text-eyebrow uppercase">
|
||||
{t(`sections.${section.titleKey}`)}
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
|
||||
@@ -235,7 +235,7 @@ export function ContentEditor({
|
||||
// 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' : '',
|
||||
!draft.translations[key].title.trim() && locale !== key ? 'text-ink-500' : '',
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
@@ -258,7 +258,7 @@ export function ContentEditor({
|
||||
onChange={(event) => setField('slug', event.target.value)}
|
||||
placeholder="how-to-layer"
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('slugHint')}</span>
|
||||
<span className="text-ink-500 text-xs">{t('slugHint')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +269,7 @@ export function ContentEditor({
|
||||
value={fields.excerpt}
|
||||
onChange={(event) => setField('excerpt', event.target.value)}
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('excerptHint')}</span>
|
||||
<span className="text-ink-500 text-xs">{t('excerptHint')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
@@ -282,7 +282,7 @@ export function ContentEditor({
|
||||
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>
|
||||
<span className="text-ink-500 text-xs">{t('bodyHint')}</span>
|
||||
</label>
|
||||
|
||||
<details className="border-ink-200 border p-4">
|
||||
|
||||
@@ -7,13 +7,27 @@ 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 {
|
||||
Badge,
|
||||
Button,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
EmptyState,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
type TypeFilter = ContentType | 'ALL';
|
||||
const TYPE_FILTERS = ['ALL', 'POST', 'PAGE'] as const satisfies readonly (ContentType | 'ALL')[];
|
||||
type TypeFilter = (typeof TYPE_FILTERS)[number];
|
||||
|
||||
export function ContentTable() {
|
||||
const t = useTranslations('cms');
|
||||
@@ -68,22 +82,12 @@ export function ContentTable() {
|
||||
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>
|
||||
<SegmentedControl
|
||||
label={t('searchPlaceholder')}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
options={TYPE_FILTERS.map((value) => ({ value, label: t(`filter.${value}`) }))}
|
||||
/>
|
||||
|
||||
{canManage ? (
|
||||
<div className="ml-auto flex gap-2">
|
||||
@@ -115,75 +119,69 @@ export function ContentTable() {
|
||||
<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>
|
||||
<EmptyState title={t('empty')} />
|
||||
) : (
|
||||
<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;
|
||||
<DataTable>
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('table.title')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.languages')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.status')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.updated')}</DataTableHeader>
|
||||
<DataTableHeader />
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{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>
|
||||
return (
|
||||
<DataTableRow key={entry.id}>
|
||||
<DataTableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.type === 'POST' ? (
|
||||
<Newspaper className="text-ink-500 size-4 shrink-0" />
|
||||
) : (
|
||||
<FileText className="text-ink-500 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>
|
||||
</td>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{languages.map((language) => (
|
||||
<Badge key={language} variant="outline">
|
||||
{language.toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<DataTableCell>
|
||||
<div className="flex gap-1">
|
||||
{languages.map((language) => (
|
||||
<Badge key={language} variant="outline">
|
||||
{language.toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={entry.status === 'PUBLISHED' ? 'success' : 'neutral'}>
|
||||
{t(`status.${entry.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<DataTableCell>
|
||||
<Badge variant={entry.status === 'PUBLISHED' ? 'success' : 'neutral'}>
|
||||
{t(`status.${entry.status}`)}
|
||||
</Badge>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="text-ink-500 px-4 py-3 text-xs">
|
||||
{formatDateTime(entry.updatedAt, format)}
|
||||
</td>
|
||||
<DataTableCell className="text-ink-500 text-xs">
|
||||
{formatDateTime(entry.updatedAt, format)}
|
||||
</DataTableCell>
|
||||
|
||||
<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>
|
||||
<DataTableCell className="text-right">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/cms/${entry.id}`}>{t('edit')}</Link>
|
||||
</Button>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
);
|
||||
})}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -214,7 +214,7 @@ export function DiscountDialog({
|
||||
onChange={(event) => set('value', event.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">
|
||||
<span className="text-ink-500 text-xs">
|
||||
{isPercentage ? t('valuePercentHint') : t('valueAmountHint')}
|
||||
</span>
|
||||
</label>
|
||||
@@ -243,7 +243,7 @@ export function DiscountDialog({
|
||||
'-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',
|
||||
: 'text-ink-500 hover:text-ink-600 border-transparent',
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
@@ -266,7 +266,7 @@ export function DiscountDialog({
|
||||
}
|
||||
placeholder={t('namePlaceholder')}
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('nameHint')}</span>
|
||||
<span className="text-ink-500 text-xs">{t('nameHint')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -317,7 +317,7 @@ export function DiscountDialog({
|
||||
placeholder={t('unlimited')}
|
||||
/>
|
||||
{discount ? (
|
||||
<span className="text-ink-400 text-xs">
|
||||
<span className="text-ink-500 text-xs">
|
||||
{t('alreadyUsed', { count: discount.usageCount })}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -337,7 +337,7 @@ export function DiscountDialog({
|
||||
value={draft.priority}
|
||||
onChange={(event) => set('priority', event.target.value)}
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('priorityHint')}</span>
|
||||
<span className="text-ink-500 text-xs">{t('priorityHint')}</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3 pt-6">
|
||||
@@ -435,7 +435,7 @@ function Choice({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hint ? <span className="text-ink-400 block text-xs">{hint}</span> : null}
|
||||
{hint ? <span className="text-ink-500 block text-xs">{hint}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -469,7 +469,7 @@ function Toggle({
|
||||
<label htmlFor={id} className="block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<span className="text-ink-400 block text-xs">{hint}</span>
|
||||
<span className="text-ink-500 block text-xs">{hint}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,20 @@ 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 {
|
||||
Badge,
|
||||
Button,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
EmptyState,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -14,7 +27,8 @@ import { formatDateTime, formatMoney } from '@/lib/format';
|
||||
|
||||
import { DiscountDialog } from './discount-dialog';
|
||||
|
||||
type TriggerFilter = 'ALL' | 'AUTOMATIC' | 'CODE';
|
||||
const TRIGGER_FILTERS = ['ALL', 'CODE', 'AUTOMATIC'] as const;
|
||||
type TriggerFilter = (typeof TRIGGER_FILTERS)[number];
|
||||
|
||||
export function DiscountsTable() {
|
||||
const t = useTranslations('discounts');
|
||||
@@ -87,22 +101,12 @@ export function DiscountsTable() {
|
||||
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>
|
||||
<SegmentedControl
|
||||
label={t('table.status')}
|
||||
value={trigger}
|
||||
onChange={setTrigger}
|
||||
options={TRIGGER_FILTERS.map((value) => ({ value, label: t(`filter.${value}`) }))}
|
||||
/>
|
||||
|
||||
<Button className="ml-auto" onClick={() => setEditing({ discount: null })}>
|
||||
<Plus className="size-4" />
|
||||
@@ -123,37 +127,41 @@ export function DiscountsTable() {
|
||||
<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>
|
||||
<EmptyState
|
||||
title={t('empty')}
|
||||
action={
|
||||
<Button onClick={() => setEditing({ discount: null })}>
|
||||
<Plus className="size-4" />
|
||||
{t('create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<DataTable>
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('table.name')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.value')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.window')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.usage')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.stacking')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.status')}</DataTableHeader>
|
||||
<DataTableHeader>
|
||||
<span className="sr-only">{t('edit')}</span>
|
||||
</DataTableHeader>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{discounts.map((discount) => (
|
||||
<Row
|
||||
key={discount.id}
|
||||
discount={discount}
|
||||
onEdit={() => setEditing({ discount })}
|
||||
now={loadedAt}
|
||||
t={t}
|
||||
format={format}
|
||||
/>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
@@ -188,13 +196,13 @@ function Row({
|
||||
discount.translations.en?.name ?? discount.translations.vi?.name ?? discount.code ?? '—';
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-ink-50/60">
|
||||
<td className="px-4 py-3">
|
||||
<DataTableRow>
|
||||
<DataTableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{discount.trigger === 'CODE' ? (
|
||||
<Ticket className="text-ink-400 size-4 shrink-0" />
|
||||
<Ticket className="text-ink-500 size-4 shrink-0" />
|
||||
) : (
|
||||
<Tag className="text-ink-400 size-4 shrink-0" />
|
||||
<Tag className="text-ink-500 size-4 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{name}</div>
|
||||
@@ -208,39 +216,39 @@ function Row({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<DataTableCell className="font-medium">
|
||||
{discount.type === 'PERCENTAGE'
|
||||
? `${discount.value}%`
|
||||
: formatMoney({ amount: discount.value, currency: 'VND' }, format)}
|
||||
</td>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="text-ink-600 px-4 py-3 text-xs">
|
||||
<DataTableCell className="text-ink-600 text-xs">
|
||||
<Window discount={discount} t={t} format={format} />
|
||||
</td>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<DataTableCell>
|
||||
{discount.usageLimit === null
|
||||
? `${discount.usageCount} · ${t('unlimited')}`
|
||||
: `${discount.usageCount} / ${discount.usageLimit}`}
|
||||
</td>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="text-ink-600 px-4 py-3 text-xs">
|
||||
<DataTableCell className="text-ink-600 text-xs">
|
||||
{discount.stackable ? t('stackable') : t('exclusive')} · {t('priorityShort')}{' '}
|
||||
{discount.priority}
|
||||
</td>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<DataTableCell>
|
||||
<StatusBadge discount={discount} now={now} t={t} />
|
||||
</td>
|
||||
</DataTableCell>
|
||||
|
||||
<td className="px-4 py-3 text-right">
|
||||
<DataTableCell className="text-right">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
{t('edit')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,19 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError, type StockAdjustment } from '@sport/api-client';
|
||||
import { PERMISSIONS, type InventoryLevel } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
Input,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -106,83 +118,79 @@ export function InventoryTable() {
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<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.sku')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.product')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.onHand')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.reserved')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.available')}</th>
|
||||
<th className="px-4 py-3 font-semibold" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{levels.map((level) => (
|
||||
<tr key={`${level.variantId}-${level.locationId}`}>
|
||||
<td className="px-4 py-3 font-mono text-xs">{level.sku}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="truncate">{level.productName}</p>
|
||||
<p className="text-ink-400 text-xs">{level.variantTitle}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">{level.onHand}</td>
|
||||
<td className="text-ink-500 px-4 py-3">{level.reserved}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
level.available === 0
|
||||
? 'text-danger'
|
||||
: level.available <= LOW_STOCK
|
||||
? 'text-warning'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
{level.available}
|
||||
</span>
|
||||
{level.available === 0 ? (
|
||||
<Badge variant="neutral" className="ml-2">
|
||||
{t('outOfStock')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!can(PERMISSIONS.INVENTORY_UPDATE) ? null : editing === level.variantId ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={delta}
|
||||
onChange={(event) => setDelta(event.target.value)}
|
||||
placeholder="+10"
|
||||
className="h-9 w-24"
|
||||
aria-label={t('deltaLabel')}
|
||||
/>
|
||||
<Button size="sm" disabled={busy} onClick={() => void adjust(level)}>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setEditing(level.variantId);
|
||||
setDelta('');
|
||||
}}
|
||||
>
|
||||
{t('adjust')}
|
||||
</Button>
|
||||
<DataTable>
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('table.sku')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.product')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.onHand')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.reserved')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.available')}</DataTableHeader>
|
||||
<DataTableHeader />
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{levels.map((level) => (
|
||||
<DataTableRow key={`${level.variantId}-${level.locationId}`}>
|
||||
<DataTableCell className="font-mono text-xs">{level.sku}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<p className="truncate">{level.productName}</p>
|
||||
<p className="text-ink-500 text-xs">{level.variantTitle}</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>{level.onHand}</DataTableCell>
|
||||
<DataTableCell className="text-ink-500">{level.reserved}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
level.available === 0
|
||||
? 'text-danger'
|
||||
: level.available <= LOW_STOCK
|
||||
? 'text-warning'
|
||||
: '',
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
>
|
||||
{level.available}
|
||||
</span>
|
||||
{level.available === 0 ? (
|
||||
<Badge variant="neutral" className="ml-2">
|
||||
{t('outOfStock')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-right">
|
||||
{!can(PERMISSIONS.INVENTORY_UPDATE) ? null : editing === level.variantId ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={delta}
|
||||
onChange={(event) => setDelta(event.target.value)}
|
||||
placeholder="+10"
|
||||
className="h-9 w-24"
|
||||
aria-label={t('deltaLabel')}
|
||||
/>
|
||||
<Button size="sm" disabled={busy} onClick={() => void adjust(level)}>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setEditing(level.variantId);
|
||||
setDelta('');
|
||||
}}
|
||||
>
|
||||
{t('adjust')}
|
||||
</Button>
|
||||
)}
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -174,7 +174,7 @@ export function MediaLibrary() {
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-ink-600 truncate text-xs">{asset.altText ?? asset.storageKey}</p>
|
||||
<p className="text-ink-400 text-[0.625rem]">
|
||||
<p className="text-ink-500 text-eyebrow">
|
||||
{asset.width && asset.height ? `${asset.width}×${asset.height} · ` : ''}
|
||||
{Math.round(asset.sizeBytes / 1024)} KB
|
||||
</p>
|
||||
@@ -183,7 +183,7 @@ export function MediaLibrary() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(asset)}
|
||||
className="rounded-card bg-ink-950/80 absolute right-1 top-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="rounded-card bg-ink-950/80 text-eyebrow absolute right-1 top-1 px-2 py-1 uppercase text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function OrderDetail({ orderId }: { orderId: string }) {
|
||||
</div>
|
||||
|
||||
{next.includes('FULFILLED') ? (
|
||||
<p className="text-ink-400 text-xs">{t('fulfilHint')}</p>
|
||||
<p className="text-ink-500 text-xs">{t('fulfilHint')}</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
@@ -154,7 +154,7 @@ export function OrderDetail({ orderId }: { orderId: string }) {
|
||||
<td className="px-6 py-3">
|
||||
<p className="font-medium">{line.productName}</p>
|
||||
<p className="text-ink-500 text-xs">{line.variantTitle}</p>
|
||||
<p className="text-ink-400 font-mono text-[0.625rem]">{line.sku}</p>
|
||||
<p className="text-ink-500 text-eyebrow font-mono">{line.sku}</p>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right tabular-nums">
|
||||
{money(line.unitPrice.amount)} × {line.quantity}
|
||||
|
||||
@@ -6,7 +6,19 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type OrderListItem, type OrderStatus } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
Input,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -118,51 +130,49 @@ export function OrdersTable() {
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-ink-500 py-16 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('column.order')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.customer')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.status')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.items')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.total')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('column.placed')}</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-ink-50/60">
|
||||
<td className="px-4 py-3 font-mono text-xs font-semibold">
|
||||
<Link href={`/orders/${order.id}`} className="hover:underline">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{order.customerName}</p>
|
||||
<p className="text-ink-500 text-xs">{order.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[order.status]}>
|
||||
{t(`status.${order.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums">{order.itemCount}</td>
|
||||
<td className="px-4 py-3 tabular-nums">{formatMoney(order.total, format)}</td>
|
||||
<td className="text-ink-500 px-4 py-3 text-xs">
|
||||
{formatDateTime(order.placedAt, format)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/orders/${order.id}`}>{t('view')}</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable>
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('column.order')}</DataTableHeader>
|
||||
<DataTableHeader>{t('column.customer')}</DataTableHeader>
|
||||
<DataTableHeader>{t('column.status')}</DataTableHeader>
|
||||
<DataTableHeader>{t('column.items')}</DataTableHeader>
|
||||
<DataTableHeader>{t('column.total')}</DataTableHeader>
|
||||
<DataTableHeader>{t('column.placed')}</DataTableHeader>
|
||||
<DataTableHeader />
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{orders.map((order) => (
|
||||
<DataTableRow key={order.id}>
|
||||
<DataTableCell className="font-mono text-xs font-semibold">
|
||||
<Link href={`/orders/${order.id}`} className="hover:underline">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<p className="font-medium">{order.customerName}</p>
|
||||
<p className="text-ink-500 text-xs">{order.email}</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<Badge variant={STATUS_VARIANT[order.status]}>
|
||||
{t(`status.${order.status}`)}
|
||||
</Badge>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">{order.itemCount}</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">
|
||||
{formatMoney(order.total, format)}
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-ink-500 text-xs">
|
||||
{formatDateTime(order.placedAt, format)}
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-right">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/orders/${order.id}`}>{t('view')}</Link>
|
||||
</Button>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ProductEditor({ productId }: { productId: string }) {
|
||||
>
|
||||
{t(`tab.${value}`)}
|
||||
{value === 'variants' ? (
|
||||
<span className="text-ink-400 ml-2 text-xs">{product.variants.length}</span>
|
||||
<span className="text-ink-500 ml-2 text-xs">{product.variants.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -230,7 +230,7 @@ export function ProductForm({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-ink-400 text-xs">{t('localeHint')}</p>
|
||||
<p className="text-ink-500 text-xs">{t('localeHint')}</p>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('name')}</span>
|
||||
@@ -303,7 +303,7 @@ export function ProductForm({
|
||||
{t('addOption')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-ink-400 text-xs">{t('optionsHint')}</p>
|
||||
<p className="text-ink-500 text-xs">{t('optionsHint')}</p>
|
||||
|
||||
{options.map((option, optionIndex) => (
|
||||
<OptionEditor
|
||||
@@ -343,7 +343,7 @@ export function ProductForm({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-ink-400 text-xs">{t('generationHint')}</p>
|
||||
<p className="text-ink-500 text-xs">{t('generationHint')}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -422,9 +422,7 @@ function OptionEditor({
|
||||
<div className="border-ink-200 space-y-3 border p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-ink-500 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{t('optionKey')}
|
||||
</span>
|
||||
<span className="text-ink-500 text-eyebrow uppercase">{t('optionKey')}</span>
|
||||
<Input
|
||||
value={option.key}
|
||||
onChange={(event) => onChange({ ...option, key: event.target.value })}
|
||||
@@ -432,9 +430,7 @@ function OptionEditor({
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-ink-500 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{t('optionName')}
|
||||
</span>
|
||||
<span className="text-ink-500 text-eyebrow uppercase">{t('optionName')}</span>
|
||||
<Input
|
||||
value={option.names[locale]}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -7,7 +7,19 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type AdminProductListItem } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
Input,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -121,90 +133,86 @@ export function ProductsTable() {
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-ink-500 py-12 text-center text-sm">{t('empty')}</p>
|
||||
) : (
|
||||
<div className="border-ink-200 overflow-x-auto border bg-white">
|
||||
<table className="min-w-4xl 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.product')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.variants')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.stock')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.price')}</th>
|
||||
<th className="px-4 py-3 font-semibold" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{items.map((product) => (
|
||||
<tr key={product.id}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-ink-100 relative size-10 shrink-0 overflow-hidden">
|
||||
{product.thumbnailUrl ? (
|
||||
<Image
|
||||
src={product.thumbnailUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
<p className="text-ink-400 truncate text-xs">
|
||||
{product.brandName ?? '—'} · {product.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[product.status]}>
|
||||
{t(`status.${product.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-ink-600 px-4 py-3">{product.variantCount}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(product.totalStock === 0 && 'text-danger')}>
|
||||
{product.totalStock}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-ink-600 px-4 py-3">
|
||||
{product.priceRange
|
||||
? product.priceRange.min.amount === product.priceRange.max.amount
|
||||
? formatMoney(product.priceRange.min, format)
|
||||
: `${formatMoney(product.priceRange.min, format)} – ${formatMoney(product.priceRange.max, format)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{/* Flex, not inline spacing: the labels are translated, and
|
||||
in Vietnamese "NGỪNG BÁN" is long enough to wrap the two
|
||||
buttons onto separate lines without an explicit row. */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/products/${product.id}`}>{t('edit')}</Link>
|
||||
</Button>
|
||||
{can(PERMISSIONS.PRODUCT_PUBLISH) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={product.status === 'ACTIVE' ? 'ghost' : 'default'}
|
||||
disabled={busyId === product.id}
|
||||
onClick={() => void toggleStatus(product)}
|
||||
>
|
||||
{product.status === 'ACTIVE' ? t('unpublish') : t('publish')}
|
||||
</Button>
|
||||
<DataTable>
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('table.product')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.status')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.variants')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.stock')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.price')}</DataTableHeader>
|
||||
<th className="px-4 py-3 font-semibold" />
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{items.map((product) => (
|
||||
<DataTableRow key={product.id}>
|
||||
<DataTableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-ink-100 relative size-10 shrink-0 overflow-hidden">
|
||||
{product.thumbnailUrl ? (
|
||||
<Image
|
||||
src={product.thumbnailUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
<p className="text-ink-500 truncate text-xs">
|
||||
{product.brandName ?? '—'} · {product.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<Badge variant={STATUS_VARIANT[product.status]}>
|
||||
{t(`status.${product.status}`)}
|
||||
</Badge>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-ink-600">{product.variantCount}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<span className={cn(product.totalStock === 0 && 'text-danger')}>
|
||||
{product.totalStock}
|
||||
</span>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-ink-600">
|
||||
{product.priceRange
|
||||
? product.priceRange.min.amount === product.priceRange.max.amount
|
||||
? formatMoney(product.priceRange.min, format)
|
||||
: `${formatMoney(product.priceRange.min, format)} – ${formatMoney(product.priceRange.max, format)}`
|
||||
: '—'}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
{/* Flex, not inline spacing: the labels are translated, and
|
||||
in Vietnamese "NGỪNG BÁN" is long enough to wrap the two
|
||||
buttons onto separate lines without an explicit row. */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/products/${product.id}`}>{t('edit')}</Link>
|
||||
</Button>
|
||||
{can(PERMISSIONS.PRODUCT_PUBLISH) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={product.status === 'ACTIVE' ? 'ghost' : 'default'}
|
||||
disabled={busyId === product.id}
|
||||
onClick={() => void toggleStatus(product)}
|
||||
>
|
||||
{product.status === 'ACTIVE' ? t('unpublish') : t('publish')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -140,7 +140,7 @@ export function VariantGrid({
|
||||
<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">
|
||||
<tr className="text-ink-500 text-eyebrow uppercase">
|
||||
<th className="px-4 py-3 font-semibold">{t('grid.variant')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('grid.sku')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('grid.price')}</th>
|
||||
@@ -202,7 +202,7 @@ export function VariantGrid({
|
||||
<span className={cn(variant.available === 0 && 'text-danger')}>
|
||||
{variant.available}
|
||||
</span>
|
||||
<span className="text-ink-400 ml-1 text-xs">/ {variant.onHand}</span>
|
||||
<span className="text-ink-500 ml-1 text-xs">/ {variant.onHand}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -211,7 +211,7 @@ export function VariantGrid({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-ink-400 text-xs">{t('stockHint')}</p>
|
||||
<p className="text-ink-500 text-xs">{t('stockHint')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
'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 { Badge, Button, EmptyState, Input, Rating, SegmentedControl, Skeleton } 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_FILTERS = ['PENDING', 'APPROVED', 'REJECTED', 'ALL'] as const satisfies readonly (
|
||||
ReviewStatus | 'ALL'
|
||||
)[];
|
||||
type StatusFilter = (typeof STATUS_FILTERS)[number];
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
PENDING: 'warning',
|
||||
@@ -87,22 +89,12 @@ export function ReviewsQueue() {
|
||||
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>
|
||||
<SegmentedControl
|
||||
label={t('searchPlaceholder')}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={STATUS_FILTERS.map((value) => ({ value, label: t(`filter.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
@@ -117,9 +109,7 @@ export function ReviewsQueue() {
|
||||
<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>
|
||||
<EmptyState title={status === 'PENDING' ? t('emptyQueue') : t('empty')} />
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{reviews.map((review) => (
|
||||
@@ -159,7 +149,7 @@ function ReviewCard({
|
||||
<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} />
|
||||
<Rating value={review.rating} size="sm" />
|
||||
<span className="text-sm font-semibold">{review.title || t('untitled')}</span>
|
||||
</div>
|
||||
<p className="text-ink-500 text-xs">
|
||||
@@ -173,7 +163,7 @@ function ReviewCard({
|
||||
{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-sm italic">{t('noBody')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-ink-500 text-xs">
|
||||
@@ -235,17 +225,3 @@ function ReviewCard({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function RolesPanel() {
|
||||
)}
|
||||
>
|
||||
<span>{role.name}</span>
|
||||
<span className="text-ink-400 text-xs">{role.userCount}</span>
|
||||
<span className="text-ink-500 text-xs">{role.userCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -110,9 +110,7 @@ export function RolesPanel() {
|
||||
<div className="mt-6 space-y-5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.resource}>
|
||||
<h3 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{group.resource}
|
||||
</h3>
|
||||
<h3 className="text-ink-500 text-eyebrow uppercase">{group.resource}</h3>
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{group.permissions.map((permission) => {
|
||||
const has = granted.has(permission.key);
|
||||
@@ -123,7 +121,7 @@ export function RolesPanel() {
|
||||
'rounded-card inline-flex items-center gap-1.5 border px-2 py-1 text-xs',
|
||||
has
|
||||
? 'border-success/40 bg-success/10 text-ink-950'
|
||||
: 'border-ink-200 text-ink-400',
|
||||
: 'border-ink-200 text-ink-500',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{has ? '✓' : '·'}</span>
|
||||
|
||||
@@ -5,7 +5,16 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type UserSummary } from '@sport/types';
|
||||
import { Badge, Skeleton } from '@sport/ui';
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
Skeleton,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -65,43 +74,39 @@ export function UsersTable() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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.email')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.type')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.roles')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('table.lastLogin')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-4 py-3 font-medium">{user.displayName}</td>
|
||||
<td className="text-ink-600 px-4 py-3">{user.email}</td>
|
||||
<td className="text-ink-600 px-4 py-3">{user.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role.id} variant="outline">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[user.status]}>{user.status}</Badge>
|
||||
</td>
|
||||
<td className="text-ink-500 px-4 py-3">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable>
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('table.name')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.email')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.type')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.roles')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.status')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.lastLogin')}</DataTableHeader>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{users.map((user) => (
|
||||
<DataTableRow key={user.id}>
|
||||
<DataTableCell className="font-medium">{user.displayName}</DataTableCell>
|
||||
<DataTableCell className="text-ink-600">{user.email}</DataTableCell>
|
||||
<DataTableCell className="text-ink-600">{user.type}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role.id} variant="outline">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<Badge variant={STATUS_VARIANT[user.status]}>{user.status}</Badge>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-ink-500">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '—'}
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
type AdminBrand,
|
||||
type AdminCategory,
|
||||
type AdminCollection,
|
||||
} from '@sport/types';
|
||||
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
import { TaxonomyManager, toPayload, type TaxonomyAdapter } from './taxonomy-manager';
|
||||
|
||||
// The API caps `perPage` at 100 (PAGINATION_DEFAULTS.maxPerPage); asking for
|
||||
// more is a validation error, not a silent clamp. Taxonomies are small enough
|
||||
// that one page holds them — a store with more than 100 brands would need
|
||||
// pagination here, and would notice immediately because the list stops short.
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export function BrandsPanel() {
|
||||
const adapter = useMemo<TaxonomyAdapter<AdminBrand>>(
|
||||
() => ({
|
||||
list: async () => [
|
||||
...(await browserApi.taxonomyAdmin.brands.list({ perPage: PER_PAGE })).items,
|
||||
],
|
||||
create: (draft) => browserApi.taxonomyAdmin.brands.create(toPayload(draft)),
|
||||
update: (id, draft) => browserApi.taxonomyAdmin.brands.update(id, toPayload(draft)),
|
||||
remove: (id) => browserApi.taxonomyAdmin.brands.remove(id),
|
||||
readPermission: PERMISSIONS.BRAND_READ,
|
||||
managePermission: PERMISSIONS.BRAND_MANAGE,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return <TaxonomyManager titleKey="brands" adapter={adapter} />;
|
||||
}
|
||||
|
||||
export function CategoriesPanel() {
|
||||
const adapter = useMemo<TaxonomyAdapter<AdminCategory>>(
|
||||
() => ({
|
||||
list: async () => [
|
||||
...(await browserApi.taxonomyAdmin.categories.list({ perPage: PER_PAGE })).items,
|
||||
],
|
||||
create: (draft) => browserApi.taxonomyAdmin.categories.create(toPayload(draft)),
|
||||
update: (id, draft) => browserApi.taxonomyAdmin.categories.update(id, toPayload(draft)),
|
||||
remove: (id) => browserApi.taxonomyAdmin.categories.remove(id),
|
||||
readPermission: PERMISSIONS.CATEGORY_READ,
|
||||
managePermission: PERMISSIONS.CATEGORY_MANAGE,
|
||||
metaHeader: 'Path',
|
||||
// The materialised path, indented by depth — the list is already sorted
|
||||
// by path, so the indentation lines up into a readable tree.
|
||||
renderMeta: (row) => (
|
||||
<span className="font-mono" style={{ paddingLeft: `${row.depth * 0.75}rem` }}>
|
||||
{row.path}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return <TaxonomyManager titleKey="categories" adapter={adapter} />;
|
||||
}
|
||||
|
||||
export function CollectionsPanel() {
|
||||
const adapter = useMemo<TaxonomyAdapter<AdminCollection>>(
|
||||
() => ({
|
||||
list: async () => [
|
||||
...(await browserApi.taxonomyAdmin.collections.list({ perPage: PER_PAGE })).items,
|
||||
],
|
||||
create: (draft) => browserApi.taxonomyAdmin.collections.create(toPayload(draft)),
|
||||
update: (id, draft) => browserApi.taxonomyAdmin.collections.update(id, toPayload(draft)),
|
||||
remove: (id) => browserApi.taxonomyAdmin.collections.remove(id),
|
||||
readPermission: PERMISSIONS.COLLECTION_READ,
|
||||
managePermission: PERMISSIONS.COLLECTION_MANAGE,
|
||||
metaHeader: 'Type',
|
||||
renderMeta: (row) => row.type,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return <TaxonomyManager titleKey="collections" adapter={adapter} />;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useId, useState, type FormEvent } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { DEFAULT_LOCALE, LOCALES, type Locale, type Permission } from '@sport/types';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeader,
|
||||
DataTableRow,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
EmptyState,
|
||||
Field,
|
||||
Input,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
|
||||
/**
|
||||
* The shape every taxonomy row shares, which is most of one.
|
||||
*
|
||||
* Brands, categories and collections differ in three fields between them —
|
||||
* a parent, a date window, a type — and are otherwise the same record with the
|
||||
* same lifecycle. Three near-identical screens would have been the obvious
|
||||
* thing to write and the wrong thing to maintain: the same reasoning that made
|
||||
* promotions and coupons one entity (ADR-0020).
|
||||
*/
|
||||
export interface TaxonomyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
metaTitle: string | null;
|
||||
metaDescription: string | null;
|
||||
translations: Record<string, TaxonomyTranslationFields>;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
interface TaxonomyTranslationFields {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
metaTitle: string | null;
|
||||
metaDescription: string | null;
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
translations: Record<Locale, TaxonomyTranslationFields>;
|
||||
};
|
||||
|
||||
const EMPTY_TRANSLATION: TaxonomyTranslationFields = {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: null,
|
||||
metaTitle: null,
|
||||
metaDescription: null,
|
||||
};
|
||||
|
||||
function emptyDraft(): Draft {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
translations: Object.fromEntries(
|
||||
LOCALES.map((locale) => [locale, { ...EMPTY_TRANSLATION }]),
|
||||
) as Draft['translations'],
|
||||
};
|
||||
}
|
||||
|
||||
function toDraft(row: TaxonomyRow): Draft {
|
||||
return {
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description ?? '',
|
||||
isActive: row.isActive,
|
||||
translations: Object.fromEntries(
|
||||
LOCALES.map((locale) => [
|
||||
locale,
|
||||
{ ...EMPTY_TRANSLATION, ...(row.translations[locale] ?? {}) },
|
||||
]),
|
||||
) as Draft['translations'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything the manager needs to talk to one specific entity's endpoints. */
|
||||
export interface TaxonomyAdapter<TRow extends TaxonomyRow> {
|
||||
list(): Promise<TRow[]>;
|
||||
create(draft: Draft): Promise<unknown>;
|
||||
update(id: string, draft: Draft): Promise<unknown>;
|
||||
remove(id: string): Promise<void>;
|
||||
readPermission: Permission;
|
||||
managePermission: Permission;
|
||||
/** Extra column between name and status, e.g. a category's path. */
|
||||
renderMeta?: (row: TRow) => React.ReactNode;
|
||||
metaHeader?: string;
|
||||
}
|
||||
|
||||
export function TaxonomyManager<TRow extends TaxonomyRow>({
|
||||
titleKey,
|
||||
adapter,
|
||||
}: {
|
||||
titleKey: 'brands' | 'categories' | 'collections';
|
||||
adapter: TaxonomyAdapter<TRow>;
|
||||
}) {
|
||||
const t = useTranslations('taxonomy');
|
||||
const { can } = useSession();
|
||||
|
||||
const [rows, setRows] = useState<TRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<{ row: TRow | null } | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
adapter
|
||||
.list()
|
||||
.then((result) => {
|
||||
// Guarded, so a response arriving after unmount — or after a newer
|
||||
// request — is discarded instead of writing to dead state.
|
||||
if (!cancelled) {
|
||||
setRows(result);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [adapter, nonce, t]);
|
||||
|
||||
const reload = useCallback(() => setNonce((current) => current + 1), []);
|
||||
|
||||
if (!can(adapter.readPermission)) {
|
||||
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
|
||||
}
|
||||
|
||||
const canManage = can(adapter.managePermission);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canManage ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setEditing({ row: null })}>
|
||||
<Plus className="size-4" />
|
||||
{t(`${titleKey}.create`)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!rows ? (
|
||||
<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>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t(`${titleKey}.empty`)}
|
||||
action={
|
||||
canManage ? (
|
||||
<Button onClick={() => setEditing({ row: null })}>{t(`${titleKey}.create`)}</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataTable minWidth="42rem">
|
||||
<DataTableHead>
|
||||
<DataTableHeader>{t('table.name')}</DataTableHeader>
|
||||
{adapter.metaHeader ? <DataTableHeader>{adapter.metaHeader}</DataTableHeader> : null}
|
||||
<DataTableHeader>{t('table.products')}</DataTableHeader>
|
||||
<DataTableHeader>{t('table.status')}</DataTableHeader>
|
||||
<DataTableHeader>
|
||||
<span className="sr-only">{t('edit')}</span>
|
||||
</DataTableHeader>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{rows.map((row) => (
|
||||
<DataTableRow key={row.id}>
|
||||
<DataTableCell>
|
||||
<div className="font-medium">{row.name}</div>
|
||||
<div className="text-ink-500 font-mono text-xs">/{row.slug}</div>
|
||||
</DataTableCell>
|
||||
|
||||
{adapter.metaHeader ? (
|
||||
<DataTableCell className="text-ink-600 text-xs">
|
||||
{adapter.renderMeta?.(row)}
|
||||
</DataTableCell>
|
||||
) : null}
|
||||
|
||||
<DataTableCell className="tabular-nums">{row.productCount}</DataTableCell>
|
||||
|
||||
<DataTableCell>
|
||||
<Badge variant={row.isActive ? 'success' : 'neutral'}>
|
||||
{row.isActive ? t('active') : t('inactive')}
|
||||
</Badge>
|
||||
</DataTableCell>
|
||||
|
||||
<DataTableCell className="text-right">
|
||||
{canManage ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing({ row })}>
|
||||
{t('edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<TaxonomyDialog
|
||||
key={editing.row?.id ?? 'new'}
|
||||
row={editing.row}
|
||||
adapter={adapter}
|
||||
titleKey={titleKey}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaxonomyDialog<TRow extends TaxonomyRow>({
|
||||
row,
|
||||
adapter,
|
||||
titleKey,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
row: TRow | null;
|
||||
adapter: TaxonomyAdapter<TRow>;
|
||||
titleKey: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const t = useTranslations('taxonomy');
|
||||
|
||||
const [draft, setDraft] = useState<Draft>(() => (row ? toDraft(row) : 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 activeId = useId();
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
if (row) {
|
||||
await adapter.update(row.id, draft);
|
||||
} else {
|
||||
await adapter.create(draft);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!row) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await adapter.remove(row.id);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
// The API refuses a delete that would strand products; that message is
|
||||
// the useful one, so it is surfaced verbatim rather than replaced.
|
||||
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
|
||||
setConfirmingDelete(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = draft.translations[locale];
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{row ? t(`${titleKey}.edit`) : t(`${titleKey}.create`)}</DialogTitle>
|
||||
<DialogDescription>{t('dialogHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="taxonomy-form" onSubmit={submit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t('name')} required>
|
||||
{(props) => (
|
||||
<Input
|
||||
{...props}
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t('slug')} hint={t('slugHint')} required>
|
||||
{(props) => (
|
||||
<Input
|
||||
{...props}
|
||||
value={draft.slug}
|
||||
onChange={(event) => setDraft({ ...draft, slug: event.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t('description')}>
|
||||
{(props) => (
|
||||
<Input
|
||||
{...props}
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft({ ...draft, description: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* ---- Per-locale copy ---- */}
|
||||
<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)}
|
||||
aria-pressed={locale === key}
|
||||
className={cn(
|
||||
'text-eyebrow -mb-px cursor-pointer border-b-2 px-3 py-1.5 uppercase',
|
||||
locale === key
|
||||
? 'border-ink-950 text-ink-950'
|
||||
: 'text-ink-500 hover:text-ink-700 border-transparent',
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
{draft.translations[key].name.trim() ? '' : ' ·'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t('nameLocalised')}>
|
||||
{(props) => (
|
||||
<Input
|
||||
{...props}
|
||||
value={fields.name}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
translations: {
|
||||
...draft.translations,
|
||||
[locale]: { ...fields, name: event.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t('slugLocalised')} hint={t('slugLocalisedHint')}>
|
||||
{(props) => (
|
||||
<Input
|
||||
{...props}
|
||||
value={fields.slug}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
translations: {
|
||||
...draft.translations,
|
||||
[locale]: { ...fields, slug: event.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* useId + htmlFor rather than wrapping: the hint sits inside the
|
||||
label too, and a screen reader would otherwise announce the
|
||||
whole paragraph as the checkbox's name. */}
|
||||
<input
|
||||
id={activeId}
|
||||
type="checkbox"
|
||||
checked={draft.isActive}
|
||||
onChange={(event) => setDraft({ ...draft, isActive: event.target.checked })}
|
||||
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={activeId} className="block text-sm font-medium">
|
||||
{t('active')}
|
||||
</label>
|
||||
<span className="text-ink-500 block text-xs">{t('activeHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{row ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmingDelete ? 'destructive' : 'ghost'}
|
||||
disabled={saving}
|
||||
onClick={() => (confirmingDelete ? void remove() : setConfirmingDelete(true))}
|
||||
>
|
||||
{confirmingDelete ? t('deleteConfirm') : t('delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="taxonomy-form" disabled={saving}>
|
||||
{saving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared payload mapping — the three entities send the same base fields. */
|
||||
export function toPayload(draft: Draft) {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
slug: draft.slug.trim(),
|
||||
description: draft.description.trim() || null,
|
||||
isActive: draft.isActive,
|
||||
// Only locales actually written; an empty name would fail the schema and
|
||||
// block saving a brand named in one language, which is how they start.
|
||||
translations: Object.fromEntries(
|
||||
LOCALES.filter((locale) => draft.translations[locale].name.trim()).map((locale) => {
|
||||
const fields = draft.translations[locale];
|
||||
return [
|
||||
locale,
|
||||
{
|
||||
name: fields.name.trim(),
|
||||
slug: (fields.slug || draft.slug).trim(),
|
||||
description: fields.description,
|
||||
metaTitle: fields.metaTitle,
|
||||
metaDescription: fields.metaDescription,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -429,5 +429,48 @@
|
||||
"unpublish": "Unpublish",
|
||||
"delete": "Retire",
|
||||
"deleteConfirm": "Confirm retire"
|
||||
},
|
||||
"taxonomy": {
|
||||
"noPermission": "You do not have permission to manage this.",
|
||||
"loadFailed": "Could not load.",
|
||||
"saveFailed": "Could not save.",
|
||||
"deleteFailed": "Could not delete.",
|
||||
"dialogHint": "The base name is the fallback; per-language copy overrides it.",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"products": "Products",
|
||||
"status": "Status"
|
||||
},
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"activeHint": "Inactive entries stay in the admin but disappear from the storefront.",
|
||||
"name": "Name",
|
||||
"slug": "URL slug",
|
||||
"slugHint": "Lowercase, hyphens.",
|
||||
"nameLocalised": "Name in this language",
|
||||
"slugLocalised": "Slug in this language",
|
||||
"slugLocalisedHint": "Leave blank to reuse the base slug.",
|
||||
"description": "Description",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Confirm delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"brands": {
|
||||
"create": "New brand",
|
||||
"edit": "Edit brand",
|
||||
"empty": "No brands yet."
|
||||
},
|
||||
"categories": {
|
||||
"create": "New category",
|
||||
"edit": "Edit category",
|
||||
"empty": "No categories yet."
|
||||
},
|
||||
"collections": {
|
||||
"create": "New collection",
|
||||
"edit": "Edit collection",
|
||||
"empty": "No collections yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,5 +429,48 @@
|
||||
"unpublish": "Gỡ xuống",
|
||||
"delete": "Ngừng dùng",
|
||||
"deleteConfirm": "Xác nhận ngừng"
|
||||
},
|
||||
"taxonomy": {
|
||||
"noPermission": "Bạn không có quyền quản lý mục này.",
|
||||
"loadFailed": "Không tải được dữ liệu.",
|
||||
"saveFailed": "Không lưu được.",
|
||||
"deleteFailed": "Không xoá được.",
|
||||
"dialogHint": "Tên cơ sở là giá trị dự phòng; nội dung theo ngôn ngữ sẽ ghi đè.",
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"products": "Sản phẩm",
|
||||
"status": "Trạng thái"
|
||||
},
|
||||
"active": "Đang bật",
|
||||
"inactive": "Đã tắt",
|
||||
"activeHint": "Mục đã tắt vẫn còn trong admin nhưng không hiển thị ngoài cửa hàng.",
|
||||
"name": "Tên",
|
||||
"slug": "Đường dẫn",
|
||||
"slugHint": "Chữ thường, gạch nối.",
|
||||
"nameLocalised": "Tên theo ngôn ngữ này",
|
||||
"slugLocalised": "Đường dẫn theo ngôn ngữ này",
|
||||
"slugLocalisedHint": "Để trống để dùng đường dẫn cơ sở.",
|
||||
"description": "Mô tả",
|
||||
"edit": "Sửa",
|
||||
"delete": "Xoá",
|
||||
"deleteConfirm": "Xác nhận xoá",
|
||||
"cancel": "Huỷ",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu…",
|
||||
"brands": {
|
||||
"create": "Thương hiệu mới",
|
||||
"edit": "Sửa thương hiệu",
|
||||
"empty": "Chưa có thương hiệu nào."
|
||||
},
|
||||
"categories": {
|
||||
"create": "Danh mục mới",
|
||||
"edit": "Sửa danh mục",
|
||||
"empty": "Chưa có danh mục nào."
|
||||
},
|
||||
"collections": {
|
||||
"create": "Bộ sưu tập mới",
|
||||
"edit": "Sửa bộ sưu tập",
|
||||
"empty": "Chưa có bộ sưu tập nào."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user