add more data

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 8fd23d162f
commit 57f44796ce
20 changed files with 1436 additions and 18 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,22 +1,24 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { CustomersTable } from '@/features/customers/customers-table';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.customers');
return { title: t('title') };
}
export default async function CustomersPage() {
export default async function Page() {
const t = await getTranslations('pages.customers');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="customer.read"
milestone="M5 — orders"
/>
<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>
<CustomersTable />
</div>
);
}
@@ -0,0 +1,268 @@
'use client';
import { useFormatter, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminCustomer, type AdminCustomerDetail } from '@sport/types';
import {
Badge,
Button,
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeader,
DataTableRow,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
EmptyState,
Input,
Skeleton,
} from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatDateTime, formatMoney } from '@/lib/format';
export function CustomersTable() {
const t = useTranslations('customers');
const format = useFormatter();
const { can } = useSession();
const [customers, setCustomers] = useState<AdminCustomer[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// Debounced so typing does not fire a request per keystroke; the cleanup
// also discards a response whose query is already stale.
const timer = setTimeout(() => {
browserApi.admin
.listCustomers({ perPage: 50, q: query.trim() || undefined })
.then((result) => {
if (!cancelled) {
setCustomers([...result.items]);
setError(null);
}
})
.catch((caught: unknown) => {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [query, t]);
if (!can(PERMISSIONS.CUSTOMER_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-4">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
aria-label={t('searchPlaceholder')}
/>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!customers ? (
<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>
) : customers.length === 0 ? (
<EmptyState title={query ? t('noMatches') : t('empty')} />
) : (
<DataTable>
<DataTableHead>
<DataTableHeader>{t('table.customer')}</DataTableHeader>
<DataTableHeader>{t('table.orders')}</DataTableHeader>
<DataTableHeader>{t('table.spend')}</DataTableHeader>
<DataTableHeader>{t('table.marketing')}</DataTableHeader>
<DataTableHeader>{t('table.joined')}</DataTableHeader>
<DataTableHeader>
<span className="sr-only">{t('view')}</span>
</DataTableHeader>
</DataTableHead>
<DataTableBody>
{customers.map((customer) => (
<DataTableRow key={customer.id}>
<DataTableCell>
<div className="font-medium">{customer.displayName}</div>
<div className="text-ink-500 text-xs">
{customer.email}
{customer.phone ? ` · ${customer.phone}` : ''}
</div>
</DataTableCell>
<DataTableCell className="tabular-nums">{customer.orderCount}</DataTableCell>
<DataTableCell className="font-medium tabular-nums">
{formatMoney(customer.lifetimeSpend, format)}
</DataTableCell>
<DataTableCell>
{customer.acceptsMarketing ? (
<Badge variant="success">{t('subscribed')}</Badge>
) : (
<span className="text-ink-500 text-xs">{t('notSubscribed')}</span>
)}
</DataTableCell>
<DataTableCell className="text-ink-500 text-xs">
{formatDateTime(customer.createdAt, format)}
</DataTableCell>
<DataTableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => setSelectedId(customer.id)}>
{t('view')}
</Button>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
)}
{selectedId ? (
<CustomerDialog customerId={selectedId} onClose={() => setSelectedId(null)} />
) : null}
</div>
);
}
function CustomerDialog({ customerId, onClose }: { customerId: string; onClose: () => void }) {
const t = useTranslations('customers');
const format = useFormatter();
const [detail, setDetail] = useState<AdminCustomerDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
browserApi.admin
.getCustomer(customerId)
.then((result) => {
if (!cancelled) setDetail(result);
})
.catch((caught: unknown) => {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [customerId, t]);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{detail?.displayName ?? t('view')}</DialogTitle>
<DialogDescription>{detail?.email ?? ''}</DialogDescription>
</DialogHeader>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : !detail ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<div className="space-y-6">
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label={t('table.orders')} value={String(detail.orderCount)} />
<Stat label={t('table.spend')} value={formatMoney(detail.lifetimeSpend, format)} />
<Stat
label={t('lastSeen')}
value={detail.lastLoginAt ? formatDateTime(detail.lastLoginAt, format) : '—'}
/>
<Stat label={t('table.joined')} value={formatDateTime(detail.createdAt, format)} />
</dl>
<section>
<h3 className="text-eyebrow text-ink-500 uppercase">{t('recentOrders')}</h3>
{detail.orders.length === 0 ? (
<p className="text-ink-500 mt-3 text-sm">{t('noOrders')}</p>
) : (
<ul className="divide-ink-100 mt-3 divide-y border-y">
{detail.orders.map((order) => (
<li key={order.id} className="flex items-center gap-3 py-2.5 text-sm">
<span className="font-mono">{order.orderNumber}</span>
<Badge variant="neutral">{order.status}</Badge>
<span className="text-ink-500 ml-auto text-xs">
{formatDateTime(order.placedAt, format)}
</span>
<span className="w-28 text-right font-medium tabular-nums">
{formatMoney(order.total, format)}
</span>
</li>
))}
</ul>
)}
</section>
<section>
<h3 className="text-eyebrow text-ink-500 uppercase">{t('addresses')}</h3>
{detail.addresses.length === 0 ? (
<p className="text-ink-500 mt-3 text-sm">{t('noAddresses')}</p>
) : (
<ul className="mt-3 grid gap-3 sm:grid-cols-2">
{detail.addresses.map((address) => (
<li key={address.id} className="border-ink-200 rounded-card border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{address.fullName}</span>
{address.isDefaultShipping ? (
<Badge variant="success">{t('default')}</Badge>
) : null}
</div>
<p className="text-ink-600 mt-1 text-xs leading-relaxed">
{address.phone}
<br />
{[address.line1, address.ward, address.district, address.province]
.filter(Boolean)
.join(', ')}
</p>
</li>
))}
</ul>
)}
</section>
</div>
)}
</DialogContent>
</Dialog>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-eyebrow text-ink-500 uppercase">{label}</dt>
<dd className="mt-1 text-sm font-medium">{value}</dd>
</div>
);
}
@@ -42,6 +42,7 @@ export function ProductsTable() {
const [status, setStatus] = useState<'' | 'DRAFT' | 'ACTIVE' | 'ARCHIVED'>('');
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
const load = useCallback(async () => {
try {
@@ -64,6 +65,28 @@ export function ProductsTable() {
return () => clearTimeout(timer);
}, [load]);
/**
* Two-step, and the second step is a different button.
*
* Retiring a product pulls it from the storefront immediately. A single
* click next to "Edit" and "Publish" is too easy to hit by accident, and a
* native `confirm()` cannot be translated or styled — so the row itself asks
* for confirmation, and clicking anything else cancels it.
*/
async function removeProduct(product: AdminProductListItem) {
setBusyId(product.id);
try {
await browserApi.catalogAdmin.deleteProduct(product.id);
setConfirmingDeleteId(null);
await load();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('actionFailed'));
setConfirmingDeleteId(null);
} finally {
setBusyId(null);
}
}
async function toggleStatus(product: AdminProductListItem) {
setBusyId(product.id);
try {
@@ -207,6 +230,20 @@ export function ProductsTable() {
{product.status === 'ACTIVE' ? t('unpublish') : t('publish')}
</Button>
) : null}
{can(PERMISSIONS.PRODUCT_DELETE) ? (
<Button
size="sm"
variant={confirmingDeleteId === product.id ? 'destructive' : 'ghost'}
disabled={busyId === product.id}
onClick={() =>
confirmingDeleteId === product.id
? void removeProduct(product)
: setConfirmingDeleteId(product.id)
}
>
{confirmingDeleteId === product.id ? t('deleteConfirm') : t('delete')}
</Button>
) : null}
</div>
</DataTableCell>
</DataTableRow>
+26 -1
View File
@@ -124,7 +124,9 @@
"price": "Price"
},
"newProduct": "New product",
"edit": "Edit"
"edit": "Edit",
"delete": "Retire",
"deleteConfirm": "Confirm retire"
},
"media": {
"chooseFiles": "Choose files",
@@ -472,5 +474,28 @@
"edit": "Edit collection",
"empty": "No collections yet."
}
},
"customers": {
"noPermission": "You do not have permission to view customers.",
"loadFailed": "Could not load customers.",
"searchPlaceholder": "Search name, email or phone",
"table": {
"customer": "Customer",
"orders": "Orders",
"spend": "Lifetime spend",
"marketing": "Marketing",
"joined": "Joined"
},
"empty": "No customers yet.",
"noMatches": "No customers match that search.",
"view": "View",
"subscribed": "Subscribed",
"notSubscribed": "No",
"recentOrders": "Recent orders",
"noOrders": "No orders yet.",
"addresses": "Saved addresses",
"noAddresses": "No saved addresses.",
"default": "Default",
"lastSeen": "Last sign-in"
}
}
+26 -1
View File
@@ -124,7 +124,9 @@
"price": "Giá"
},
"newProduct": "Sản phẩm mới",
"edit": "Sửa"
"edit": "Sửa",
"delete": "Ngừng bán",
"deleteConfirm": "Xác nhận ngừng"
},
"media": {
"chooseFiles": "Chọn tệp",
@@ -472,5 +474,28 @@
"edit": "Sửa bộ sưu tập",
"empty": "Chưa có bộ sưu tập nào."
}
},
"customers": {
"noPermission": "Bạn không có quyền xem khách hàng.",
"loadFailed": "Không tải được danh sách khách hàng.",
"searchPlaceholder": "Tìm tên, email hoặc số điện thoại",
"table": {
"customer": "Khách hàng",
"orders": "Đơn hàng",
"spend": "Tổng chi tiêu",
"marketing": "Nhận tin",
"joined": "Ngày tham gia"
},
"empty": "Chưa có khách hàng nào.",
"noMatches": "Không tìm thấy khách hàng phù hợp.",
"view": "Xem",
"subscribed": "Có đăng ký",
"notSubscribed": "Không",
"recentOrders": "Đơn hàng gần đây",
"noOrders": "Chưa có đơn hàng.",
"addresses": "Địa chỉ đã lưu",
"noAddresses": "Chưa lưu địa chỉ nào.",
"default": "Mặc định",
"lastSeen": "Đăng nhập gần nhất"
}
}