Wip stage M4
This commit is contained in:
@@ -17,10 +17,12 @@
|
||||
"@sport/ui": "workspace:*",
|
||||
"@sport/validation": "workspace:*",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"lucide-react": "1.31.0",
|
||||
"next": "catalog:",
|
||||
"next-intl": "^4.13.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"zod": "catalog:",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { InventoryTable } from '@/features/inventory/inventory-table';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.inventory');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function InventoryPage() {
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('pages.inventory');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="inventory.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>
|
||||
|
||||
<InventoryTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { MediaLibrary } from '@/features/media/media-library';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.media');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function MediaPage() {
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('pages.media');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="media.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>
|
||||
|
||||
<MediaLibrary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { ProductEditor } from '@/features/products/product-editor';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('editor');
|
||||
return { title: t('editTitle') };
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return (
|
||||
<div className="p-8">
|
||||
<ProductEditor productId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { ProductForm } from '@/features/products/product-form';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('editor');
|
||||
return { title: t('createTitle') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared as a sibling of `[id]`, so Next matches this literal segment first —
|
||||
* otherwise `/products/new` would load the editor for a product with the id
|
||||
* "new".
|
||||
*/
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('editor');
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-8">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/products" className="text-ink-500 hover:text-ink-950 text-sm">
|
||||
← {t('backToList')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{t('createTitle')}</h1>
|
||||
</header>
|
||||
|
||||
<p className="text-ink-500 max-w-2xl text-sm">{t('createHint')}</p>
|
||||
|
||||
<ProductForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { ProductsTable } from '@/features/products/products-table';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.products');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('pages.products');
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="product.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>
|
||||
|
||||
<ProductsTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function LoginForm() {
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
invalid={Boolean(error)}
|
||||
aria-invalid={Boolean(error)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function LoginForm() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
invalid={Boolean(error)}
|
||||
aria-invalid={Boolean(error)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
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 { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
const LOW_STOCK = 5;
|
||||
|
||||
export function InventoryTable() {
|
||||
const t = useTranslations('inventory');
|
||||
const { can } = useSession();
|
||||
|
||||
const [levels, setLevels] = useState<InventoryLevel[] | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [lowOnly, setLowOnly] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [delta, setDelta] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await browserApi.catalogAdmin.listInventory({
|
||||
perPage: 50,
|
||||
...(query ? { q: query } : {}),
|
||||
...(lowOnly ? { lowStockOnly: true } : {}),
|
||||
});
|
||||
setLevels([...result.items]);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}, [query, lowOnly, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => void load(), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
async function adjust(level: InventoryLevel) {
|
||||
const amount = Number.parseInt(delta, 10);
|
||||
if (!Number.isInteger(amount) || amount === 0) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload: StockAdjustment = {
|
||||
variantId: level.variantId,
|
||||
// A manual correction from this screen is exactly that. Receipts,
|
||||
// returns and damage are separate reasons the fuller inventory screen
|
||||
// will offer — mislabelling them here would poison the ledger.
|
||||
reason: 'MANUAL_ADJUSTMENT',
|
||||
quantityDelta: amount,
|
||||
note: 'Adjusted from the inventory table',
|
||||
};
|
||||
|
||||
await browserApi.catalogAdmin.adjustStock(payload);
|
||||
setEditing(null);
|
||||
setDelta('');
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('adjustFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!can(PERMISSIONS.INVENTORY_READ)) {
|
||||
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<label className="text-ink-600 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lowOnly}
|
||||
onChange={(event) => setLowOnly(event.target.checked)}
|
||||
/>
|
||||
{t('lowStockOnly')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!levels ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type MediaAssetSummary } from '@sport/types';
|
||||
import { Button, Skeleton } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
interface UploadState {
|
||||
name: string;
|
||||
status: 'uploading' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset library with direct-to-storage upload.
|
||||
*
|
||||
* The browser gets a presigned URL, PUTs the file straight to object storage,
|
||||
* then tells the API the asset exists (ADR-0009). Nothing streams through the
|
||||
* API, so a 20 MB file is the storage provider's problem, not ours.
|
||||
*/
|
||||
export function MediaLibrary() {
|
||||
const t = useTranslations('media');
|
||||
const { can } = useSession();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [assets, setAssets] = useState<MediaAssetSummary[] | null>(null);
|
||||
const [uploads, setUploads] = useState<UploadState[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await browserApi.catalogAdmin.listMedia({ perPage: 60 });
|
||||
setAssets([...result.items]);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// Declared inside the effect and guarded, rather than calling `load()`
|
||||
// directly: the lint rule cannot see that every setState here happens after
|
||||
// an await, and the cancellation flag is what stops a slow response from
|
||||
// writing into an unmounted component anyway.
|
||||
async function initialLoad() {
|
||||
try {
|
||||
const result = await browserApi.catalogAdmin.listMedia({ perPage: 60 });
|
||||
if (cancelled) return;
|
||||
|
||||
setAssets([...result.items]);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
if (cancelled) return;
|
||||
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
void initialLoad();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const list = [...files];
|
||||
setUploads(list.map((file) => ({ name: file.name, status: 'uploading' as const })));
|
||||
|
||||
// Sequential rather than parallel: a merchandiser dropping 30 photos at
|
||||
// once would otherwise open 30 simultaneous uploads and starve the rest of
|
||||
// the page's requests.
|
||||
for (const file of list) {
|
||||
try {
|
||||
await browserApi.catalogAdmin.uploadFile(file);
|
||||
setUploads((current) => current.filter((upload) => upload.name !== file.name));
|
||||
} catch (caught) {
|
||||
const message = isApiClientError(caught) ? caught.message : t('uploadFailed');
|
||||
setUploads((current) =>
|
||||
current.map((upload) =>
|
||||
upload.name === file.name ? { ...upload, status: 'failed', error: message } : upload,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await load();
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
|
||||
async function remove(asset: MediaAssetSummary) {
|
||||
try {
|
||||
await browserApi.catalogAdmin.deleteMedia(asset.id);
|
||||
await load();
|
||||
} catch (caught) {
|
||||
// The API refuses to delete an asset a product still uses — surface that
|
||||
// reason rather than a generic failure.
|
||||
setError(isApiClientError(caught) ? caught.message : t('deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
if (!can(PERMISSIONS.MEDIA_READ)) {
|
||||
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{can(PERMISSIONS.MEDIA_UPLOAD) ? (
|
||||
<div className="border-ink-300 flex flex-wrap items-center gap-3 border border-dashed bg-white p-6">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="media-upload"
|
||||
aria-label={t('chooseFiles')}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/webp,image/avif"
|
||||
className="sr-only"
|
||||
onChange={(event) => void handleFiles(event.target.files)}
|
||||
/>
|
||||
<Button type="button" onClick={() => inputRef.current?.click()}>
|
||||
{t('chooseFiles')}
|
||||
</Button>
|
||||
<p className="text-ink-500 text-sm">{t('uploadHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploads.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{uploads.map((upload) => (
|
||||
<li
|
||||
key={upload.name}
|
||||
className={upload.status === 'failed' ? 'text-danger' : 'text-ink-500'}
|
||||
>
|
||||
{upload.name} — {upload.status === 'failed' ? upload.error : t('uploading')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!assets ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6" aria-busy="true">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<Skeleton key={index} className="aspect-square w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<p className="text-ink-500 py-12 text-center text-sm">{t('empty')}</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{assets.map((asset) => (
|
||||
<li key={asset.id} className="border-ink-200 group relative border bg-white">
|
||||
<div className="bg-ink-100 relative aspect-square overflow-hidden">
|
||||
<Image
|
||||
src={asset.url}
|
||||
alt={asset.altText ?? ''}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 16vw, 33vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</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]">
|
||||
{asset.width && asset.height ? `${asset.width}×${asset.height} · ` : ''}
|
||||
{Math.round(asset.sizeBytes / 1024)} KB
|
||||
</p>
|
||||
</div>
|
||||
{can(PERMISSIONS.MEDIA_DELETE) ? (
|
||||
<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"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
PERMISSIONS,
|
||||
type AdminProductDetail,
|
||||
type MediaAssetSummary,
|
||||
} from '@sport/types';
|
||||
import { Button, Skeleton, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
interface Slot {
|
||||
mediaId: string;
|
||||
url: string;
|
||||
optionValueId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product imagery: which assets, in what order, and which colourway each belongs
|
||||
* to.
|
||||
*
|
||||
* That last part is what makes the storefront gallery swap when a shopper picks
|
||||
* a colour — `ProductImage.optionValueId` is the link, and this is the only
|
||||
* place it gets set.
|
||||
*/
|
||||
export function ImageManager({
|
||||
product,
|
||||
onChange,
|
||||
}: {
|
||||
product: AdminProductDetail;
|
||||
onChange: (product: AdminProductDetail) => void;
|
||||
}) {
|
||||
const t = useTranslations('editor');
|
||||
const { can } = useSession();
|
||||
|
||||
const [slots, setSlots] = useState<Slot[]>(() =>
|
||||
product.images.map((image) => ({
|
||||
mediaId: image.mediaId,
|
||||
url: image.url,
|
||||
optionValueId: image.optionValueId,
|
||||
})),
|
||||
);
|
||||
const [library, setLibrary] = useState<MediaAssetSummary[] | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Colourway options an image can be attached to.
|
||||
const colourValues = product.options.find((option) => option.key === 'colour')?.values ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadLibrary() {
|
||||
try {
|
||||
const result = await browserApi.catalogAdmin.listMedia({ perPage: 60 });
|
||||
if (!cancelled) setLibrary([...result.items]);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
void loadLibrary();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
try {
|
||||
const updated = await browserApi.catalogAdmin.setImages(
|
||||
product.id,
|
||||
// Position is the array index — the order shown is the order stored.
|
||||
slots.map((slot, index) => ({
|
||||
mediaId: slot.mediaId,
|
||||
position: index,
|
||||
optionValueId: slot.optionValueId,
|
||||
})),
|
||||
);
|
||||
|
||||
setSlots(
|
||||
updated.images.map((image) => ({
|
||||
mediaId: image.mediaId,
|
||||
url: image.url,
|
||||
optionValueId: image.optionValueId,
|
||||
})),
|
||||
);
|
||||
onChange(updated);
|
||||
setSaved(true);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function move(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= slots.length) return;
|
||||
|
||||
setSlots((current) => {
|
||||
const moved = current[index];
|
||||
if (!moved) return current;
|
||||
|
||||
const next = [...current];
|
||||
next.splice(index, 1);
|
||||
next.splice(target, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const editable = can(PERMISSIONS.PRODUCT_UPDATE);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-widest">{t('attached')}</h3>
|
||||
{saved ? <span className="text-success ml-auto text-xs">{t('saved')}</span> : null}
|
||||
{editable ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(!saved && 'ml-auto')}
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t('saving') : t('saveImages')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-ink-500 text-sm">{t('noImages')}</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{slots.map((slot, index) => (
|
||||
<li key={`${slot.mediaId}-${index}`} className="border-ink-200 space-y-2 border p-2">
|
||||
<div className="bg-ink-100 relative aspect-square overflow-hidden">
|
||||
<Image src={slot.url} alt="" fill sizes="16vw" className="object-cover" />
|
||||
{index === 0 ? (
|
||||
<span className="bg-ink-950 absolute left-1 top-1 px-1.5 py-0.5 text-[0.5rem] font-semibold uppercase tracking-widest text-white">
|
||||
{t('primary')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{colourValues.length > 0 ? (
|
||||
<select
|
||||
value={slot.optionValueId ?? ''}
|
||||
disabled={!editable}
|
||||
onChange={(event) =>
|
||||
setSlots((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index ? { ...item, optionValueId: event.target.value || null } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
aria-label={t('colourway')}
|
||||
className="border-ink-200 w-full border px-1 py-1 text-xs"
|
||||
>
|
||||
<option value="">{t('anyColourway')}</option>
|
||||
{colourValues.map((value) => (
|
||||
<option key={value.id} value={value.id}>
|
||||
{value.labels[DEFAULT_LOCALE] ?? value.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
{editable ? (
|
||||
<div className="flex justify-between gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
className="text-ink-500 hover:text-ink-950 px-1 text-xs"
|
||||
aria-label={t('moveLeft')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlots((c) => c.filter((_, i) => i !== index))}
|
||||
className="text-danger text-xs"
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
className="text-ink-500 hover:text-ink-950 px-1 text-xs"
|
||||
aria-label={t('moveRight')}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editable ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-widest">{t('library')}</h3>
|
||||
{!library ? (
|
||||
<div className="grid grid-cols-4 gap-3 lg:grid-cols-8" aria-busy="true">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<Skeleton key={index} className="aspect-square w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-4 gap-3 lg:grid-cols-8">
|
||||
{library.map((asset) => {
|
||||
const used = slots.some((slot) => slot.mediaId === asset.id);
|
||||
|
||||
return (
|
||||
<li key={asset.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={used}
|
||||
onClick={() =>
|
||||
setSlots((current) => [
|
||||
...current,
|
||||
{ mediaId: asset.id, url: asset.url, optionValueId: null },
|
||||
])
|
||||
}
|
||||
className={cn(
|
||||
'bg-ink-100 relative block aspect-square w-full overflow-hidden border transition-colors',
|
||||
used
|
||||
? 'border-ink-200 cursor-not-allowed opacity-30'
|
||||
: 'border-ink-200 hover:border-ink-950',
|
||||
)}
|
||||
title={asset.altText ?? asset.storageKey}
|
||||
>
|
||||
<Image src={asset.url} alt="" fill sizes="12vw" className="object-cover" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { PERMISSIONS, type AdminProductDetail } from '@sport/types';
|
||||
import { Badge, Button, Skeleton, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
import { ImageManager } from './image-manager';
|
||||
import { ProductForm } from './product-form';
|
||||
import { VariantGrid } from './variant-grid';
|
||||
|
||||
type Tab = 'details' | 'variants' | 'images';
|
||||
|
||||
const STATUS_VARIANT = { ACTIVE: 'success', DRAFT: 'warning', ARCHIVED: 'neutral' } as const;
|
||||
|
||||
/**
|
||||
* The editor shell.
|
||||
*
|
||||
* Loads the product client-side rather than on the server because every tab
|
||||
* mutates it and then needs the authoritative version back — the API returns
|
||||
* the full product from each write, so the page can stay in step without a
|
||||
* round-trip through server rendering.
|
||||
*/
|
||||
export function ProductEditor({ productId }: { productId: string }) {
|
||||
const t = useTranslations('editor');
|
||||
const { can } = useSession();
|
||||
|
||||
const [product, setProduct] = useState<AdminProductDetail | null>(null);
|
||||
const [tab, setTab] = useState<Tab>('details');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.catalogAdmin.getProduct(productId);
|
||||
if (!cancelled) setProduct(result);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, t]);
|
||||
|
||||
async function setStatus(status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED') {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setProduct(await browserApi.catalogAdmin.setStatus(productId, status));
|
||||
} catch (caught) {
|
||||
// Publishing without variants is rejected by the API — surface that
|
||||
// reason rather than a generic failure.
|
||||
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !product) {
|
||||
return (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const name = product.translations.vi?.name ?? product.translations.en?.name ?? '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/products" className="text-ink-500 hover:text-ink-950 text-sm">
|
||||
← {t('backToList')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{name}</h1>
|
||||
<Badge variant={STATUS_VARIANT[product.status]}>{product.status}</Badge>
|
||||
|
||||
{can(PERMISSIONS.PRODUCT_PUBLISH) ? (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{product.status === 'ACTIVE' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => void setStatus('DRAFT')}
|
||||
>
|
||||
{t('unpublish')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" disabled={busy} onClick={() => void setStatus('ACTIVE')}>
|
||||
{t('publish')}
|
||||
</Button>
|
||||
)}
|
||||
{product.status !== 'ARCHIVED' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void setStatus('ARCHIVED')}
|
||||
>
|
||||
{t('archive')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<nav className="border-ink-200 flex gap-1 border-b">
|
||||
{(['details', 'variants', 'images'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value)}
|
||||
aria-current={tab === value ? 'page' : undefined}
|
||||
className={cn(
|
||||
'-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
||||
tab === value
|
||||
? 'border-ink-950 text-ink-950'
|
||||
: 'text-ink-500 hover:text-ink-950 border-transparent',
|
||||
)}
|
||||
>
|
||||
{t(`tab.${value}`)}
|
||||
{value === 'variants' ? (
|
||||
<span className="text-ink-400 ml-2 text-xs">{product.variants.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'details' ? <ProductForm product={product} onChange={setProduct} /> : null}
|
||||
{/*
|
||||
Keyed on the matrix shape: editing the options regenerates variants, and
|
||||
the grid seeds its draft from props exactly once. Remounting it when the
|
||||
set of variant ids changes is what keeps the inputs honest.
|
||||
*/}
|
||||
{tab === 'variants' ? (
|
||||
<VariantGrid
|
||||
key={product.variants.map((variant) => variant.id).join(',')}
|
||||
product={product}
|
||||
onChange={setProduct}
|
||||
/>
|
||||
) : null}
|
||||
{tab === 'images' ? <ImageManager product={product} onChange={setProduct} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
LOCALE_LABELS,
|
||||
type AdminProductDetail,
|
||||
type Locale,
|
||||
} from '@sport/types';
|
||||
import { Badge, Button, Input, cn } from '@sport/ui';
|
||||
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
const GENDERS = ['MEN', 'WOMEN', 'KIDS', 'UNISEX'] as const;
|
||||
const SPORTS = ['RUNNING', 'FOOTBALL', 'TRAINING', 'GYM', 'BADMINTON', 'LIFESTYLE'] as const;
|
||||
|
||||
interface DraftTranslation {
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DraftValue {
|
||||
value: string;
|
||||
swatchHex: string;
|
||||
labels: Record<Locale, string>;
|
||||
}
|
||||
|
||||
interface DraftOption {
|
||||
key: string;
|
||||
names: Record<Locale, string>;
|
||||
values: DraftValue[];
|
||||
}
|
||||
|
||||
const emptyTranslations = (): Record<Locale, DraftTranslation> =>
|
||||
Object.fromEntries(
|
||||
LOCALES.map((locale) => [locale, { name: '', shortDescription: '', description: '' }]),
|
||||
) as Record<Locale, DraftTranslation>;
|
||||
|
||||
/** A sensible starting point: every apparel product has these two axes. */
|
||||
const starterOptions = (): DraftOption[] => [
|
||||
{
|
||||
key: 'colour',
|
||||
names: { vi: 'Màu sắc', en: 'Colour' },
|
||||
values: [{ value: 'black', swatchHex: '#111111', labels: { vi: 'Đen', en: 'Black' } }],
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
names: { vi: 'Kích cỡ', en: 'Size' },
|
||||
values: [{ value: 'm', swatchHex: '', labels: { vi: 'M', en: 'M' } }],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create and edit form for a product's content and option axes.
|
||||
*
|
||||
* Saving options re-runs the variant matrix on the server, so this form owns
|
||||
* *what varies* while the variant grid owns *what each combination costs*.
|
||||
* Splitting them that way means editing a price never risks regenerating the
|
||||
* matrix, and adding a colour never silently overwrites a price.
|
||||
*/
|
||||
export function ProductForm({
|
||||
product,
|
||||
onChange,
|
||||
}: {
|
||||
product?: AdminProductDetail;
|
||||
/**
|
||||
* Hands the saved product back to the editor shell, which owns it. Without
|
||||
* this the shell keeps rendering the version it loaded, and a tab that
|
||||
* compares its draft against that stale prop never sees its own save.
|
||||
*/
|
||||
onChange?: (product: AdminProductDetail) => void;
|
||||
}) {
|
||||
const t = useTranslations('editor');
|
||||
const router = useRouter();
|
||||
|
||||
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
||||
const [translations, setTranslations] = useState<Record<Locale, DraftTranslation>>(() => {
|
||||
if (!product) return emptyTranslations();
|
||||
|
||||
const seeded = emptyTranslations();
|
||||
for (const key of LOCALES) {
|
||||
const existing = product.translations[key];
|
||||
if (existing) {
|
||||
seeded[key] = {
|
||||
name: existing.name,
|
||||
shortDescription: existing.shortDescription ?? '',
|
||||
description: existing.description ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return seeded;
|
||||
});
|
||||
|
||||
const [genders, setGenders] = useState<string[]>(() => [...(product?.genderTargets ?? [])]);
|
||||
const [sports, setSports] = useState<string[]>(() => [...(product?.sportTypes ?? [])]);
|
||||
const [skuPrefix, setSkuPrefix] = useState('');
|
||||
const [basePrice, setBasePrice] = useState('0');
|
||||
|
||||
const [options, setOptions] = useState<DraftOption[]>(() => {
|
||||
if (!product || product.options.length === 0) return starterOptions();
|
||||
|
||||
return product.options.map((option) => ({
|
||||
key: option.key,
|
||||
names: Object.fromEntries(LOCALES.map((key) => [key, option.names[key] ?? ''])) as Record<
|
||||
Locale,
|
||||
string
|
||||
>,
|
||||
values: option.values.map((value) => ({
|
||||
value: value.value,
|
||||
swatchHex: value.swatchHex ?? '',
|
||||
labels: Object.fromEntries(LOCALES.map((key) => [key, value.labels[key] ?? ''])) as Record<
|
||||
Locale,
|
||||
string
|
||||
>,
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function buildPayload() {
|
||||
// Only locales the operator actually filled in are sent. Posting an empty
|
||||
// name would create a translation row that resolves to a blank product page
|
||||
// in that language — worse than having no translation at all, because the
|
||||
// fallback would no longer apply.
|
||||
const filled = Object.fromEntries(
|
||||
LOCALES.filter((key) => translations[key].name.trim().length > 0).map((key) => [
|
||||
key,
|
||||
{
|
||||
name: translations[key].name.trim(),
|
||||
shortDescription: translations[key].shortDescription.trim() || null,
|
||||
description: translations[key].description.trim() || null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
translations: filled,
|
||||
genderTargets: genders,
|
||||
sportTypes: sports,
|
||||
...(skuPrefix.trim() ? { skuPrefix: skuPrefix.trim() } : {}),
|
||||
basePriceAmount: Number.parseInt(basePrice, 10) || 0,
|
||||
options: options.map((option, index) => ({
|
||||
key: option.key,
|
||||
position: index,
|
||||
names: Object.fromEntries(
|
||||
LOCALES.filter((key) => option.names[key].trim()).map((key) => [
|
||||
key,
|
||||
option.names[key].trim(),
|
||||
]),
|
||||
),
|
||||
values: option.values.map((value, valueIndex) => ({
|
||||
value: value.value,
|
||||
position: valueIndex,
|
||||
swatchHex: value.swatchHex.trim() || null,
|
||||
labels: Object.fromEntries(
|
||||
LOCALES.filter((key) => value.labels[key].trim()).map((key) => [
|
||||
key,
|
||||
value.labels[key].trim(),
|
||||
]),
|
||||
),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (product) {
|
||||
onChange?.(await browserApi.catalogAdmin.updateProduct(product.id, payload));
|
||||
setSaved(true);
|
||||
} else {
|
||||
const created = await browserApi.catalogAdmin.createProduct(payload);
|
||||
// Straight into the editor: the operator's next step is always pricing
|
||||
// the matrix that was just generated.
|
||||
router.push(`/products/${created.id}`);
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* ---- Content, per locale ---- */}
|
||||
<section className="border-ink-200 space-y-4 border bg-white p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('content')}</h2>
|
||||
<div className="flex gap-1">
|
||||
{LOCALES.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setLocale(key)}
|
||||
className={cn(
|
||||
'rounded-card border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
locale === key
|
||||
? 'border-ink-950 bg-ink-950 text-white'
|
||||
: 'border-ink-200 text-ink-600 hover:border-ink-400',
|
||||
)}
|
||||
>
|
||||
{LOCALE_LABELS[key]}
|
||||
{translations[key].name.trim() ? '' : ' •'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-ink-400 text-xs">{t('localeHint')}</p>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('name')}</span>
|
||||
<Input
|
||||
value={translations[locale].name}
|
||||
onChange={(event) =>
|
||||
setTranslations((current) => ({
|
||||
...current,
|
||||
[locale]: { ...current[locale], name: event.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{t('shortDescription')}
|
||||
</span>
|
||||
<Input
|
||||
value={translations[locale].shortDescription}
|
||||
onChange={(event) =>
|
||||
setTranslations((current) => ({
|
||||
...current,
|
||||
[locale]: { ...current[locale], shortDescription: event.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{t('description')}
|
||||
</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={translations[locale].description}
|
||||
onChange={(event) =>
|
||||
setTranslations((current) => ({
|
||||
...current,
|
||||
[locale]: { ...current[locale], description: event.target.value },
|
||||
}))
|
||||
}
|
||||
className="border-ink-300 focus:border-ink-950 w-full border bg-white px-4 py-3 text-sm focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* ---- Merchandising facets ---- */}
|
||||
<section className="border-ink-200 space-y-4 border bg-white p-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('facets')}</h2>
|
||||
|
||||
<Chips label={t('gender')} all={GENDERS} selected={genders} onChange={setGenders} />
|
||||
<Chips label={t('sport')} all={SPORTS} selected={sports} onChange={setSports} />
|
||||
</section>
|
||||
|
||||
{/* ---- Options ---- */}
|
||||
<section className="border-ink-200 space-y-4 border bg-white p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('options')}</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setOptions((current) => [
|
||||
...current,
|
||||
{ key: `option_${current.length + 1}`, names: { vi: '', en: '' }, values: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
{t('addOption')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-ink-400 text-xs">{t('optionsHint')}</p>
|
||||
|
||||
{options.map((option, optionIndex) => (
|
||||
<OptionEditor
|
||||
key={optionIndex}
|
||||
option={option}
|
||||
locale={locale}
|
||||
onChange={(next) =>
|
||||
setOptions((current) => current.map((item, i) => (i === optionIndex ? next : item)))
|
||||
}
|
||||
onRemove={() => setOptions((current) => current.filter((_, i) => i !== optionIndex))}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{!product ? (
|
||||
<section className="border-ink-200 space-y-4 border bg-white p-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest">{t('generation')}</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{t('skuPrefix')}
|
||||
</span>
|
||||
<Input
|
||||
value={skuPrefix}
|
||||
onChange={(event) => setSkuPrefix(event.target.value)}
|
||||
placeholder="VEL-ART"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{t('basePrice')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={basePrice}
|
||||
onChange={(event) => setBasePrice(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-ink-400 text-xs">{t('generationHint')}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="lg" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? t('saving') : product ? t('save') : t('create')}
|
||||
</Button>
|
||||
{product ? <Badge variant="outline">{t('matrixWarning')}</Badge> : null}
|
||||
{saved ? <span className="text-success text-xs">{t('saved')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chips({
|
||||
label,
|
||||
all,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
all: readonly string[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{label}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{all.map((item) => {
|
||||
const active = selected.includes(item);
|
||||
return (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
onChange(active ? selected.filter((x) => x !== item) : [...selected, item])
|
||||
}
|
||||
className={cn(
|
||||
'rounded-card border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-ink-950 bg-ink-950 text-white'
|
||||
: 'border-ink-200 text-ink-600 hover:border-ink-400',
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionEditor({
|
||||
option,
|
||||
locale,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
option: DraftOption;
|
||||
locale: Locale;
|
||||
onChange: (next: DraftOption) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useTranslations('editor');
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Input
|
||||
value={option.key}
|
||||
onChange={(event) => onChange({ ...option, key: event.target.value })}
|
||||
className="w-40"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-ink-500 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{t('optionName')}
|
||||
</span>
|
||||
<Input
|
||||
value={option.names[locale]}
|
||||
onChange={(event) =>
|
||||
onChange({ ...option, names: { ...option.names, [locale]: event.target.value } })
|
||||
}
|
||||
className="w-56"
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" variant="ghost" onClick={onRemove} className="ml-auto">
|
||||
{t('removeOption')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{option.values.map((value, index) => (
|
||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
value={value.value}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...option,
|
||||
values: option.values.map((item, i) =>
|
||||
i === index ? { ...item, value: event.target.value } : item,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t('valueKey')}
|
||||
aria-label={t('valueKey')}
|
||||
className="w-32"
|
||||
/>
|
||||
<Input
|
||||
value={value.labels[locale]}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...option,
|
||||
values: option.values.map((item, i) =>
|
||||
i === index
|
||||
? { ...item, labels: { ...item.labels, [locale]: event.target.value } }
|
||||
: item,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t('valueLabel')}
|
||||
aria-label={t('valueLabel')}
|
||||
className="w-44"
|
||||
/>
|
||||
<Input
|
||||
value={value.swatchHex}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...option,
|
||||
values: option.values.map((item, i) =>
|
||||
i === index ? { ...item, swatchHex: event.target.value } : item,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="#111111"
|
||||
aria-label={t('swatch')}
|
||||
className="w-28"
|
||||
/>
|
||||
{value.swatchHex ? (
|
||||
<span
|
||||
className="border-ink-200 size-8 shrink-0 rounded-full border"
|
||||
style={{ backgroundColor: value.swatchHex }}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
onChange({ ...option, values: option.values.filter((_, i) => i !== index) })
|
||||
}
|
||||
>
|
||||
{t('removeValue')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...option,
|
||||
values: [...option.values, { value: '', swatchHex: '', labels: { vi: '', en: '' } }],
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('addValue')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import { MINOR_UNIT_SCALE, PERMISSIONS, type AdminProductListItem, type Money } from '@sport/types';
|
||||
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
ACTIVE: 'success',
|
||||
DRAFT: 'warning',
|
||||
ARCHIVED: 'neutral',
|
||||
} as const;
|
||||
|
||||
/** Admin money display. VND has no minor unit, so this is a plain group format. */
|
||||
function formatMoney(money: Money): string {
|
||||
const scale = MINOR_UNIT_SCALE[money.currency];
|
||||
return new Intl.NumberFormat('vi-VN', {
|
||||
style: 'currency',
|
||||
currency: money.currency,
|
||||
minimumFractionDigits: scale,
|
||||
maximumFractionDigits: scale,
|
||||
}).format(money.amount / 10 ** scale);
|
||||
}
|
||||
|
||||
export function ProductsTable() {
|
||||
const t = useTranslations('catalog');
|
||||
const { can } = useSession();
|
||||
|
||||
const [items, setItems] = useState<AdminProductListItem[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [query, setQuery] = useState('');
|
||||
const [status, setStatus] = useState<'' | 'DRAFT' | 'ACTIVE' | 'ARCHIVED'>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await browserApi.catalogAdmin.listProducts({
|
||||
perPage: 50,
|
||||
...(query ? { q: query } : {}),
|
||||
...(status ? { status } : {}),
|
||||
});
|
||||
setItems([...result.items]);
|
||||
setTotal(result.pageInfo.totalItems);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
|
||||
}
|
||||
}, [query, status, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounced so typing in the search box does not fire a request per keystroke.
|
||||
const timer = setTimeout(() => void load(), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
async function toggleStatus(product: AdminProductListItem) {
|
||||
setBusyId(product.id);
|
||||
try {
|
||||
await browserApi.catalogAdmin.setStatus(
|
||||
product.id,
|
||||
product.status === 'ACTIVE' ? 'DRAFT' : 'ACTIVE',
|
||||
);
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('actionFailed'));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!can(PERMISSIONS.PRODUCT_READ)) {
|
||||
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{(['', 'ACTIVE', 'DRAFT', 'ARCHIVED'] as const).map((value) => (
|
||||
<button
|
||||
key={value || 'all'}
|
||||
type="button"
|
||||
onClick={() => setStatus(value)}
|
||||
className={cn(
|
||||
'rounded-card border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
status === value
|
||||
? 'border-ink-950 bg-ink-950 text-white'
|
||||
: 'border-ink-200 text-ink-600 hover:border-ink-400',
|
||||
)}
|
||||
>
|
||||
{value ? t(`status.${value}`) : t('status.ALL')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-ink-500 ml-auto text-xs">{t('count', { count: total })}</span>
|
||||
{can(PERMISSIONS.PRODUCT_CREATE) ? (
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/products/new">{t('newProduct')}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!items ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</div>
|
||||
) : 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)
|
||||
: `${formatMoney(product.priceRange.min)} – ${formatMoney(product.priceRange.max)}`
|
||||
: '—'}
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isApiClientError, type VariantPatch } from '@sport/api-client';
|
||||
import { PERMISSIONS, type AdminProductDetail, type AdminProductVariant } from '@sport/types';
|
||||
import { Badge, Button, Input, cn } from '@sport/ui';
|
||||
|
||||
import { useSession } from '@/features/auth/session-provider';
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
type Draft = Record<string, { sku: string; price: string; sale: string }>;
|
||||
|
||||
function toDraft(variants: readonly AdminProductVariant[]): Draft {
|
||||
return Object.fromEntries(
|
||||
variants.map((variant) => [
|
||||
variant.id,
|
||||
{
|
||||
sku: variant.sku,
|
||||
price: String(variant.priceAmount),
|
||||
sale: variant.salePriceAmount === null ? '' : String(variant.salePriceAmount),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The variant matrix, editable in place.
|
||||
*
|
||||
* This is where ADR-0003 becomes an interface: one row per purchasable
|
||||
* combination, each with its own SKU, price and stock. Stock is shown but not
|
||||
* editable here — it moves through inventory so that every change carries a
|
||||
* reason and lands in the ledger.
|
||||
*
|
||||
* Only rows the operator actually changed are sent. Posting all 60 rows on
|
||||
* every save would make the audit entry useless and would overwrite a
|
||||
* colleague's concurrent edit with stale values from this page's load.
|
||||
*/
|
||||
export function VariantGrid({
|
||||
product,
|
||||
onChange,
|
||||
}: {
|
||||
product: AdminProductDetail;
|
||||
onChange: (product: AdminProductDetail) => void;
|
||||
}) {
|
||||
const t = useTranslations('editor');
|
||||
const { can } = useSession();
|
||||
|
||||
const [draft, setDraft] = useState<Draft>(() => toDraft(product.variants));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const active = product.variants.filter((variant) => variant.status === 'ACTIVE');
|
||||
const archived = product.variants.filter((variant) => variant.status === 'ARCHIVED');
|
||||
|
||||
// Pairs rather than variants alone: carrying the draft row along proves it
|
||||
// exists, so building the patch below needs no assertion.
|
||||
const dirty = product.variants.flatMap((variant) => {
|
||||
const row = draft[variant.id];
|
||||
if (!row) return [];
|
||||
|
||||
const changed =
|
||||
row.sku !== variant.sku ||
|
||||
row.price !== String(variant.priceAmount) ||
|
||||
row.sale !== (variant.salePriceAmount === null ? '' : String(variant.salePriceAmount));
|
||||
|
||||
return changed ? [{ variant, row }] : [];
|
||||
});
|
||||
|
||||
function patchRow(id: string, patch: Partial<Draft[string]>) {
|
||||
setDraft((current) => {
|
||||
const row = current[id];
|
||||
return row ? { ...current, [id]: { ...row, ...patch } } : current;
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (dirty.length === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
try {
|
||||
const patches: VariantPatch[] = dirty.map(({ variant, row }) => {
|
||||
return {
|
||||
id: variant.id,
|
||||
sku: row.sku.trim(),
|
||||
priceAmount: Number.parseInt(row.price, 10) || 0,
|
||||
// An empty sale field means "no sale", which is null rather than 0 —
|
||||
// a sale price of zero would make the product free.
|
||||
salePriceAmount: row.sale.trim() === '' ? null : Number.parseInt(row.sale, 10),
|
||||
};
|
||||
});
|
||||
|
||||
const updated = await browserApi.catalogAdmin.updateVariants(product.id, patches);
|
||||
// Both, and in this order: the draft holds what the inputs display, and
|
||||
// `dirty` compares it against the product. Resetting only the draft would
|
||||
// leave every saved row comparing against pre-save values and therefore
|
||||
// still marked dirty.
|
||||
setDraft(toDraft(updated.variants));
|
||||
onChange(updated);
|
||||
setSaved(true);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (product.variants.length === 0) {
|
||||
return <p className="text-ink-500 text-sm">{t('noVariants')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-ink-500 text-xs">{t('variantCount', { count: active.length })}</p>
|
||||
{archived.length > 0 ? (
|
||||
<Badge variant="neutral">{t('archivedCount', { count: archived.length })}</Badge>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
{saved ? <span className="text-success text-xs">{t('saved')}</span> : null}
|
||||
{can(PERMISSIONS.PRODUCT_UPDATE) ? (
|
||||
<Button size="sm" disabled={saving || dirty.length === 0} onClick={() => void save()}>
|
||||
{saving ? t('saving') : t('saveChanged', { count: dirty.length })}
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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('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>
|
||||
<th className="px-4 py-3 font-semibold">{t('grid.sale')}</th>
|
||||
<th className="px-4 py-3 font-semibold">{t('grid.stock')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-ink-100 divide-y">
|
||||
{product.variants.map((variant) => {
|
||||
const row = draft[variant.id];
|
||||
const isArchived = variant.status === 'ARCHIVED';
|
||||
const changed = dirty.some((item) => item.variant.id === variant.id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={variant.id}
|
||||
className={cn(isArchived && 'opacity-50', changed && 'bg-volt-50')}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className="font-medium">{variant.title}</span>
|
||||
{isArchived ? (
|
||||
<Badge variant="neutral" className="ml-2">
|
||||
{t('archived')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
value={row?.sku ?? ''}
|
||||
disabled={isArchived || !can(PERMISSIONS.PRODUCT_UPDATE)}
|
||||
onChange={(event) => patchRow(variant.id, { sku: event.target.value })}
|
||||
aria-label={`${t('grid.sku')} ${variant.title}`}
|
||||
className="h-9 w-44 font-mono text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={row?.price ?? ''}
|
||||
disabled={isArchived || !can(PERMISSIONS.PRODUCT_UPDATE)}
|
||||
onChange={(event) => patchRow(variant.id, { price: event.target.value })}
|
||||
aria-label={`${t('grid.price')} ${variant.title}`}
|
||||
className="h-9 w-32"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={row?.sale ?? ''}
|
||||
placeholder="—"
|
||||
disabled={isArchived || !can(PERMISSIONS.PRODUCT_UPDATE)}
|
||||
onChange={(event) => patchRow(variant.id, { sale: event.target.value })}
|
||||
aria-label={`${t('grid.sale')} ${variant.title}`}
|
||||
className="h-9 w-32"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{/* Read-only by design: stock moves through inventory. */}
|
||||
<span className={cn(variant.available === 0 && 'text-danger')}>
|
||||
{variant.available}
|
||||
</span>
|
||||
<span className="text-ink-400 ml-1 text-xs">/ {variant.onHand}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-ink-400 text-xs">{t('stockHint')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,5 +104,127 @@
|
||||
"status": "Status",
|
||||
"lastLogin": "Last sign-in"
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"searchPlaceholder": "Search name or SKU",
|
||||
"count": "{count} products",
|
||||
"empty": "No products match.",
|
||||
"loadFailed": "Could not load products.",
|
||||
"actionFailed": "That action failed.",
|
||||
"noPermission": "You do not have permission to view this.",
|
||||
"publish": "Publish",
|
||||
"unpublish": "Unpublish",
|
||||
"status": {
|
||||
"ALL": "All",
|
||||
"ACTIVE": "Active",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVED": "Archived"
|
||||
},
|
||||
"table": {
|
||||
"product": "Product",
|
||||
"status": "Status",
|
||||
"variants": "Variants",
|
||||
"stock": "Stock",
|
||||
"price": "Price"
|
||||
},
|
||||
"newProduct": "New product",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"media": {
|
||||
"chooseFiles": "Choose files",
|
||||
"uploadHint": "PNG, JPEG, WebP or AVIF, up to 25 MB. Uploaded straight to storage.",
|
||||
"uploading": "uploading…",
|
||||
"uploadFailed": "Upload failed.",
|
||||
"deleteFailed": "Could not delete.",
|
||||
"loadFailed": "Could not load the library.",
|
||||
"empty": "No media yet.",
|
||||
"delete": "Delete",
|
||||
"noPermission": "You do not have permission to view this."
|
||||
},
|
||||
"inventory": {
|
||||
"searchPlaceholder": "Search SKU or product",
|
||||
"lowStockOnly": "Low stock only",
|
||||
"loadFailed": "Could not load stock levels.",
|
||||
"adjustFailed": "Adjustment failed.",
|
||||
"noPermission": "You do not have permission to view this.",
|
||||
"adjust": "Adjust",
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel",
|
||||
"outOfStock": "Out of stock",
|
||||
"deltaLabel": "Quantity change",
|
||||
"table": {
|
||||
"sku": "SKU",
|
||||
"product": "Product",
|
||||
"onHand": "On hand",
|
||||
"reserved": "Reserved",
|
||||
"available": "Available"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"createTitle": "New product",
|
||||
"editTitle": "Edit product",
|
||||
"createHint": "Fill in the content and the option axes. Saving generates one variant per combination, which you then price on the Variants tab.",
|
||||
"backToList": "Products",
|
||||
"content": "Content",
|
||||
"facets": "Merchandising",
|
||||
"options": "Options",
|
||||
"generation": "Variant generation",
|
||||
"localeHint": "A language with no name is skipped — the storefront falls back per field.",
|
||||
"name": "Name",
|
||||
"shortDescription": "Short description",
|
||||
"description": "Description",
|
||||
"gender": "Gender",
|
||||
"sport": "Sport",
|
||||
"optionsHint": "Every combination of these values becomes a purchasable variant with its own SKU, price and stock.",
|
||||
"addOption": "Add option",
|
||||
"removeOption": "Remove",
|
||||
"optionKey": "Key",
|
||||
"optionName": "Label",
|
||||
"addValue": "Add value",
|
||||
"removeValue": "Remove",
|
||||
"valueKey": "value",
|
||||
"valueLabel": "Label",
|
||||
"swatch": "Swatch",
|
||||
"skuPrefix": "SKU prefix",
|
||||
"basePrice": "Base price (VND)",
|
||||
"generationHint": "Applied to every generated variant. Adjust individual prices afterwards.",
|
||||
"save": "Save changes",
|
||||
"create": "Create product",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Could not save.",
|
||||
"loadFailed": "Could not load this product.",
|
||||
"matrixWarning": "Saving re-runs the variant matrix",
|
||||
"publish": "Publish",
|
||||
"unpublish": "Unpublish",
|
||||
"archive": "Archive",
|
||||
"tab": {
|
||||
"details": "Details",
|
||||
"variants": "Variants",
|
||||
"images": "Images"
|
||||
},
|
||||
"noVariants": "No variants yet. Add option values on the Details tab.",
|
||||
"variantCount": "{count} active",
|
||||
"archivedCount": "{count} archived",
|
||||
"archived": "Archived",
|
||||
"saveChanged": "Save {count} change(s)",
|
||||
"grid": {
|
||||
"variant": "Variant",
|
||||
"sku": "SKU",
|
||||
"price": "Price",
|
||||
"sale": "Sale price",
|
||||
"stock": "Available / on hand"
|
||||
},
|
||||
"stockHint": "Stock is read-only here. Adjust it from Inventory so every change lands in the ledger with a reason.",
|
||||
"attached": "Attached images",
|
||||
"library": "Library",
|
||||
"noImages": "No images attached yet.",
|
||||
"saveImages": "Save images",
|
||||
"primary": "Primary",
|
||||
"remove": "Remove",
|
||||
"colourway": "Colourway",
|
||||
"anyColourway": "All colourways",
|
||||
"moveLeft": "Move earlier",
|
||||
"moveRight": "Move later"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,5 +104,127 @@
|
||||
"status": "Trạng thái",
|
||||
"lastLogin": "Đăng nhập gần nhất"
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"searchPlaceholder": "Tìm theo tên hoặc SKU",
|
||||
"count": "{count} sản phẩm",
|
||||
"empty": "Không có sản phẩm phù hợp.",
|
||||
"loadFailed": "Không tải được danh sách sản phẩm.",
|
||||
"actionFailed": "Thao tác thất bại.",
|
||||
"noPermission": "Bạn không có quyền xem mục này.",
|
||||
"publish": "Đăng bán",
|
||||
"unpublish": "Ngừng bán",
|
||||
"status": {
|
||||
"ALL": "Tất cả",
|
||||
"ACTIVE": "Đang bán",
|
||||
"DRAFT": "Nháp",
|
||||
"ARCHIVED": "Lưu trữ"
|
||||
},
|
||||
"table": {
|
||||
"product": "Sản phẩm",
|
||||
"status": "Trạng thái",
|
||||
"variants": "Phiên bản",
|
||||
"stock": "Tồn kho",
|
||||
"price": "Giá"
|
||||
},
|
||||
"newProduct": "Sản phẩm mới",
|
||||
"edit": "Sửa"
|
||||
},
|
||||
"media": {
|
||||
"chooseFiles": "Chọn tệp",
|
||||
"uploadHint": "PNG, JPEG, WebP hoặc AVIF, tối đa 25 MB. Tải thẳng lên kho lưu trữ.",
|
||||
"uploading": "đang tải lên…",
|
||||
"uploadFailed": "Tải lên thất bại.",
|
||||
"deleteFailed": "Không xoá được.",
|
||||
"loadFailed": "Không tải được thư viện.",
|
||||
"empty": "Chưa có media.",
|
||||
"delete": "Xoá",
|
||||
"noPermission": "Bạn không có quyền xem mục này."
|
||||
},
|
||||
"inventory": {
|
||||
"searchPlaceholder": "Tìm SKU hoặc sản phẩm",
|
||||
"lowStockOnly": "Chỉ hàng sắp hết",
|
||||
"loadFailed": "Không tải được tồn kho.",
|
||||
"adjustFailed": "Điều chỉnh thất bại.",
|
||||
"noPermission": "Bạn không có quyền xem mục này.",
|
||||
"adjust": "Điều chỉnh",
|
||||
"apply": "Áp dụng",
|
||||
"cancel": "Huỷ",
|
||||
"outOfStock": "Hết hàng",
|
||||
"deltaLabel": "Thay đổi số lượng",
|
||||
"table": {
|
||||
"sku": "SKU",
|
||||
"product": "Sản phẩm",
|
||||
"onHand": "Tồn kho",
|
||||
"reserved": "Đang giữ",
|
||||
"available": "Có sẵn"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"createTitle": "Sản phẩm mới",
|
||||
"editTitle": "Sửa sản phẩm",
|
||||
"createHint": "Điền nội dung và các trục tuỳ chọn. Khi lưu, hệ thống sinh một phiên bản cho mỗi tổ hợp để bạn đặt giá ở tab Phiên bản.",
|
||||
"backToList": "Sản phẩm",
|
||||
"content": "Nội dung",
|
||||
"facets": "Phân loại",
|
||||
"options": "Tuỳ chọn",
|
||||
"generation": "Sinh phiên bản",
|
||||
"localeHint": "Ngôn ngữ chưa có tên sẽ được bỏ qua — storefront tự dùng nội dung thay thế theo từng trường.",
|
||||
"name": "Tên",
|
||||
"shortDescription": "Mô tả ngắn",
|
||||
"description": "Mô tả",
|
||||
"gender": "Giới tính",
|
||||
"sport": "Môn thể thao",
|
||||
"optionsHint": "Mỗi tổ hợp giá trị sẽ thành một phiên bản bán được, có SKU, giá và tồn kho riêng.",
|
||||
"addOption": "Thêm tuỳ chọn",
|
||||
"removeOption": "Xoá",
|
||||
"optionKey": "Mã",
|
||||
"optionName": "Nhãn",
|
||||
"addValue": "Thêm giá trị",
|
||||
"removeValue": "Xoá",
|
||||
"valueKey": "giá trị",
|
||||
"valueLabel": "Nhãn",
|
||||
"swatch": "Màu",
|
||||
"skuPrefix": "Tiền tố SKU",
|
||||
"basePrice": "Giá cơ bản (VND)",
|
||||
"generationHint": "Áp dụng cho mọi phiên bản được sinh ra. Bạn có thể chỉnh từng giá sau.",
|
||||
"save": "Lưu thay đổi",
|
||||
"create": "Tạo sản phẩm",
|
||||
"saving": "Đang lưu…",
|
||||
"saved": "Đã lưu",
|
||||
"saveFailed": "Không lưu được.",
|
||||
"loadFailed": "Không tải được sản phẩm này.",
|
||||
"matrixWarning": "Lưu sẽ sinh lại ma trận phiên bản",
|
||||
"publish": "Đăng bán",
|
||||
"unpublish": "Ngừng bán",
|
||||
"archive": "Lưu trữ",
|
||||
"tab": {
|
||||
"details": "Chi tiết",
|
||||
"variants": "Phiên bản",
|
||||
"images": "Hình ảnh"
|
||||
},
|
||||
"noVariants": "Chưa có phiên bản. Thêm giá trị tuỳ chọn ở tab Chi tiết.",
|
||||
"variantCount": "{count} đang bán",
|
||||
"archivedCount": "{count} đã lưu trữ",
|
||||
"archived": "Lưu trữ",
|
||||
"saveChanged": "Lưu {count} thay đổi",
|
||||
"grid": {
|
||||
"variant": "Phiên bản",
|
||||
"sku": "SKU",
|
||||
"price": "Giá",
|
||||
"sale": "Giá giảm",
|
||||
"stock": "Có sẵn / tồn kho"
|
||||
},
|
||||
"stockHint": "Tồn kho chỉ để xem ở đây. Điều chỉnh ở mục Kho hàng để mọi thay đổi được ghi vào sổ nhật ký kèm lý do.",
|
||||
"attached": "Hình đã gắn",
|
||||
"library": "Thư viện",
|
||||
"noImages": "Chưa gắn hình nào.",
|
||||
"saveImages": "Lưu hình ảnh",
|
||||
"primary": "Chính",
|
||||
"remove": "Xoá",
|
||||
"colourway": "Màu",
|
||||
"anyColourway": "Mọi màu",
|
||||
"moveLeft": "Chuyển lên trước",
|
||||
"moveRight": "Chuyển xuống sau"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@sport/config/tailwind/theme.css';
|
||||
/* Supplies the enter/exit utilities Radix-driven overlays animate with
|
||||
(`animate-in`, `fade-in-0`, `slide-in-from-right`). Registry components
|
||||
assume these exist; without it Dialog and Sheet appear instantly. */
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@source "../../../../packages/ui/src";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user