add more data
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user