796 lines
26 KiB
JavaScript
Executable File
796 lines
26 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Seed demo data into a running vmall-api (dev).
|
|
* Usage: node scripts/seed-demo.mjs [baseUrl]
|
|
* Idempotent: existing slugs/emails are reused, products are re-published,
|
|
* and an owner's role is only reassigned when it does not already point at
|
|
* the right shop.
|
|
*
|
|
* Categories are NOT created here - they are reference data seeded by the
|
|
* migrations, because the API exposes no category write route.
|
|
*/
|
|
const base = process.argv[2] ?? "http://localhost:8080/api";
|
|
|
|
async function call(method, path, { token, body } = {}) {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
data = text;
|
|
}
|
|
return { status: res.status, data };
|
|
}
|
|
|
|
const fail = (what, r) => {
|
|
console.error(`FAILED ${what}: ${r.status}`, r.data);
|
|
process.exit(1);
|
|
};
|
|
|
|
// 1. admin login
|
|
let r = await call("POST", "/auth/login", {
|
|
body: { email: "admin@vmall.local", password: "admin1234" },
|
|
});
|
|
if (r.status !== 200) fail("admin login", r);
|
|
const admin = r.data.token;
|
|
|
|
// 2. shops with their own owner accounts and public profiles
|
|
const SHOPS = [
|
|
{
|
|
slug: "demo-store",
|
|
name: { en: "Demo Store", zh: "演示店铺" },
|
|
owner: { email: "shop@vmall.local", password: "shop12345", displayName: "Demo Owner" },
|
|
profile: {
|
|
logo: "/mock/store-1.svg",
|
|
banner: "/mock/banner-1.svg",
|
|
company: "Demo Commerce Co., Ltd.",
|
|
region: "California",
|
|
address: { en: "1 Market Street, San Francisco", zh: "旧金山市场街 1 号" },
|
|
notice: { en: "Free shipping on orders over $99.", zh: "满 99 美元免运费。" },
|
|
after_sale: { en: "7-day returns, 1-year warranty.", zh: "支持 7 天退货,一年质保。" },
|
|
score_rating: 4.9,
|
|
score_agreement: 4.8,
|
|
score_service: 4.9,
|
|
score_speed: 4.7,
|
|
},
|
|
},
|
|
{
|
|
slug: "aurora-digital",
|
|
name: { en: "Aurora Digital", zh: "极光数码" },
|
|
owner: { email: "aurora@vmall.local", password: "shop12345", displayName: "Aurora Owner" },
|
|
profile: {
|
|
logo: "/mock/store-2.svg",
|
|
banner: "/mock/banner-2.svg",
|
|
company: "Aurora Technology Co., Ltd.",
|
|
region: "New York",
|
|
address: { en: "88 Hudson Yards, New York", zh: "纽约哈德逊城市广场 88 号" },
|
|
notice: { en: "Same-day dispatch before 3 PM.", zh: "下午 3 点前下单当天发货。" },
|
|
after_sale: {
|
|
en: "Nationwide warranty, support 9:00-21:00.",
|
|
zh: "全国联保,客服 9:00-21:00。",
|
|
},
|
|
score_rating: 4.7,
|
|
score_agreement: 4.6,
|
|
score_service: 4.8,
|
|
score_speed: 4.6,
|
|
},
|
|
},
|
|
{
|
|
slug: "nordwind-home",
|
|
name: { en: "Nordwind Home", zh: "北风家居" },
|
|
owner: { email: "nordwind@vmall.local", password: "shop12345", displayName: "Nordwind Owner" },
|
|
profile: {
|
|
logo: "/mock/store-3.svg",
|
|
banner: "/mock/banner-3.svg",
|
|
company: "Nordwind Trading LLC",
|
|
region: "Texas",
|
|
address: { en: "1200 Congress Avenue, Austin", zh: "奥斯汀国会大道 1200 号" },
|
|
notice: { en: "Free installation on large appliances.", zh: "大家电免费上门安装。" },
|
|
after_sale: { en: "30-day returns on unopened goods.", zh: "未拆封商品 30 天可退。" },
|
|
score_rating: 4.8,
|
|
score_agreement: 4.7,
|
|
score_service: 4.6,
|
|
score_speed: 4.8,
|
|
},
|
|
},
|
|
{
|
|
slug: "terra-grocery",
|
|
name: { en: "Terra Grocery", zh: "大地生鲜" },
|
|
owner: { email: "terra@vmall.local", password: "shop12345", displayName: "Terra Owner" },
|
|
profile: {
|
|
logo: "/mock/store-4.svg",
|
|
banner: "/mock/promo-1.svg",
|
|
company: "Terra Foods Inc.",
|
|
region: "Oregon",
|
|
address: { en: "45 Willamette Loop, Portland", zh: "波特兰威拉米特环路 45 号" },
|
|
notice: { en: "Cold-chain delivery, next-day slots.", zh: "全程冷链,次日达时段可选。" },
|
|
after_sale: {
|
|
en: "Fresh items refunded on delivery issues.",
|
|
zh: "生鲜配送问题可直接退款。",
|
|
},
|
|
score_rating: 4.6,
|
|
score_agreement: 4.5,
|
|
score_service: 4.7,
|
|
score_speed: 4.5,
|
|
},
|
|
},
|
|
];
|
|
|
|
/** Create the shop if missing and return a token for its owner. */
|
|
async function ensureShop(def) {
|
|
let shopId;
|
|
r = await call("POST", "/admin/shops", {
|
|
token: admin,
|
|
body: { name: def.name, slug: def.slug },
|
|
});
|
|
if (r.status === 201) shopId = r.data.id;
|
|
else if (r.status === 409) {
|
|
const shops = await call("GET", "/admin/shops", { token: admin });
|
|
shopId = shops.data.find((s) => s.slug === def.slug).id;
|
|
} else fail(`create shop ${def.slug}`, r);
|
|
|
|
// Upsert, so re-running the seed refreshes the profile rather than failing.
|
|
r = await call("PUT", `/admin/shops/${shopId}/profile`, {
|
|
token: admin,
|
|
body: def.profile,
|
|
});
|
|
if (r.status !== 200) fail(`shop profile ${def.slug}`, r);
|
|
|
|
await call("POST", "/auth/register", {
|
|
body: {
|
|
email: def.owner.email,
|
|
password: def.owner.password,
|
|
display_name: def.owner.displayName,
|
|
},
|
|
});
|
|
r = await call("POST", "/auth/login", {
|
|
body: { email: def.owner.email, password: def.owner.password },
|
|
});
|
|
if (r.status !== 200) fail(`owner login ${def.owner.email}`, r);
|
|
let token = r.data.token;
|
|
|
|
const ownsThisShop = r.data.user.role === "shop_owner" && r.data.user.shop_id === shopId;
|
|
if (!ownsThisShop) {
|
|
r = await call("PUT", `/admin/users/${r.data.user.id}/role`, {
|
|
token: admin,
|
|
body: { role: "shop_owner", shop_id: shopId },
|
|
});
|
|
if (r.status !== 200) fail(`assign owner ${def.slug}`, r);
|
|
r = await call("POST", "/auth/login", {
|
|
body: { email: def.owner.email, password: def.owner.password },
|
|
});
|
|
if (r.status !== 200) fail(`owner re-login ${def.owner.email}`, r);
|
|
token = r.data.token;
|
|
}
|
|
console.log(`shop ready: ${def.slug}`);
|
|
return token;
|
|
}
|
|
|
|
const ownerTokens = new Map();
|
|
for (const def of SHOPS) {
|
|
ownerTokens.set(def.slug, await ensureShop(def));
|
|
}
|
|
|
|
// 2b. shop coupons: deterministic, idempotent by localized title.
|
|
const DEMO_COUPONS = [
|
|
{
|
|
title: { en: "$5 off over $59", zh: "满 59 减 5" },
|
|
amount_minor: 500,
|
|
threshold_minor: 5900,
|
|
stock: 200,
|
|
},
|
|
{
|
|
title: { en: "$15 off over $199", zh: "满 199 减 15" },
|
|
amount_minor: 1500,
|
|
threshold_minor: 19900,
|
|
stock: 100,
|
|
},
|
|
];
|
|
let demoShopId = "";
|
|
{
|
|
const owner = ownerTokens.get("demo-store");
|
|
const profile = await call("GET", "/shop/profile", { token: owner });
|
|
if (profile.status !== 200) fail("coupon shop profile", profile);
|
|
demoShopId = profile.data.id;
|
|
|
|
const listed = await call("GET", "/shop/coupon-templates", { token: owner });
|
|
if (listed.status !== 200) fail("list coupon templates", listed);
|
|
const known = new Set(listed.data.map((t) => t.title.en));
|
|
const starts_at = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
|
const ends_at = new Date(Date.now() + 365 * 24 * 3600 * 1000).toISOString();
|
|
for (const coupon of DEMO_COUPONS) {
|
|
if (known.has(coupon.title.en)) continue;
|
|
const res = await call("POST", "/shop/coupon-templates", {
|
|
token: owner,
|
|
body: { ...coupon, currency: "USD", enabled: true, starts_at, ends_at },
|
|
});
|
|
if (res.status !== 201) fail(`seed coupon ${coupon.title.en}`, res);
|
|
}
|
|
console.log(`coupons ready: ${DEMO_COUPONS.length}`);
|
|
}
|
|
|
|
// 3. demo customer
|
|
await call("POST", "/auth/register", {
|
|
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
|
|
});
|
|
{
|
|
r = await call("POST", "/auth/login", {
|
|
body: { email: "customer@vmall.local", password: "customer123" },
|
|
});
|
|
if (r.status !== 200) fail("customer login", r);
|
|
const customerToken = r.data.token;
|
|
const existing = (await call("GET", "/addresses", { token: customerToken })).data;
|
|
if (Array.isArray(existing) && existing.length === 0) {
|
|
const demoAddresses = [
|
|
{
|
|
recipient: "Demo Customer",
|
|
phone: "+1 555 0100",
|
|
country: "US",
|
|
region: "California",
|
|
city: "Cupertino",
|
|
line1: "1 Infinite Loop",
|
|
postal_code: "95014",
|
|
is_default: true,
|
|
},
|
|
{
|
|
recipient: "Demo Customer",
|
|
phone: "+1 555 0100",
|
|
country: "US",
|
|
region: "New York",
|
|
city: "New York",
|
|
line1: "88 Fifth Ave",
|
|
postal_code: "10011",
|
|
},
|
|
];
|
|
for (const address of demoAddresses) {
|
|
r = await call("POST", "/addresses", { token: customerToken, body: address });
|
|
if (r.status !== 200 && r.status !== 201) fail("seed address", r);
|
|
}
|
|
}
|
|
|
|
// Claim the demo shop's coupons so the buyer starts with live ones.
|
|
const claimable = (await call("GET", `/shops/${demoShopId}/coupon-templates`)).data;
|
|
if (Array.isArray(claimable)) {
|
|
for (const template of claimable) {
|
|
const res = await call("POST", "/me/coupons", {
|
|
token: customerToken,
|
|
body: { template_id: template.id },
|
|
});
|
|
// 409 means a previous seed run already claimed it.
|
|
if (res.status !== 201 && res.status !== 409) fail(`claim coupon ${template.id}`, res);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. categories (reference data from the migrations)
|
|
const cats = (await call("GET", "/categories")).data;
|
|
const catBy = (slug) => cats.find((c) => c.slug === slug)?.id ?? null;
|
|
if (cats.length < 7) {
|
|
console.error(`expected the seeded category tree, found ${cats.length} categories`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// 5. products: 24 across 4 shops, covering all six top-level categories so
|
|
// every home floor is non-empty.
|
|
const products = [
|
|
// demo-store
|
|
{
|
|
shop: "demo-store",
|
|
slug: "wireless-headphones",
|
|
category: "electronics-audio",
|
|
price: 7999,
|
|
stock: 25,
|
|
name: { en: "Wireless Headphones", zh: "无线耳机" },
|
|
description: {
|
|
en: "Noise-cancelling over-ear headphones with 40h battery.",
|
|
zh: "降噪头戴式耳机,40 小时续航。",
|
|
},
|
|
img: "headphones",
|
|
},
|
|
{
|
|
shop: "demo-store",
|
|
slug: "mechanical-keyboard",
|
|
category: "computers-keyboards",
|
|
price: 12900,
|
|
stock: 40,
|
|
name: { en: "Mechanical Keyboard", zh: "机械键盘" },
|
|
description: {
|
|
en: "Hot-swappable 75% keyboard, gasket mount.",
|
|
zh: "热插拔 75% 配列机械键盘,Gasket 结构。",
|
|
},
|
|
img: "keyboard",
|
|
},
|
|
{
|
|
shop: "demo-store",
|
|
slug: "linen-shirt",
|
|
category: "fashion-shirts",
|
|
price: 4599,
|
|
stock: 60,
|
|
name: { en: "Linen Shirt", zh: "亚麻衬衫" },
|
|
description: { en: "Breathable 100% linen shirt.", zh: "透气纯亚麻衬衫。" },
|
|
img: "shirt",
|
|
},
|
|
{
|
|
shop: "demo-store",
|
|
slug: "ceramic-mug",
|
|
category: "home-cookers",
|
|
price: 1999,
|
|
stock: 100,
|
|
name: { en: "Ceramic Mug", zh: "陶瓷马克杯" },
|
|
description: { en: "Hand-glazed 350ml mug.", zh: "手工上釉 350ml 马克杯。" },
|
|
img: "mug",
|
|
},
|
|
{
|
|
shop: "demo-store",
|
|
slug: "canvas-backpack",
|
|
category: "fashion-backpacks",
|
|
price: 3999,
|
|
stock: 45,
|
|
name: { en: "Canvas Backpack", zh: "帆布双肩包" },
|
|
description: { en: "Water-resistant 22L everyday backpack.", zh: "防泼水 22L 通勤双肩包。" },
|
|
img: "backpack",
|
|
},
|
|
{
|
|
shop: "demo-store",
|
|
slug: "merino-sweater",
|
|
category: "fashion-menswear",
|
|
price: 6900,
|
|
stock: 30,
|
|
name: { en: "Merino Sweater", zh: "美利奴羊毛衫" },
|
|
description: { en: "Fine-gauge merino crew neck.", zh: "细针美利奴圆领毛衣。" },
|
|
img: "sweater",
|
|
},
|
|
|
|
// aurora-digital
|
|
{
|
|
shop: "aurora-digital",
|
|
slug: "aurora-x1-pro",
|
|
category: "electronics-flagship",
|
|
price: 99900,
|
|
stock: 12,
|
|
name: { en: "Aurora X1 Pro", zh: "Aurora X1 Pro 旗舰手机" },
|
|
description: {
|
|
en: "6.7in flagship with triple camera and 120Hz display.",
|
|
zh: "6.7 英寸三摄旗舰,120Hz 屏幕。",
|
|
},
|
|
img: "aurora-x1",
|
|
},
|
|
{
|
|
shop: "aurora-digital",
|
|
slug: "aurora-a3",
|
|
category: "electronics-budget",
|
|
price: 19900,
|
|
stock: 60,
|
|
name: { en: "Aurora A3", zh: "Aurora A3 手机" },
|
|
description: {
|
|
en: "Everyday 5G phone with 5000mAh battery.",
|
|
zh: "日常 5G 手机,5000mAh 电池。",
|
|
},
|
|
img: "aurora-a3",
|
|
},
|
|
{
|
|
shop: "aurora-digital",
|
|
slug: "aurora-buds-air",
|
|
category: "electronics-earbuds",
|
|
price: 12900,
|
|
stock: 80,
|
|
name: { en: "Aurora Buds Air", zh: "Aurora Buds Air 耳机" },
|
|
description: {
|
|
en: "Active noise cancelling true wireless earbuds.",
|
|
zh: "主动降噪真无线耳机。",
|
|
},
|
|
img: "aurora-buds",
|
|
},
|
|
{
|
|
shop: "aurora-digital",
|
|
slug: "aurora-boom-speaker",
|
|
category: "electronics-speakers",
|
|
price: 8900,
|
|
stock: 35,
|
|
name: { en: "Aurora Boom Speaker", zh: "Aurora Boom 音箱" },
|
|
description: {
|
|
en: "Portable IPX7 speaker with 20h playback.",
|
|
zh: "IPX7 防水便携音箱,20 小时播放。",
|
|
},
|
|
img: "aurora-boom",
|
|
},
|
|
{
|
|
shop: "aurora-digital",
|
|
slug: "aurora-ultrabook-14",
|
|
category: "computers-ultrabooks",
|
|
price: 129900,
|
|
stock: 18,
|
|
name: { en: "Aurora Ultrabook 14", zh: "Aurora 轻薄本 14" },
|
|
description: {
|
|
en: "1.1kg magnesium chassis with 14in OLED panel.",
|
|
zh: "1.1kg 镁合金机身,14 英寸 OLED 屏。",
|
|
},
|
|
img: "aurora-ultrabook",
|
|
},
|
|
{
|
|
shop: "aurora-digital",
|
|
slug: "aurora-27-monitor",
|
|
category: "computers-monitors",
|
|
price: 34900,
|
|
stock: 22,
|
|
name: { en: "Aurora 27 Monitor", zh: "Aurora 27 英寸显示器" },
|
|
description: {
|
|
en: "27in 4K USB-C monitor with 90W power delivery.",
|
|
zh: "27 英寸 4K USB-C 显示器,90W 反向供电。",
|
|
},
|
|
img: "aurora-monitor",
|
|
},
|
|
|
|
// nordwind-home
|
|
{
|
|
shop: "nordwind-home",
|
|
slug: "nordwind-rice-cooker",
|
|
category: "home-cookers",
|
|
price: 8900,
|
|
stock: 50,
|
|
name: { en: "Nordwind Rice Cooker", zh: "北风智能电饭煲" },
|
|
description: { en: "IH rice cooker with 12 presets.", zh: "IH 电磁加热,12 种预设菜单。" },
|
|
img: "nordwind-cooker",
|
|
},
|
|
{
|
|
shop: "nordwind-home",
|
|
slug: "nordwind-blender-pro",
|
|
category: "home-blenders",
|
|
price: 15900,
|
|
stock: 28,
|
|
name: { en: "Nordwind Blender Pro", zh: "北风破壁机 Pro" },
|
|
description: {
|
|
en: "1400W high-speed blender with vacuum jar.",
|
|
zh: "1400W 高速破壁机,配真空杯。",
|
|
},
|
|
img: "nordwind-blender",
|
|
},
|
|
{
|
|
shop: "nordwind-home",
|
|
slug: "nordwind-stick-vacuum",
|
|
category: "home-vacuums",
|
|
price: 24900,
|
|
stock: 24,
|
|
name: { en: "Nordwind Stick Vacuum", zh: "北风无线吸尘器" },
|
|
description: {
|
|
en: "Cordless vacuum with 60min runtime.",
|
|
zh: "无线手持吸尘器,续航 60 分钟。",
|
|
},
|
|
img: "nordwind-vacuum",
|
|
},
|
|
{
|
|
shop: "nordwind-home",
|
|
slug: "nordwind-air-purifier",
|
|
category: "home-purifiers",
|
|
price: 32900,
|
|
stock: 20,
|
|
name: { en: "Nordwind Air Purifier", zh: "北风空气净化器" },
|
|
description: {
|
|
en: "HEPA 13 purifier covering 60 square metres.",
|
|
zh: "HEPA 13 滤网,适用 60 平方米。",
|
|
},
|
|
img: "nordwind-purifier",
|
|
},
|
|
{
|
|
shop: "nordwind-home",
|
|
slug: "nordwind-serum-c",
|
|
category: "beauty-serums",
|
|
price: 4900,
|
|
stock: 70,
|
|
name: { en: "Nordwind Vitamin C Serum", zh: "北风维C精华" },
|
|
description: { en: "15% vitamin C brightening serum.", zh: "15% 维C 提亮精华。" },
|
|
img: "nordwind-serum",
|
|
},
|
|
{
|
|
shop: "nordwind-home",
|
|
slug: "nordwind-shave-9000",
|
|
category: "beauty-shavers",
|
|
price: 7900,
|
|
stock: 40,
|
|
name: { en: "Nordwind Shaver 9000", zh: "北风剃须刀 9000" },
|
|
description: {
|
|
en: "Wet and dry rotary shaver with travel case.",
|
|
zh: "干湿两用旋转剃须刀,含旅行盒。",
|
|
},
|
|
img: "nordwind-shaver",
|
|
},
|
|
|
|
// terra-grocery
|
|
{
|
|
shop: "terra-grocery",
|
|
slug: "terra-mixed-nuts",
|
|
category: "grocery-nuts",
|
|
price: 1599,
|
|
stock: 200,
|
|
name: { en: "Terra Mixed Nuts", zh: "大地混合坚果" },
|
|
description: { en: "Roasted unsalted nut mix, 500g.", zh: "烘烤无盐混合坚果,500g。" },
|
|
img: "terra-nuts",
|
|
},
|
|
{
|
|
shop: "terra-grocery",
|
|
slug: "terra-dark-chocolate",
|
|
category: "grocery-chocolate",
|
|
price: 1299,
|
|
stock: 180,
|
|
name: { en: "Terra Dark Chocolate", zh: "大地黑巧克力" },
|
|
description: { en: "72% single-origin dark chocolate.", zh: "72% 单一产地黑巧克力。" },
|
|
img: "terra-chocolate",
|
|
},
|
|
{
|
|
shop: "terra-grocery",
|
|
slug: "terra-orchard-apples",
|
|
category: "grocery-fruit",
|
|
price: 899,
|
|
stock: 300,
|
|
name: { en: "Terra Orchard Apples", zh: "大地果园苹果" },
|
|
description: { en: "Crisp orchard apples, 1kg box.", zh: "脆甜果园苹果,1kg 装。" },
|
|
img: "terra-apples",
|
|
},
|
|
{
|
|
shop: "terra-grocery",
|
|
slug: "terra-organic-kale",
|
|
category: "grocery-vegetables",
|
|
price: 699,
|
|
stock: 260,
|
|
name: { en: "Terra Organic Kale", zh: "大地有机羽衣甘蓝" },
|
|
description: { en: "Certified organic kale, 400g.", zh: "有机认证羽衣甘蓝,400g。" },
|
|
img: "terra-kale",
|
|
},
|
|
{
|
|
shop: "terra-grocery",
|
|
slug: "terra-carry-on",
|
|
category: "fashion-luggage",
|
|
price: 42900,
|
|
stock: 16,
|
|
name: { en: "Terra Carry-On", zh: "大地登机箱" },
|
|
description: {
|
|
en: "Polycarbonate carry-on with TSA lock.",
|
|
zh: "聚碳酸酯登机箱,TSA 密码锁。",
|
|
},
|
|
img: "terra-carryon",
|
|
},
|
|
{
|
|
shop: "terra-grocery",
|
|
slug: "terra-leather-tote",
|
|
category: "fashion-bags",
|
|
price: 21900,
|
|
stock: 26,
|
|
name: { en: "Terra Leather Tote", zh: "大地真皮托特包" },
|
|
description: {
|
|
en: "Full-grain leather tote with laptop sleeve.",
|
|
zh: "头层牛皮托特包,含电脑隔层。",
|
|
},
|
|
img: "terra-tote",
|
|
},
|
|
];
|
|
|
|
// 5b. brands (admin-managed reference data), then assign one per product
|
|
const BRANDS = [
|
|
{ slug: "aurora", name: { en: "Aurora", zh: "极光" } },
|
|
{ slug: "nordwind", name: { en: "Nordwind", zh: "北风" } },
|
|
{ slug: "hexon", name: { en: "Hexon", zh: "赫克森" } },
|
|
{ slug: "mikado", name: { en: "Mikado", zh: "御门" } },
|
|
{ slug: "solace", name: { en: "Solace", zh: "索莱斯" } },
|
|
{ slug: "terra", name: { en: "Terra", zh: "大地" } },
|
|
];
|
|
r = await call("PUT", "/admin/brands", { token: admin, body: BRANDS });
|
|
if (r.status !== 200) fail("replace brands", r);
|
|
// Captured, not read back through `r`: the product loop reassigns `r`.
|
|
const brandList = r.data;
|
|
const brandBy = (slug) => brandList.find((b) => b.slug === slug)?.id ?? null;
|
|
const SHOP_BRAND = {
|
|
"demo-store": "solace",
|
|
"aurora-digital": "aurora",
|
|
"nordwind-home": "nordwind",
|
|
"terra-grocery": "terra",
|
|
};
|
|
// A couple of deliberate exceptions so more than four brands are in use.
|
|
const PRODUCT_BRAND = { "mechanical-keyboard": "hexon", "terra-leather-tote": "mikado" };
|
|
console.log(`brands ready: ${r.data.length}`);
|
|
|
|
for (const p of products) {
|
|
const token = ownerTokens.get(p.shop);
|
|
const body = {
|
|
slug: p.slug,
|
|
name: p.name,
|
|
description: p.description,
|
|
category_id: catBy(p.category),
|
|
brand_id: brandBy(PRODUCT_BRAND[p.slug] ?? SHOP_BRAND[p.shop]),
|
|
images: [`https://picsum.photos/seed/${p.img}/600/600`],
|
|
};
|
|
r = await call("POST", "/shop/products", { token, body });
|
|
let id;
|
|
if (r.status === 201) id = r.data.id;
|
|
else if (r.status === 409) {
|
|
const list = await call("GET", "/shop/products?per_page=100", { token });
|
|
id = list.data.items.find((x) => x.slug === p.slug)?.id;
|
|
if (!id) fail(`lookup product ${p.slug}`, r);
|
|
// Re-apply the body so a re-run converges the brand assignment too.
|
|
r = await call("PUT", `/shop/products/${id}`, { token, body });
|
|
if (r.status !== 200) fail(`update product ${p.slug}`, r);
|
|
} else fail(`create product ${p.slug}`, r);
|
|
|
|
r = await call("POST", `/shop/products/${id}/skus`, {
|
|
token,
|
|
body: {
|
|
sku_code: `${p.slug}-std`,
|
|
price_minor: p.price,
|
|
currency: "USD",
|
|
stock: p.stock,
|
|
},
|
|
});
|
|
if (r.status !== 200) fail(`sku ${p.slug}`, r);
|
|
r = await call("POST", `/shop/products/${id}/publish`, { token });
|
|
if (r.status !== 200) fail(`publish ${p.slug}`, r);
|
|
}
|
|
console.log(`products ready: ${products.length} across ${SHOPS.length} shops`);
|
|
|
|
// 5c. deterministic customer favorites (idempotent PUTs).
|
|
{
|
|
r = await call("POST", "/auth/login", {
|
|
body: { email: "customer@vmall.local", password: "customer123" },
|
|
});
|
|
if (r.status !== 200) fail("customer login for favorites", r);
|
|
const customerToken = r.data.token;
|
|
const listed = await call("GET", `/products?shop_id=${demoShopId}&per_page=100`);
|
|
const headphones = Array.isArray(listed.data?.items)
|
|
? listed.data.items.find((p) => p.slug === "wireless-headphones")
|
|
: null;
|
|
if (!headphones?.id) fail("lookup wireless-headphones", listed);
|
|
r = await call("PUT", `/favorites/products/${headphones.id}`, { token: customerToken });
|
|
if (r.status !== 200) fail("seed product favorite", r);
|
|
r = await call("PUT", `/favorites/shops/${demoShopId}`, { token: customerToken });
|
|
if (r.status !== 200) fail("seed shop favorite", r);
|
|
console.log("favorites ready: wireless-headphones + demo-store");
|
|
}
|
|
|
|
// 6. points mall products (idempotent by English name). The demo customer's
|
|
// spendable points are credited by the API through the account ledger.
|
|
const POINTS_PRODUCTS = [
|
|
{
|
|
name: { en: "Insulated Mug", zh: "保温杯" },
|
|
image: "/mock/product-9.svg",
|
|
points_price: 1200,
|
|
stock: 300,
|
|
},
|
|
{
|
|
name: { en: "Canvas Tote", zh: "帆布手提袋" },
|
|
image: "/mock/product-15.svg",
|
|
points_price: 600,
|
|
stock: 500,
|
|
},
|
|
{
|
|
name: { en: "Phone Stand", zh: "手机支架" },
|
|
image: "/mock/product-7.svg",
|
|
points_price: 400,
|
|
stock: 800,
|
|
},
|
|
{
|
|
name: { en: "Cable Organizer Set", zh: "数据线收纳套装" },
|
|
image: "/mock/product-3.svg",
|
|
points_price: 300,
|
|
stock: 1000,
|
|
},
|
|
{
|
|
name: { en: "Notebook A5", zh: "A5 笔记本" },
|
|
image: "/mock/product-14.svg",
|
|
points_price: 250,
|
|
stock: 1200,
|
|
},
|
|
];
|
|
{
|
|
const listed = await call("GET", "/admin/points/products", { token: admin });
|
|
if (listed.status !== 200) fail("list points products", listed);
|
|
const known = new Set(listed.data.map((p) => p.name.en));
|
|
let created = 0;
|
|
for (const [index, product] of POINTS_PRODUCTS.entries()) {
|
|
if (known.has(product.name.en)) continue;
|
|
const res = await call("POST", "/admin/points/products", {
|
|
token: admin,
|
|
body: { ...product, position: index, published: true, recommend: index < 2 },
|
|
});
|
|
if (res.status !== 201) fail(`seed points product ${product.name.en}`, res);
|
|
created += 1;
|
|
}
|
|
console.log(`points products ready: ${POINTS_PRODUCTS.length} (${created} new)`);
|
|
}
|
|
|
|
// 7. an active flash sale for the demo shop (idempotent by English label).
|
|
{
|
|
const owner = ownerTokens.get("demo-store");
|
|
const listed = await call("GET", "/shop/flash-sales", { token: owner });
|
|
if (listed.status !== 200) fail("list flash sales", listed);
|
|
const known = new Set(listed.data.map((s) => s.label.en));
|
|
if (!known.has("Weekend flash sale")) {
|
|
const products = await call("GET", "/shop/products?per_page=100", { token: owner });
|
|
if (products.status !== 200) fail("list shop products", products);
|
|
const sku = (products.data.items ?? [])
|
|
.flatMap((p) => p.skus ?? [])
|
|
.find((s) => s.active && s.stock > 0);
|
|
if (!sku) fail("find a flash-sale SKU", { status: 0, data: "no active SKU" });
|
|
|
|
const now = Date.now();
|
|
const session = await call("POST", "/shop/flash-sales", {
|
|
token: owner,
|
|
body: {
|
|
label: { en: "Weekend flash sale", zh: "周末秒杀" },
|
|
starts_at: new Date(now - 3600 * 1000).toISOString(),
|
|
ends_at: new Date(now + 7 * 24 * 3600 * 1000).toISOString(),
|
|
enabled: true,
|
|
},
|
|
});
|
|
if (session.status !== 201) fail("seed flash session", session);
|
|
|
|
const item = await call("POST", `/shop/flash-sales/${session.data.id}/items`, {
|
|
token: owner,
|
|
body: {
|
|
sku_id: sku.id,
|
|
sale_price_minor: Math.max(1, Math.round(sku.price_minor * 0.6)),
|
|
currency: sku.currency,
|
|
reserved_stock: 20,
|
|
per_customer_limit: 2,
|
|
},
|
|
});
|
|
if (item.status !== 201) fail("seed flash item", item);
|
|
}
|
|
console.log("flash sale ready");
|
|
}
|
|
|
|
// 8. an active group-buying activity for the demo shop (idempotent by name).
|
|
{
|
|
const owner = ownerTokens.get("demo-store");
|
|
const listed = await call("GET", "/shop/group-buying-activities", { token: owner });
|
|
if (listed.status !== 200) fail("list group activities", listed);
|
|
const known = new Set(listed.data.map((a) => a.name.en));
|
|
if (!known.has("Weekend group deal")) {
|
|
const products = await call("GET", "/shop/products?per_page=100", { token: owner });
|
|
if (products.status !== 200) fail("list shop products", products);
|
|
const flash = await call("GET", "/shop/flash-sales", { token: owner });
|
|
const flashSkus = new Set(
|
|
(flash.data ?? []).flatMap((s) => s.items ?? []).map((i) => i.sku_id),
|
|
);
|
|
const used = new Set(listed.data.map((a) => a.sku_id));
|
|
// A SKU already in an overlapping flash sale cannot join a group activity.
|
|
const sku = (products.data.items ?? [])
|
|
.flatMap((p) => p.skus ?? [])
|
|
.find((s) => s.active && s.stock > 0 && !flashSkus.has(s.id) && !used.has(s.id));
|
|
if (!sku) fail("find a group-buying SKU", { status: 0, data: "no free SKU" });
|
|
|
|
const now = Date.now();
|
|
const res = await call("POST", "/shop/group-buying-activities", {
|
|
token: owner,
|
|
body: {
|
|
sku_id: sku.id,
|
|
name: { en: "Weekend group deal", zh: "周末拼团" },
|
|
description: {
|
|
en: "Gather two friends to unlock the group price.",
|
|
zh: "两人成团,享拼团价。",
|
|
},
|
|
group_price_minor: Math.max(1, Math.round(sku.price_minor * 0.75)),
|
|
currency: sku.currency,
|
|
required_members: 2,
|
|
starts_at: new Date(now - 3600 * 1000).toISOString(),
|
|
ends_at: new Date(now + 7 * 24 * 3600 * 1000).toISOString(),
|
|
group_lifetime_hours: 24,
|
|
enabled: true,
|
|
},
|
|
});
|
|
if (res.status !== 201) fail("seed group activity", res);
|
|
}
|
|
console.log("group buying ready");
|
|
}
|
|
|
|
console.log("\nDemo data seeded.");
|
|
console.log(" platform admin : admin@vmall.local / admin1234 (apps/admin :3002)");
|
|
console.log(" shop owner : shop@vmall.local / shop12345 (apps/shop-admin :3001)");
|
|
console.log(" customer : customer@vmall.local / customer123 (apps/mall :3000)");
|
|
console.log(" extra owners : aurora@ / nordwind@ / terra@vmall.local (password shop12345)");
|