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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sp
|
||||
|
||||
import { seedDevAccounts } from './seed/accounts';
|
||||
import { seedCatalog } from './seed/catalog';
|
||||
import { seedDemoVolume } from './seed/demo';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -147,6 +148,18 @@ async function main(): Promise<void> {
|
||||
await seedCatalog(prisma);
|
||||
|
||||
const accounts = await seedDevAccounts(prisma);
|
||||
|
||||
/**
|
||||
* Demo volume — orders, customers, reviews, posts.
|
||||
*
|
||||
* Behind the same guard as the demo catalog: production seeds roles,
|
||||
* permissions and an inventory location, and nothing else.
|
||||
*/
|
||||
const demo = await seedDemoVolume(prisma);
|
||||
console.log(
|
||||
`Demo volume: ${demo.customers} customers, ${demo.orders} orders, ` +
|
||||
`${demo.reviews} reviews, ${demo.content} posts, ${demo.discounts} discounts.`,
|
||||
);
|
||||
const created = accounts.filter((account) => account.created);
|
||||
|
||||
if (created.length > 0) {
|
||||
|
||||
@@ -610,3 +610,231 @@ export const PRODUCTS: ProductSeed[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* ---- UI/UX stress cases -----------------------------------------------------
|
||||
*
|
||||
* The products above are a tidy, representative catalog. These exist to break
|
||||
* layouts on purpose: the longest name a merchandiser might plausibly type, a
|
||||
* product with one variant next to one with twenty-five, a 62% markdown beside
|
||||
* something at full price, and an item that is deliberately out of stock.
|
||||
*
|
||||
* A grid that only ever sees three-word names and 20% discounts looks finished
|
||||
* right up until real data arrives.
|
||||
*/
|
||||
const STRESS_PRODUCTS: ProductSeed[] = [
|
||||
{
|
||||
key: 'endurance-long-sleeve-thermal-base-layer',
|
||||
skuPrefix: 'VEL-ELT',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN'],
|
||||
sports: ['RUNNING', 'TRAINING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['black', 'slate', 'navy', 'sand', 'white'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1290000,
|
||||
salePrice: 490000,
|
||||
compareAt: 1290000,
|
||||
weightGrams: 220,
|
||||
// Deliberately the longest name in the catalog: it wraps the product card
|
||||
// title to three lines and overflows the admin table without truncation.
|
||||
name: {
|
||||
vi: 'Áo Giữ Nhiệt Dài Tay Endurance Cho Buổi Chạy Sáng Sớm Mùa Đông',
|
||||
en: 'Endurance Long-Sleeve Thermal Base Layer For Early Winter Morning Runs',
|
||||
},
|
||||
slug: {
|
||||
vi: 'ao-giu-nhiet-dai-tay-endurance-mua-dong',
|
||||
en: 'endurance-long-sleeve-thermal-base-layer',
|
||||
},
|
||||
shortDescription: {
|
||||
vi: 'Lớp nền giữ nhiệt cho những buổi chạy dưới 10°C, mỏng nhưng ấm.',
|
||||
en: 'A thermal base layer for sub-10°C runs — thin, but genuinely warm.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Dệt kim hai mặt giữ nhiệt mà không bí. Cổ cao ôm vừa, bo tay có lỗ xỏ ngón, và dải phản quang chạy dọc sống lưng cho những buổi chạy trước bình minh.',
|
||||
en: 'A double-knit face traps warmth without trapping sweat. The collar sits high without choking, the cuffs have thumbholes, and a reflective strip runs the length of the spine for pre-dawn miles.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: {
|
||||
vi: '88% Polyester tái chế, 12% Elastane',
|
||||
en: '88% recycled polyester, 12% elastane',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'fit',
|
||||
label: { vi: 'Kiểu dáng', en: 'Fit' },
|
||||
value: { vi: 'Ôm sát', en: 'Slim fit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'field-cap',
|
||||
skuPrefix: 'KNX-FCP',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['UNISEX'],
|
||||
sports: ['RUNNING', 'LIFESTYLE'],
|
||||
collections: [],
|
||||
colours: ['black'],
|
||||
// One colour, one size — the minimum viable product, and the case where a
|
||||
// variant selector should not render at all.
|
||||
sizes: ['M'],
|
||||
price: 350000,
|
||||
weightGrams: 70,
|
||||
name: { vi: 'Mũ Lưỡi Trai Field', en: 'Field Cap' },
|
||||
slug: { vi: 'mu-luoi-trai-field', en: 'field-cap' },
|
||||
shortDescription: {
|
||||
vi: 'Mũ chạy nhẹ, một size vừa mọi người.',
|
||||
en: 'A light running cap. One size, fits most.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Vành mềm gập được để nhét túi. Dải thấm mồ hôi thay thế được.',
|
||||
en: 'A soft, packable brim that survives being stuffed in a pocket. Replaceable sweatband.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: 'Nylon ripstop', en: 'Ripstop nylon' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'trail-runner-gtx',
|
||||
skuPrefix: 'RDG-TRG',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/footwear',
|
||||
genders: ['MEN'],
|
||||
sports: ['RUNNING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['slate', 'volt', 'black', 'sand'],
|
||||
sizes: SHOE_SIZES,
|
||||
price: 3290000,
|
||||
weightGrams: 310,
|
||||
name: { vi: 'Giày Chạy Trail Ridge GTX', en: 'Ridge Trail Runner GTX' },
|
||||
slug: { vi: 'giay-chay-trail-ridge-gtx', en: 'ridge-trail-runner-gtx' },
|
||||
shortDescription: {
|
||||
vi: 'Giày trail chống nước cho địa hình lầy.',
|
||||
en: 'A waterproof trail shoe for churned-up ground.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Đế gai sâu 5mm bám tốt trên bùn, màng chống nước giữ chân khô, và tấm chống đá bảo vệ bàn chân trên đường đá dăm.',
|
||||
en: 'Five-millimetre lugs bite into mud, a waterproof membrane keeps the wet out, and a rock plate takes the sting out of loose gravel.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'drop',
|
||||
label: { vi: 'Độ chênh gót', en: 'Heel drop' },
|
||||
value: { vi: '8mm', en: '8mm' },
|
||||
},
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: 'Lưới dệt + màng chống nước', en: 'Engineered mesh with waterproof membrane' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'studio-crop-tee',
|
||||
skuPrefix: 'KNX-SCT',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'women/tops',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['TRAINING', 'GYM'],
|
||||
collections: ['new-arrivals', 'summer-drop'],
|
||||
colours: ['white', 'sand', 'teal', 'crimson'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 490000,
|
||||
weightGrams: 95,
|
||||
name: { vi: 'Áo Croptop Studio', en: 'Studio Crop Tee' },
|
||||
slug: { vi: 'ao-croptop-studio', en: 'studio-crop-tee' },
|
||||
shortDescription: {
|
||||
vi: 'Áo croptop mềm cho buổi tập trong nhà.',
|
||||
en: 'A soft crop tee for indoor sessions.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Vải cotton pha modal rủ mềm, dáng rộng vừa phải, không bó khi giơ tay.',
|
||||
en: 'A cotton–modal blend that drapes rather than clings, cut loose enough to stay put when your arms go overhead.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '60% Cotton, 40% Modal', en: '60% cotton, 40% modal' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'summit-shell-jacket',
|
||||
skuPrefix: 'RDG-SSJ',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'women/tops',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['RUNNING', 'LIFESTYLE'],
|
||||
collections: ['sale'],
|
||||
colours: ['navy', 'crimson'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 2890000,
|
||||
salePrice: 1090000,
|
||||
compareAt: 2890000,
|
||||
weightGrams: 260,
|
||||
name: { vi: 'Áo Khoác Gió Summit', en: 'Summit Shell Jacket' },
|
||||
slug: { vi: 'ao-khoac-gio-summit', en: 'summit-shell-jacket' },
|
||||
shortDescription: {
|
||||
vi: 'Áo khoác gió gập gọn, chống mưa nhẹ.',
|
||||
en: 'A packable wind shell that shrugs off light rain.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Gập vào chính túi ngực. Đủ nhẹ để mang theo cả buổi mà không nghĩ đến, đủ kín để cứu bạn khi trời đổi.',
|
||||
en: 'Packs into its own chest pocket. Light enough to carry all day without thinking about it, sealed enough to matter when the weather turns.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: 'Nylon 20D phủ DWR', en: '20D nylon with a DWR finish' },
|
||||
},
|
||||
{
|
||||
key: 'care',
|
||||
label: { vi: 'Bảo quản', en: 'Care' },
|
||||
value: { vi: 'Giặt tay, phơi khô', en: 'Hand wash, line dry' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'anchor-lifting-belt',
|
||||
skuPrefix: 'RDG-ALB',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/bottoms',
|
||||
genders: ['UNISEX'],
|
||||
sports: ['GYM'],
|
||||
collections: [],
|
||||
colours: ['black', 'sand'],
|
||||
sizes: ['S', 'M', 'L'],
|
||||
price: 1150000,
|
||||
weightGrams: 480,
|
||||
name: { vi: 'Đai Lưng Tập Tạ Anchor', en: 'Anchor Lifting Belt' },
|
||||
slug: { vi: 'dai-lung-tap-ta-anchor', en: 'anchor-lifting-belt' },
|
||||
shortDescription: {
|
||||
vi: 'Đai da 10mm cho squat và deadlift nặng.',
|
||||
en: 'A 10mm leather belt for heavy squats and pulls.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Da thuộc nguyên tấm dày 10mm, chốt cài đơn, bản đều 10cm quanh thân — hỗ trợ đều thay vì chỉ ở lưng dưới.',
|
||||
en: 'Single-piece 10mm leather with a one-prong buckle and a uniform 10cm width the whole way round, so the support is even instead of concentrated at the lower back.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: 'Da bò thuộc thảo mộc', en: 'Vegetable-tanned leather' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
PRODUCTS.push(...STRESS_PRODUCTS);
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Reuses the seed's own hasher rather than adding a third copy of the
|
||||
// format — see the note at the top of accounts.ts.
|
||||
import { hashPassword } from './accounts';
|
||||
|
||||
/**
|
||||
* Demo volume, for looking at the UI with something like real data in it.
|
||||
*
|
||||
* Separate from `seed/catalog.ts` on purpose. That file is the canonical demo
|
||||
* catalog — a small, hand-written, meaningful set. This one generates *volume*:
|
||||
* enough orders to fill a table and paginate it, enough reviews to make a
|
||||
* rating distribution interesting, enough customers to sort by spend.
|
||||
*
|
||||
* Everything here is deterministic. A seeded PRNG rather than `Math.random`
|
||||
* means re-running produces the same store, so "the third order looks wrong"
|
||||
* is a reproducible statement rather than a race with the data.
|
||||
*
|
||||
* It writes through the same invariants the application maintains — order
|
||||
* totals equal the sum of their lines, stock is decremented for what ships,
|
||||
* rating aggregates are recomputed from approved reviews — because demo data
|
||||
* that violates them makes the UI look broken in ways the code is not.
|
||||
*/
|
||||
|
||||
/** Mulberry32 — small, fast, and identical on every run. */
|
||||
function makeRandom(seed: number) {
|
||||
let state = seed;
|
||||
|
||||
return () => {
|
||||
state |= 0;
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const random = makeRandom(20260813);
|
||||
|
||||
const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!;
|
||||
const between = (min: number, max: number) => min + Math.floor(random() * (max - min + 1));
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Minh',
|
||||
'Lan',
|
||||
'Huy',
|
||||
'Trang',
|
||||
'Nam',
|
||||
'Thảo',
|
||||
'Quân',
|
||||
'Linh',
|
||||
'Dũng',
|
||||
'Mai',
|
||||
'Phong',
|
||||
'Hà',
|
||||
'Sơn',
|
||||
'Ngọc',
|
||||
'Tuấn',
|
||||
'Vy',
|
||||
];
|
||||
const LAST_NAMES = ['Nguyễn', 'Trần', 'Lê', 'Phạm', 'Hoàng', 'Vũ', 'Đặng', 'Bùi'];
|
||||
const PROVINCES = ['Hà Nội', 'TP Hồ Chí Minh', 'Đà Nẵng', 'Hải Phòng', 'Cần Thơ'];
|
||||
const DISTRICTS = ['Hoàn Kiếm', 'Ba Đình', 'Quận 1', 'Quận 3', 'Hải Châu', 'Ngô Quyền'];
|
||||
|
||||
/** Skewed to the top, the way real ratings are. */
|
||||
const RATING_WEIGHTS = [5, 5, 5, 5, 5, 4, 4, 4, 4, 3, 3, 2, 1];
|
||||
|
||||
const REVIEW_TITLES_EN = [
|
||||
'Exactly what I wanted',
|
||||
'Good, with one caveat',
|
||||
'Runs small — size up',
|
||||
'Worth the money',
|
||||
'Held up better than expected',
|
||||
'Comfortable from day one',
|
||||
'Not for cold weather',
|
||||
'My third one of these',
|
||||
'Fit is spot on',
|
||||
];
|
||||
const REVIEW_BODIES_EN = [
|
||||
'Wore it for a half marathon last weekend and forgot it was on, which is the highest praise I have for kit.',
|
||||
'The fabric is genuinely good. My only complaint is the cuffs stretched a little after about ten washes.',
|
||||
'Sizing runs at least half a size small. I normally take M and the L is the right call here.',
|
||||
'Bought it on sale and would happily have paid full price. No chafing on a two-hour run.',
|
||||
'Colour is slightly darker than the photos suggest. Still happy with it.',
|
||||
'',
|
||||
];
|
||||
|
||||
export interface DemoSummary {
|
||||
customers: number;
|
||||
orders: number;
|
||||
reviews: number;
|
||||
content: number;
|
||||
discounts: number;
|
||||
}
|
||||
|
||||
export async function seedDemoVolume(prisma: PrismaClient): Promise<DemoSummary> {
|
||||
const customerRole = await prisma.role.findUnique({ where: { key: 'customer' } });
|
||||
if (!customerRole) throw new Error('The customer role is missing; run the base seed first.');
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
where: { deletedAt: null, status: 'ACTIVE' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
variants: {
|
||||
where: { status: 'ACTIVE', deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
optionValues: { select: { optionValue: { select: { label: true } } } },
|
||||
},
|
||||
},
|
||||
images: { take: 1, select: { media: { select: { storageKey: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const sellable = products.filter((product) => product.variants.length > 0);
|
||||
if (sellable.length === 0) throw new Error('No sellable products; run the catalog seed first.');
|
||||
|
||||
// ---- Customers ---------------------------------------------------------
|
||||
// One shared hash: scrypt at N=16384 costs real time, and these accounts all
|
||||
// use the same password anyway.
|
||||
const passwordHash = await hashPassword('Demo@2026Sport');
|
||||
const customers: { id: string; email: string; name: string }[] = [];
|
||||
|
||||
for (let index = 0; index < 18; index += 1) {
|
||||
const first = pick(FIRST_NAMES);
|
||||
const last = pick(LAST_NAMES);
|
||||
const email = `demo${index + 1}@example.com`;
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, customer: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (existing?.customer) {
|
||||
customers.push({ id: existing.customer.id, email, name: `${first} ${last}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const user =
|
||||
existing ??
|
||||
(await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
type: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
firstName: first,
|
||||
lastName: last,
|
||||
phone: `09${between(10, 99)}${between(100000, 999999)}`,
|
||||
roles: { create: { roleId: customerRole.id } },
|
||||
},
|
||||
select: { id: true, customer: { select: { id: true } } },
|
||||
}));
|
||||
|
||||
const customer = await prisma.customer.create({
|
||||
data: { userId: user.id, acceptsMarketing: random() > 0.45 },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.address.create({
|
||||
data: {
|
||||
customerId: customer.id,
|
||||
fullName: `${first} ${last}`,
|
||||
phone: `09${between(10, 99)}${between(100000, 999999)}`,
|
||||
line1: `${between(1, 250)} ${pick(['Lê Lợi', 'Trần Hưng Đạo', 'Nguyễn Huệ', 'Láng Hạ'])}`,
|
||||
district: pick(DISTRICTS),
|
||||
province: pick(PROVINCES),
|
||||
isDefaultShipping: true,
|
||||
isDefaultBilling: true,
|
||||
},
|
||||
});
|
||||
|
||||
customers.push({ id: customer.id, email, name: `${first} ${last}` });
|
||||
}
|
||||
|
||||
// ---- Orders ------------------------------------------------------------
|
||||
const STATUSES = [
|
||||
'PENDING',
|
||||
'CONFIRMED',
|
||||
'CONFIRMED',
|
||||
'FULFILLED',
|
||||
'FULFILLED',
|
||||
'FULFILLED',
|
||||
'COMPLETED',
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
|
||||
const existingDemoOrders = await prisma.order.count({
|
||||
where: { email: { startsWith: 'demo' }, customerNote: 'demo-volume' },
|
||||
});
|
||||
|
||||
let ordersCreated = 0;
|
||||
const reviewableLines: { orderLineId: string; productId: string }[] = [];
|
||||
|
||||
if (existingDemoOrders === 0) {
|
||||
for (let index = 0; index < 45; index += 1) {
|
||||
const customer = pick(customers);
|
||||
const status = pick(STATUSES);
|
||||
|
||||
// Spread across ~120 days so date filters and "recent orders" have range.
|
||||
const placedAt = new Date(Date.now() - between(0, 120) * 86_400_000);
|
||||
|
||||
const lineCount = between(1, 3);
|
||||
const lines: {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
variantTitle: string;
|
||||
sku: string;
|
||||
imageUrl: string | null;
|
||||
unitAmount: number;
|
||||
quantity: number;
|
||||
lineAmount: number;
|
||||
}[] = [];
|
||||
|
||||
for (let n = 0; n < lineCount; n += 1) {
|
||||
const product = pick(sellable);
|
||||
const variant = pick(product.variants);
|
||||
if (lines.some((line) => line.variantId === variant.id)) continue;
|
||||
|
||||
const unitAmount = variant.salePriceAmount ?? variant.priceAmount;
|
||||
const quantity = between(1, 2);
|
||||
|
||||
lines.push({
|
||||
variantId: variant.id,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
variantTitle: variant.optionValues.map((v) => v.optionValue.label).join(' / ') || '—',
|
||||
sku: variant.sku,
|
||||
imageUrl: product.images[0]?.media.storageKey ?? null,
|
||||
unitAmount,
|
||||
quantity,
|
||||
lineAmount: unitAmount * quantity,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
const subtotal = lines.reduce((sum, line) => sum + line.lineAmount, 0);
|
||||
// A third of orders carry a discount, so the order table is not a column
|
||||
// of identical zeroes.
|
||||
const discount = random() > 0.66 ? Math.floor(subtotal * 0.1) : 0;
|
||||
const shipping = subtotal >= 1_000_000 ? 0 : 30_000;
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
customerId: customer.id,
|
||||
email: customer.email,
|
||||
phone: `09${between(10, 99)}${between(100000, 999999)}`,
|
||||
status,
|
||||
paymentStatus: status === 'CANCELLED' ? 'UNPAID' : 'PAID',
|
||||
// Kept consistent with `status` — a FULFILLED order showing
|
||||
// UNFULFILLED was a real bug once (see the M7 notes).
|
||||
fulfillmentStatus:
|
||||
status === 'FULFILLED' || status === 'COMPLETED' ? 'FULFILLED' : 'UNFULFILLED',
|
||||
currency: 'VND',
|
||||
subtotalAmount: subtotal,
|
||||
discountAmount: discount,
|
||||
shippingAmount: shipping,
|
||||
taxAmount: 0,
|
||||
totalAmount: subtotal - discount + shipping,
|
||||
shipFullName: customer.name,
|
||||
shipPhone: `09${between(10, 99)}${between(100000, 999999)}`,
|
||||
shipLine1: `${between(1, 250)} ${pick(['Lê Lợi', 'Trần Hưng Đạo', 'Nguyễn Huệ'])}`,
|
||||
shipDistrict: pick(DISTRICTS),
|
||||
shipProvince: pick(PROVINCES),
|
||||
customerNote: 'demo-volume',
|
||||
placedAt,
|
||||
confirmedAt: status === 'PENDING' ? null : placedAt,
|
||||
cancelledAt: status === 'CANCELLED' ? placedAt : null,
|
||||
lines: {
|
||||
create: lines.map((line) => ({
|
||||
variantId: line.variantId,
|
||||
productName: line.productName,
|
||||
variantTitle: line.variantTitle,
|
||||
sku: line.sku,
|
||||
imageUrl: line.imageUrl,
|
||||
unitAmount: line.unitAmount,
|
||||
quantity: line.quantity,
|
||||
lineAmount: line.lineAmount,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: { id: true, lines: { select: { id: true, variantId: true } } },
|
||||
});
|
||||
|
||||
ordersCreated += 1;
|
||||
|
||||
// Only delivered goods can be reviewed — the same rule the API enforces.
|
||||
if (status === 'FULFILLED' || status === 'COMPLETED') {
|
||||
for (const line of order.lines) {
|
||||
const source = lines.find((candidate) => candidate.variantId === line.variantId);
|
||||
if (source) reviewableLines.push({ orderLineId: line.id, productId: source.productId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reviews -----------------------------------------------------------
|
||||
let reviewsCreated = 0;
|
||||
const touchedProducts = new Set<string>();
|
||||
|
||||
for (const candidate of reviewableLines) {
|
||||
if (random() > 0.55) continue;
|
||||
|
||||
const existing = await prisma.review.findUnique({
|
||||
where: { orderLineId: candidate.orderLineId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const rating = pick(RATING_WEIGHTS);
|
||||
// Most reviews land approved; a few stay pending and one in ten is
|
||||
// rejected, so the moderation queue has all three states to show.
|
||||
const roll = random();
|
||||
const status = roll > 0.25 ? 'APPROVED' : roll > 0.1 ? 'PENDING' : 'REJECTED';
|
||||
|
||||
await prisma.review.create({
|
||||
data: {
|
||||
productId: candidate.productId,
|
||||
orderLineId: candidate.orderLineId,
|
||||
rating,
|
||||
title: random() > 0.25 ? pick(REVIEW_TITLES_EN) : null,
|
||||
body: pick(REVIEW_BODIES_EN) || null,
|
||||
authorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES).charAt(0)}.`,
|
||||
status,
|
||||
moderationNote: status === 'REJECTED' ? 'Off-topic' : null,
|
||||
moderatedAt: status === 'PENDING' ? null : new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
reviewsCreated += 1;
|
||||
touchedProducts.add(candidate.productId);
|
||||
}
|
||||
|
||||
// Rating aggregates are derived columns with a single writer; recompute them
|
||||
// here for exactly the reason ReviewsService does — an incremented aggregate
|
||||
// that nobody recomputed is a product showing stars no review supports.
|
||||
for (const productId of touchedProducts) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE products p
|
||||
SET rating_sum = COALESCE(agg.total, 0),
|
||||
rating_count = COALESCE(agg.n, 0)
|
||||
FROM (
|
||||
SELECT SUM(rating)::int AS total, COUNT(*)::int AS n
|
||||
FROM reviews
|
||||
WHERE product_id = ${productId}::uuid
|
||||
AND status = 'APPROVED'
|
||||
) agg
|
||||
WHERE p.id = ${productId}::uuid
|
||||
`;
|
||||
}
|
||||
|
||||
// ---- Content -----------------------------------------------------------
|
||||
const POSTS = [
|
||||
{
|
||||
slug: 'chon-giay-chay-bo',
|
||||
slugEn: 'choosing-running-shoes',
|
||||
title: 'Chọn giày chạy bộ: ba thứ thực sự quan trọng',
|
||||
titleEn: 'Choosing running shoes: the three things that matter',
|
||||
excerpt: 'Bỏ qua tiếp thị. Đây là những gì quyết định một đôi giày hợp với bạn.',
|
||||
excerptEn: 'Ignore the marketing. These are what actually decide whether a shoe suits you.',
|
||||
},
|
||||
{
|
||||
slug: 'tap-luyen-mua-mua',
|
||||
slugEn: 'training-through-the-rainy-season',
|
||||
title: 'Tập luyện xuyên mùa mưa',
|
||||
titleEn: 'Training through the rainy season',
|
||||
excerpt: 'Mưa không phải lý do nghỉ tập — nhưng cần chuẩn bị khác đi.',
|
||||
excerptEn: 'Rain is not a reason to skip a session, but it does change the preparation.',
|
||||
},
|
||||
{
|
||||
slug: 'giat-do-the-thao-dung-cach',
|
||||
slugEn: 'washing-technical-fabrics',
|
||||
title: 'Giặt đồ thể thao đúng cách',
|
||||
titleEn: 'How to wash technical fabrics without ruining them',
|
||||
excerpt: 'Nước xả vải là kẻ thù của vải thoát ẩm. Đây là lý do.',
|
||||
excerptEn: 'Fabric softener is the enemy of moisture-wicking fabric. Here is why.',
|
||||
},
|
||||
];
|
||||
|
||||
let contentCreated = 0;
|
||||
|
||||
for (const post of POSTS) {
|
||||
const exists = await prisma.contentEntryTranslation.findFirst({
|
||||
where: { slug: post.slug },
|
||||
select: { entryId: true },
|
||||
});
|
||||
if (exists) continue;
|
||||
|
||||
await prisma.contentEntry.create({
|
||||
data: {
|
||||
type: 'POST',
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: new Date(Date.now() - between(1, 60) * 86_400_000),
|
||||
translations: {
|
||||
create: [
|
||||
{
|
||||
locale: 'VI',
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
body: `## ${post.title}\n\n${post.excerpt}\n\n### Điều đầu tiên\n\nMột đoạn nội dung mẫu để kiểm tra cách trình bày bài viết trên nhiều độ dài khác nhau.\n\n- Điểm thứ nhất\n- Điểm thứ hai\n- Điểm thứ ba\n\n> Một trích dẫn để kiểm tra kiểu blockquote.\n\nXem thêm [đồ nam](/men).`,
|
||||
},
|
||||
{
|
||||
locale: 'EN',
|
||||
slug: post.slugEn,
|
||||
title: post.titleEn,
|
||||
excerpt: post.excerptEn,
|
||||
body: `## ${post.titleEn}\n\n${post.excerptEn}\n\n### The first thing\n\nSample copy, here to check how an article reads at a few different lengths.\n\n- The first point\n- The second point\n- The third point\n\n> A pull quote, to exercise the blockquote style.\n\nSee also [menswear](/men).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
contentCreated += 1;
|
||||
}
|
||||
|
||||
// ---- Discounts ---------------------------------------------------------
|
||||
const DISCOUNTS = [
|
||||
{
|
||||
code: 'FREESHIP',
|
||||
type: 'FIXED_AMOUNT' as const,
|
||||
value: 30_000,
|
||||
stackable: true,
|
||||
priority: 60,
|
||||
name: { vi: 'Miễn phí vận chuyển', en: 'Free shipping' },
|
||||
minSubtotalAmount: 500_000,
|
||||
},
|
||||
{
|
||||
code: 'EXPIRED10',
|
||||
type: 'PERCENTAGE' as const,
|
||||
value: 10,
|
||||
stackable: false,
|
||||
priority: 40,
|
||||
name: { vi: 'Ưu đãi đã hết hạn', en: 'Expired offer' },
|
||||
endsAt: new Date(Date.now() - 7 * 86_400_000),
|
||||
},
|
||||
{
|
||||
code: 'BIGSALE30',
|
||||
type: 'PERCENTAGE' as const,
|
||||
value: 30,
|
||||
stackable: false,
|
||||
priority: 5,
|
||||
name: { vi: 'Giảm 30% toàn bộ', en: '30% off everything' },
|
||||
usageLimit: 50,
|
||||
},
|
||||
];
|
||||
|
||||
let discountsCreated = 0;
|
||||
|
||||
for (const discount of DISCOUNTS) {
|
||||
const exists = await prisma.discount.findFirst({
|
||||
where: { code: discount.code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists) continue;
|
||||
|
||||
await prisma.discount.create({
|
||||
data: {
|
||||
code: discount.code,
|
||||
trigger: 'CODE',
|
||||
type: discount.type,
|
||||
scope: 'ORDER',
|
||||
value: discount.value,
|
||||
minSubtotalAmount: discount.minSubtotalAmount ?? null,
|
||||
endsAt: discount.endsAt ?? null,
|
||||
usageLimit: discount.usageLimit ?? null,
|
||||
stackable: discount.stackable,
|
||||
priority: discount.priority,
|
||||
isActive: true,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'VI', name: discount.name.vi },
|
||||
{ locale: 'EN', name: discount.name.en },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
discountsCreated += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
customers: customers.length,
|
||||
orders: ordersCreated,
|
||||
reviews: reviewsCreated,
|
||||
content: contentCreated,
|
||||
discounts: discountsCreated,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AdminCustomer,
|
||||
type AdminCustomerDetail,
|
||||
type OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
import { customerListQuerySchema, type CustomerListQuery } from '@sport/validation';
|
||||
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { CustomersAdminService } from './customers-admin.service';
|
||||
|
||||
/**
|
||||
* Read-only, deliberately.
|
||||
*
|
||||
* There is no admin endpoint to edit a shopper's profile or delete their
|
||||
* account. Support changing someone's saved address on their behalf is a
|
||||
* social-engineering vector, and account deletion is a GDPR-shaped process
|
||||
* (export, then erase, with the order history retained for tax law) rather
|
||||
* than a button. Both belong in their own milestone with their own audit
|
||||
* trail; shipping a half version now would be worse than not having one.
|
||||
*/
|
||||
@ApiTags('admin/customers')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/customers')
|
||||
export class CustomersAdminController {
|
||||
constructor(private readonly service: CustomersAdminService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: 'Customers, newest first' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery,
|
||||
): Promise<OffsetPaginated<AdminCustomer>> {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: 'One customer with recent orders and saved addresses' })
|
||||
getById(@Param('id') id: string): Promise<AdminCustomerDetail> {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { AdminCustomer, AdminCustomerDetail, OffsetPaginated } from '@sport/types';
|
||||
import type { CustomerListQuery } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const listSelect = {
|
||||
id: true,
|
||||
acceptsMarketing: true,
|
||||
createdAt: true,
|
||||
note: true,
|
||||
user: {
|
||||
select: { email: true, firstName: true, lastName: true, phone: true, lastLoginAt: true },
|
||||
},
|
||||
_count: { select: { orders: true, addresses: true } },
|
||||
} as const;
|
||||
|
||||
type Row = Prisma.CustomerGetPayload<{ select: typeof listSelect }>;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersAdminService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: CustomerListQuery): Promise<OffsetPaginated<AdminCustomer>> {
|
||||
const where: Prisma.CustomerWhereInput = query.q
|
||||
? {
|
||||
user: {
|
||||
OR: [
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
{ firstName: { contains: query.q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: query.q, mode: 'insensitive' } },
|
||||
{ phone: { contains: query.q } },
|
||||
],
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.prisma.customer.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
skip: (query.page - 1) * query.perPage,
|
||||
take: query.perPage,
|
||||
select: listSelect,
|
||||
}),
|
||||
this.prisma.customer.count({ where }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Lifetime spend, one grouped query for the whole page.
|
||||
*
|
||||
* Not a per-row count: twenty customers would mean twenty round trips, and
|
||||
* the number is only useful *because* it appears next to every row.
|
||||
* Cancelled orders are excluded — money that was never taken is not spend.
|
||||
*/
|
||||
const totals = await this.prisma.order.groupBy({
|
||||
by: ['customerId'],
|
||||
where: {
|
||||
customerId: { in: rows.map((row) => row.id) },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
_sum: { totalAmount: true },
|
||||
});
|
||||
|
||||
const spendByCustomer = new Map(
|
||||
totals.map((total) => [total.customerId, total._sum.totalAmount ?? 0]),
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toAdmin(row, spendByCustomer.get(row.id) ?? 0)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdminCustomerDetail> {
|
||||
const row = await this.prisma.customer.findUnique({ where: { id }, select: listSelect });
|
||||
if (!row) throw AppException.notFound('Customer');
|
||||
|
||||
const [orders, addresses, spend] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: { customerId: id },
|
||||
orderBy: { placedAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
status: true,
|
||||
totalAmount: true,
|
||||
currency: true,
|
||||
placedAt: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.address.findMany({
|
||||
where: { customerId: id },
|
||||
orderBy: [{ isDefaultShipping: 'desc' }, { createdAt: 'desc' }],
|
||||
}),
|
||||
this.prisma.order.aggregate({
|
||||
where: { customerId: id, status: { not: 'CANCELLED' } },
|
||||
_sum: { totalAmount: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...this.toAdmin(row, spend._sum.totalAmount ?? 0),
|
||||
orders: orders.map((order) => ({
|
||||
id: order.id,
|
||||
orderNumber: `SP-${String(order.number).padStart(6, '0')}`,
|
||||
status: order.status,
|
||||
total: { amount: order.totalAmount, currency: order.currency },
|
||||
placedAt: order.placedAt.toISOString(),
|
||||
})),
|
||||
addresses: addresses.map((address) => ({
|
||||
id: address.id,
|
||||
fullName: address.fullName,
|
||||
phone: address.phone,
|
||||
line1: address.line1,
|
||||
line2: address.line2,
|
||||
ward: address.ward,
|
||||
district: address.district,
|
||||
province: address.province,
|
||||
countryCode: address.countryCode,
|
||||
postalCode: address.postalCode,
|
||||
isDefaultShipping: address.isDefaultShipping,
|
||||
isDefaultBilling: address.isDefaultBilling,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private toAdmin(row: Row, lifetimeSpend: number): AdminCustomer {
|
||||
const name = `${row.user.firstName ?? ''} ${row.user.lastName ?? ''}`.trim();
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.user.email,
|
||||
// Falls back to the email so a row is never a blank line in the table.
|
||||
displayName: name || row.user.email,
|
||||
phone: row.user.phone,
|
||||
acceptsMarketing: row.acceptsMarketing,
|
||||
note: row.note,
|
||||
orderCount: row._count.orders,
|
||||
addressCount: row._count.addresses,
|
||||
lifetimeSpend: { amount: lifetimeSpend, currency: 'VND' },
|
||||
lastLoginAt: row.user.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CustomersAdminController } from './customers-admin.controller';
|
||||
import { CustomersAdminService } from './customers-admin.service';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { CustomersService } from './customers.service';
|
||||
@@ -15,8 +17,8 @@ import { CustomersService } from './customers.service';
|
||||
* registration — the only place another module writes here.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, CustomersRepository],
|
||||
controllers: [CustomersController, CustomersAdminController],
|
||||
providers: [CustomersService, CustomersRepository, CustomersAdminService],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -107,6 +107,13 @@ export class ProductsAdminController {
|
||||
return this.service.setImages(id, body, actor.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_DELETE)
|
||||
@ApiOperation({ summary: 'Retire a product; it disappears from the storefront' })
|
||||
remove(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
|
||||
return this.service.remove(id, actor.userId);
|
||||
}
|
||||
|
||||
@Post(':id/status')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_PUBLISH)
|
||||
@ApiOperation({ summary: 'Publish, unpublish or archive' })
|
||||
|
||||
@@ -319,6 +319,43 @@ export class ProductsAdminService {
|
||||
return this.getById(productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires a product. Soft, and deliberately so.
|
||||
*
|
||||
* A hard delete is not merely risky here, it is mostly impossible:
|
||||
* `StockMovement.variant` is `onDelete: Restrict`, so Postgres refuses to
|
||||
* remove any variant that has ever moved stock — which is every variant that
|
||||
* has ever been sold. Where it *would* succeed, `OrderLine.variant` is
|
||||
* `SetNull`, so past orders would quietly lose their link back to the
|
||||
* catalog. The order lines keep their own snapshot (ADR-0018), so a customer
|
||||
* still sees what they bought, but every report joining orders to products
|
||||
* would start under-counting with no error to notice.
|
||||
*
|
||||
* So `deletedAt` is set instead. The eight `deletedAt: null` filters in the
|
||||
* products repository already make that invisible to the storefront, and the
|
||||
* row stays available to anything that legitimately still points at it.
|
||||
*/
|
||||
async remove(id: string, actorUserId: string): Promise<void> {
|
||||
const existing = await this.prisma.product.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw AppException.notFound('Product');
|
||||
|
||||
await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
// Archived as well as deleted: a soft-deleted row still marked ACTIVE
|
||||
// is one forgotten `deletedAt: null` away from being live again.
|
||||
status: 'ARCHIVED',
|
||||
publishedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.afterWrite(actorUserId, 'product.delete', id, {});
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
id: string,
|
||||
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
||||
|
||||
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,4 +1,11 @@
|
||||
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
|
||||
import type {
|
||||
AdminCustomer,
|
||||
AdminCustomerDetail,
|
||||
OffsetPaginated,
|
||||
PermissionGroup,
|
||||
RoleDetail,
|
||||
UserSummary,
|
||||
} from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
@@ -44,6 +51,13 @@ export interface AdminResource {
|
||||
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
|
||||
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
|
||||
|
||||
listCustomers(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
q?: string;
|
||||
}): Promise<OffsetPaginated<AdminCustomer>>;
|
||||
getCustomer(id: string): Promise<AdminCustomerDetail>;
|
||||
|
||||
listRoles(): Promise<RoleDetail[]>;
|
||||
getRole(id: string): Promise<RoleDetail>;
|
||||
listPermissions(): Promise<PermissionGroup[]>;
|
||||
@@ -73,6 +87,14 @@ export function createAdminResource(http: HttpClient): AdminResource {
|
||||
uncached,
|
||||
),
|
||||
|
||||
listCustomers: (params = {}) =>
|
||||
http.get<OffsetPaginated<AdminCustomer>>('/admin/customers', {
|
||||
...uncached,
|
||||
query: { ...params },
|
||||
}),
|
||||
getCustomer: (id) =>
|
||||
http.get<AdminCustomerDetail>(`/admin/customers/${encodeURIComponent(id)}`, uncached),
|
||||
|
||||
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
|
||||
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface CatalogAdminResource {
|
||||
images: { mediaId: string; position: number; optionValueId?: string | null }[],
|
||||
): Promise<AdminProductDetail>;
|
||||
setStatus(id: string, status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'): Promise<AdminProductDetail>;
|
||||
/** Soft delete — the row survives for order history, see the service. */
|
||||
deleteProduct(id: string): Promise<void>;
|
||||
|
||||
listMedia(params?: {
|
||||
page?: number;
|
||||
@@ -112,6 +114,7 @@ export function createCatalogAdminResource(http: HttpClient): CatalogAdminResour
|
||||
{ status },
|
||||
uncached,
|
||||
),
|
||||
deleteProduct: (id) => http.delete<void>(`/admin/products/${encodeURIComponent(id)}`, uncached),
|
||||
|
||||
listMedia: (params = {}) =>
|
||||
http.get<OffsetPaginated<MediaAssetSummary>>('/admin/media', {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Id, Money, Nullable } from '../primitives';
|
||||
|
||||
import type { CustomerAddress } from './account';
|
||||
|
||||
/** A shopper as the back office sees them. */
|
||||
export interface AdminCustomer {
|
||||
readonly id: Id;
|
||||
readonly email: string;
|
||||
readonly displayName: string;
|
||||
readonly phone: Nullable<string>;
|
||||
readonly acceptsMarketing: boolean;
|
||||
readonly note: Nullable<string>;
|
||||
readonly orderCount: number;
|
||||
readonly addressCount: number;
|
||||
/** Excludes cancelled orders — money never taken is not spend. */
|
||||
readonly lifetimeSpend: Money;
|
||||
readonly lastLoginAt: Nullable<string>;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminCustomerOrderSummary {
|
||||
readonly id: Id;
|
||||
readonly orderNumber: string;
|
||||
readonly status: string;
|
||||
readonly total: Money;
|
||||
readonly placedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminCustomerDetail extends AdminCustomer {
|
||||
readonly orders: readonly AdminCustomerOrderSummary[];
|
||||
readonly addresses: readonly CustomerAddress[];
|
||||
}
|
||||
@@ -32,3 +32,4 @@ export * from './commerce/order';
|
||||
export * from './commerce/review';
|
||||
export * from './content/entry';
|
||||
export * from './customers/account';
|
||||
export * from './customers/admin';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { phoneSchema } from './common';
|
||||
import { offsetPageQuerySchema } from './pagination';
|
||||
|
||||
/**
|
||||
* The shopper's own profile.
|
||||
@@ -40,3 +41,10 @@ export const wishlistItemSchema = z.object({
|
||||
export type UpdateProfileInput = z.output<typeof updateProfileSchema>;
|
||||
export type CustomerAddressInput = z.output<typeof customerAddressSchema>;
|
||||
export type WishlistItemInput = z.output<typeof wishlistItemSchema>;
|
||||
|
||||
/** Back-office customer search. */
|
||||
export const customerListQuerySchema = offsetPageQuerySchema.extend({
|
||||
q: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
export type CustomerListQuery = z.output<typeof customerListQuerySchema>;
|
||||
|
||||
Reference in New Issue
Block a user