Finish admin page

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent cda5d21d0d
commit 8fd23d162f
107 changed files with 6321 additions and 563 deletions
+10 -8
View File
@@ -1,22 +1,24 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { 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">
+77 -79
View File
@@ -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}
+56 -46
View File
@@ -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,
},
];
}),
),
};
}
+43
View File
@@ -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."
}
}
}
+43
View File
@@ -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."
}
}
}