Files
web_sport/apps/admin/src/features/customers/customers-table.tsx
T
2026-08-13 23:20:23 +07:00

266 lines
9.2 KiB
TypeScript

'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,
FormError,
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')}
/>
<FormError message={error} />
{!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>
);
}