266 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|