The product page loads a shop's claimable coupons through the shared client and claims them in place; the buyer coupon list reads owned snapshots with their claimed/redeemed/expired state. Checkout offers each shop order at most one owned coupon and submits only the choice; the pay page and order detail render the server-persisted discount and reduced total, never a client figure. Coupon fixtures leave the pages; the fixed-data adapter still serves the coupon domain as the rollback path. The demo seed creates two deterministic templates and claims them for the demo customer.
350 lines
19 KiB
JavaScript
Executable File
350 lines
19 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`);
|
|
|
|
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)");
|