add more data

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 8fd23d162f
commit 57f44796ce
20 changed files with 1436 additions and 18 deletions
+228
View File
@@ -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);
+497
View File
@@ -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,
};
}