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