Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.
- a `shop_profiles` table beside `shops`, so the identity model both consoles
consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
invented; the pages guard every block, and a missing logo renders an
initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
`distanceKm` and its sort (no geo model), the store home's sales/comments
sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
and re-exported, so live pages stop importing the mock module for a pure
helper
Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.
Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.
OpenSpec change: openspec/changes/replace-mock-api-wave-5
844 lines
33 KiB
TypeScript
844 lines
33 KiB
TypeScript
// Fixed bilingual mock content for the mall MVP. No network, deterministic.
|
||
// Money is always integer minor units + currency code (base USD).
|
||
|
||
import type {
|
||
Address,
|
||
Category,
|
||
Invoice,
|
||
LocalizedText,
|
||
Order,
|
||
Product,
|
||
Shipment,
|
||
Sku,
|
||
User,
|
||
} from "@vmall/shared";
|
||
|
||
export const BASE_CURRENCY = "USD";
|
||
|
||
const L = (en: string, zh: string): LocalizedText => ({ en, zh });
|
||
|
||
// ---------- mall-local mock types ----------
|
||
|
||
export interface MockStore {
|
||
id: string;
|
||
slug: string;
|
||
name: LocalizedText;
|
||
company: string;
|
||
logo: string;
|
||
banner: string;
|
||
region: string;
|
||
address: LocalizedText;
|
||
distanceKm: number;
|
||
rate: { score: number; agree: number; service: number; speed: number };
|
||
notice: LocalizedText;
|
||
afterSale: LocalizedText;
|
||
}
|
||
|
||
export interface MockBanner {
|
||
image: string;
|
||
url: string;
|
||
}
|
||
|
||
export interface MockQuickLink {
|
||
label: LocalizedText;
|
||
url: string;
|
||
glyph: string; // inline SVG path data, 24x24 viewBox
|
||
}
|
||
|
||
export interface MockPromo {
|
||
image: string;
|
||
url: string;
|
||
}
|
||
|
||
export interface MockComment {
|
||
id: string;
|
||
productId: string;
|
||
author: string;
|
||
avatar: string;
|
||
rating: number; // 1-5
|
||
content: LocalizedText;
|
||
images: string[];
|
||
reply: LocalizedText | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface MockCoupon {
|
||
id: string;
|
||
title: LocalizedText;
|
||
amountMinor: number;
|
||
thresholdMinor: number;
|
||
currency: string;
|
||
expiresAt: string;
|
||
}
|
||
|
||
export interface MockAddress {
|
||
id: string;
|
||
recipient: string;
|
||
phone: string;
|
||
region: string;
|
||
city: string;
|
||
line1: string;
|
||
postalCode: string;
|
||
isDefault: boolean;
|
||
}
|
||
|
||
export interface MockFavorite {
|
||
id: string;
|
||
kind: "product" | "store";
|
||
refId: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface SeckillSession {
|
||
startHour: number;
|
||
endHour: number;
|
||
label: string;
|
||
}
|
||
|
||
export interface IntegralProduct {
|
||
id: string;
|
||
name: LocalizedText;
|
||
image: string;
|
||
points: number;
|
||
marketPriceMinor: number;
|
||
currency: string;
|
||
stock: number;
|
||
}
|
||
|
||
export interface ProductDetail {
|
||
product: Product;
|
||
store: MockStore;
|
||
comments: MockComment[];
|
||
commentStats: { all: number; good: number; medium: number; bad: number; goodRate: number };
|
||
coupons: MockCoupon[];
|
||
salesRank: Product[];
|
||
}
|
||
|
||
export interface StoreDetail {
|
||
store: MockStore;
|
||
products: Product[];
|
||
salesRank: Product[];
|
||
}
|
||
|
||
// ---------- currencies ----------
|
||
|
||
export const MOCK_CURRENCIES = [
|
||
{ code: "USD", name: L("US Dollar", "美元"), symbol: "$", exponent: 2, is_base: true, rate_to_base: "1", enabled: true },
|
||
{ code: "CNY", name: L("Chinese Yuan", "人民币"), symbol: "¥", exponent: 2, is_base: false, rate_to_base: "0.14", enabled: true },
|
||
{ code: "JPY", name: L("Japanese Yen", "日元"), symbol: "¥", exponent: 0, is_base: false, rate_to_base: "0.0067", enabled: true },
|
||
{ code: "EUR", name: L("Euro", "欧元"), symbol: "€", exponent: 2, is_base: false, rate_to_base: "1.09", enabled: true },
|
||
];
|
||
|
||
// units of currency per 1 USD, scaled by 1e4 (integer math only)
|
||
const PER_USD: Record<string, number> = { USD: 10000, CNY: 72000, JPY: 1500000, EUR: 9200 };
|
||
|
||
export function mockConvertMinor(amountMinor: number, from: string, to: string): number {
|
||
if (from === to) return amountMinor;
|
||
const fromRate = PER_USD[from];
|
||
const toRate = PER_USD[to];
|
||
if (!fromRate || !toRate) throw new Error(`mock convert: unknown currency ${from}/${to}`);
|
||
return Math.round((amountMinor * toRate) / fromRate);
|
||
}
|
||
|
||
// ---------- categories (3-level tree) ----------
|
||
|
||
interface CatSpec {
|
||
id: string;
|
||
slug: string;
|
||
name: LocalizedText;
|
||
children?: CatSpec[];
|
||
}
|
||
|
||
const CAT_TREE: CatSpec[] = [
|
||
{
|
||
id: "c1",
|
||
slug: "phones",
|
||
name: L("Phones & Digital", "手机数码"),
|
||
children: [
|
||
{
|
||
id: "c1-1",
|
||
slug: "smartphones",
|
||
name: L("Smartphones", "智能手机"),
|
||
children: [
|
||
{ id: "c1-1-1", slug: "flagship", name: L("Flagship", "旗舰机型") },
|
||
{ id: "c1-1-2", slug: "budget", name: L("Budget", "千元机") },
|
||
],
|
||
},
|
||
{
|
||
id: "c1-2",
|
||
slug: "audio",
|
||
name: L("Audio", "影音娱乐"),
|
||
children: [
|
||
{ id: "c1-2-1", slug: "earbuds", name: L("Earbuds", "真无线耳机") },
|
||
{ id: "c1-2-2", slug: "speakers", name: L("Speakers", "蓝牙音箱") },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "c2",
|
||
slug: "computers",
|
||
name: L("Computers & Office", "电脑办公"),
|
||
children: [
|
||
{
|
||
id: "c2-1",
|
||
slug: "laptops",
|
||
name: L("Laptops", "笔记本电脑"),
|
||
children: [
|
||
{ id: "c2-1-1", slug: "ultrabook", name: L("Ultrabooks", "轻薄本") },
|
||
{ id: "c2-1-2", slug: "gaming", name: L("Gaming Laptops", "游戏本") },
|
||
],
|
||
},
|
||
{
|
||
id: "c2-2",
|
||
slug: "peripherals",
|
||
name: L("Peripherals", "外设产品"),
|
||
children: [
|
||
{ id: "c2-2-1", slug: "keyboards", name: L("Keyboards", "键盘") },
|
||
{ id: "c2-2-2", slug: "monitors", name: L("Monitors", "显示器") },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "c3",
|
||
slug: "appliances",
|
||
name: L("Home Appliances", "家用电器"),
|
||
children: [
|
||
{
|
||
id: "c3-1",
|
||
slug: "kitchen",
|
||
name: L("Kitchen", "厨房电器"),
|
||
children: [
|
||
{ id: "c3-1-1", slug: "cookers", name: L("Cookers", "电饭煲") },
|
||
{ id: "c3-1-2", slug: "blenders", name: L("Blenders", "破壁机") },
|
||
],
|
||
},
|
||
{
|
||
id: "c3-2",
|
||
slug: "cleaning",
|
||
name: L("Cleaning", "清洁电器"),
|
||
children: [
|
||
{ id: "c3-2-1", slug: "vacuums", name: L("Vacuums", "吸尘器") },
|
||
{ id: "c3-2-2", slug: "purifiers", name: L("Air Purifiers", "空气净化器") },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "c4",
|
||
slug: "fashion",
|
||
name: L("Fashion", "服饰鞋包"),
|
||
children: [
|
||
{
|
||
id: "c4-1",
|
||
slug: "menswear",
|
||
name: L("Menswear", "男装"),
|
||
children: [
|
||
{ id: "c4-1-1", slug: "jackets", name: L("Jackets", "夹克外套") },
|
||
{ id: "c4-1-2", slug: "shirts", name: L("Shirts", "衬衫") },
|
||
],
|
||
},
|
||
{
|
||
id: "c4-2",
|
||
slug: "bags",
|
||
name: L("Bags", "箱包"),
|
||
children: [
|
||
{ id: "c4-2-1", slug: "backpacks", name: L("Backpacks", "双肩包") },
|
||
{ id: "c4-2-2", slug: "luggage", name: L("Luggage", "旅行箱") },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "c5",
|
||
slug: "beauty",
|
||
name: L("Beauty & Care", "美妆个护"),
|
||
children: [
|
||
{
|
||
id: "c5-1",
|
||
slug: "skincare",
|
||
name: L("Skincare", "面部护肤"),
|
||
children: [
|
||
{ id: "c5-1-1", slug: "serums", name: L("Serums", "精华") },
|
||
{ id: "c5-1-2", slug: "masks", name: L("Masks", "面膜") },
|
||
],
|
||
},
|
||
{
|
||
id: "c5-2",
|
||
slug: "grooming",
|
||
name: L("Grooming", "个人护理"),
|
||
children: [
|
||
{ id: "c5-2-1", slug: "shavers", name: L("Shavers", "电动剃须刀") },
|
||
{ id: "c5-2-2", slug: "toothbrush", name: L("Electric Toothbrushes", "电动牙刷") },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "c6",
|
||
slug: "grocery",
|
||
name: L("Grocery & Fresh", "食品生鲜"),
|
||
children: [
|
||
{
|
||
id: "c6-1",
|
||
slug: "snacks",
|
||
name: L("Snacks", "休闲零食"),
|
||
children: [
|
||
{ id: "c6-1-1", slug: "nuts", name: L("Nuts", "坚果炒货") },
|
||
{ id: "c6-1-2", slug: "pastry", name: L("Pastry", "糕点饼干") },
|
||
],
|
||
},
|
||
{
|
||
id: "c6-2",
|
||
slug: "fresh",
|
||
name: L("Fresh Food", "生鲜果蔬"),
|
||
children: [
|
||
{ id: "c6-2-1", slug: "fruit", name: L("Fruit", "新鲜水果") },
|
||
{ id: "c6-2-2", slug: "seafood", name: L("Seafood", "海鲜水产") },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
function flattenCats(specs: CatSpec[], parentId: string | null, out: Category[]): void {
|
||
specs.forEach((s, i) => {
|
||
out.push({ id: s.id, parent_id: parentId, name: s.name, slug: s.slug, position: i });
|
||
if (s.children) flattenCats(s.children, s.id, out);
|
||
});
|
||
}
|
||
|
||
export const MOCK_CATEGORIES: Category[] = (() => {
|
||
const out: Category[] = [];
|
||
flattenCats(CAT_TREE, null, out);
|
||
return out;
|
||
})();
|
||
|
||
export function categorySubtreeIds(rootId: string): Set<string> {
|
||
const ids = new Set<string>([rootId]);
|
||
let grew = true;
|
||
while (grew) {
|
||
grew = false;
|
||
for (const c of MOCK_CATEGORIES) {
|
||
if (c.parent_id && ids.has(c.parent_id) && !ids.has(c.id)) {
|
||
ids.add(c.id);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
// ---------- brands ----------
|
||
|
||
const PRODUCT_BRAND: Record<string, string> = {};
|
||
|
||
// ---------- stores ----------
|
||
|
||
export const MOCK_STORES: MockStore[] = [
|
||
{
|
||
id: "s1",
|
||
slug: "aurora-flagship",
|
||
name: L("Aurora Flagship Store", "Aurora 官方旗舰店"),
|
||
company: "Aurora Technology Co., Ltd.",
|
||
logo: "/mock/store-1.svg",
|
||
banner: "/mock/banner-1.svg",
|
||
region: "California",
|
||
address: L("1 Infinite Loop, Cupertino, CA", "加利福尼亚州库比蒂诺"),
|
||
distanceKm: 3.2,
|
||
rate: { score: 4.9, agree: 4.8, service: 4.9, speed: 4.7 },
|
||
notice: L("Free shipping on orders over $99.", "满 99 美元免运费。"),
|
||
afterSale: L("7-day returns, 15-day exchanges, 1-year warranty.", "支持 7 天退货、15 天换货、一年质保。"),
|
||
},
|
||
{
|
||
id: "s2",
|
||
slug: "nordwind-digital",
|
||
name: L("Nordwind Digital", "北风数码专营店"),
|
||
company: "Nordwind Trading LLC",
|
||
logo: "/mock/store-2.svg",
|
||
banner: "/mock/banner-2.svg",
|
||
region: "New York",
|
||
address: L("88 Fifth Ave, New York, NY", "纽约州纽约市第五大道 88 号"),
|
||
distanceKm: 12.5,
|
||
rate: { score: 4.7, agree: 4.6, service: 4.8, speed: 4.6 },
|
||
notice: L("Same-day dispatch before 3 PM.", "下午 3 点前下单当天发货。"),
|
||
afterSale: L("Nationwide warranty, online support 9:00-21:00.", "全国联保,在线客服 9:00-21:00。"),
|
||
},
|
||
{
|
||
id: "s3",
|
||
slug: "solace-living",
|
||
name: L("Solace Living", "Solace 生活馆"),
|
||
company: "Solace Home Inc.",
|
||
logo: "/mock/store-3.svg",
|
||
banner: "/mock/banner-3.svg",
|
||
region: "Texas",
|
||
address: L("500 Congress Ave, Austin, TX", "德克萨斯州奥斯汀国会大道 500 号"),
|
||
distanceKm: 28.9,
|
||
rate: { score: 4.8, agree: 4.9, service: 4.7, speed: 4.8 },
|
||
notice: L("New members get a welcome coupon.", "新会员可领新人券。"),
|
||
afterSale: L("30-day worry-free returns on home goods.", "家居商品 30 天无忧退。"),
|
||
},
|
||
{
|
||
id: "s4",
|
||
slug: "terra-fresh",
|
||
name: L("Terra Fresh Market", "Terra 生鲜市集"),
|
||
company: "Terra Foods Group",
|
||
logo: "/mock/store-4.svg",
|
||
banner: "/mock/banner-1.svg",
|
||
region: "Washington",
|
||
address: L("710 Pike St, Seattle, WA", "华盛顿州西雅图派克街 710 号"),
|
||
distanceKm: 45.0,
|
||
rate: { score: 4.6, agree: 4.7, service: 4.6, speed: 4.9 },
|
||
notice: L("Cold-chain delivery for fresh items.", "生鲜商品全程冷链配送。"),
|
||
afterSale: L("Bad-item refund within 48 hours.", "生鲜坏单 48 小时内包赔。"),
|
||
},
|
||
];
|
||
|
||
// ---------- products ----------
|
||
|
||
interface ProductSpec {
|
||
n: number; // image number
|
||
slug: string;
|
||
cat: string;
|
||
store: string;
|
||
brand: string;
|
||
name: LocalizedText;
|
||
sub: LocalizedText;
|
||
price: number; // USD minor
|
||
market: number;
|
||
stock: number;
|
||
attrs: { key: string; values: string[] }[];
|
||
}
|
||
|
||
const PRODUCT_SPECS: ProductSpec[] = [
|
||
{ n: 1, slug: "aurora-x90-pro", cat: "c1-1-1", store: "s1", brand: "b1", name: L("Aurora X90 Pro 5G", "Aurora X90 Pro 5G 手机"), sub: L("Flagship imaging, 120Hz OLED", "旗舰影像,120Hz OLED 屏"), price: 89900, market: 99900, stock: 128, attrs: [{ key: "Color", values: ["Black", "Silver"] }, { key: "Storage", values: ["256GB", "512GB"] }] },
|
||
{ n: 2, slug: "aurora-lite-5g", cat: "c1-1-2", store: "s1", brand: "b1", name: L("Aurora Lite 5G", "Aurora Lite 5G 手机"), sub: L("All-day battery, great value", "长续航,高性价比"), price: 29900, market: 34900, stock: 356, attrs: [{ key: "Color", values: ["Blue", "Green"] }] },
|
||
{ n: 3, slug: "hexon-buds-air", cat: "c1-2-1", store: "s2", brand: "b3", name: L("Hexon Buds Air", "Hexon Buds Air 真无线耳机"), sub: L("ANC, 36h total playback", "主动降噪,36 小时总续航"), price: 7900, market: 9900, stock: 842, attrs: [{ key: "Color", values: ["White", "Black"] }] },
|
||
{ n: 4, slug: "hexon-boombox-mini", cat: "c1-2-2", store: "s2", brand: "b3", name: L("Hexon Boombox Mini", "Hexon Boombox Mini 蓝牙音箱"), sub: L("IPX7 waterproof, 20h playtime", "IPX7 防水,20 小时播放"), price: 5900, market: 7900, stock: 410, attrs: [{ key: "Color", values: ["Gray", "Red"] }] },
|
||
{ n: 5, slug: "nordwind-air-14", cat: "c2-1-1", store: "s2", brand: "b2", name: L("Nordwind Air 14 Laptop", "Nordwind Air 14 轻薄本"), sub: L("1.19kg, 2.8K display, 16GB RAM", "1.19kg,2.8K 屏,16GB 内存"), price: 109900, market: 124900, stock: 96, attrs: [{ key: "Color", values: ["Silver", "Gray"] }, { key: "Memory", values: ["16GB", "32GB"] }] },
|
||
{ n: 6, slug: "hexon-storm-16", cat: "c2-1-2", store: "s2", brand: "b3", name: L("Hexon Storm 16 Gaming Laptop", "Hexon Storm 16 游戏本"), sub: L("RTX graphics, 240Hz screen", "RTX 显卡,240Hz 电竞屏"), price: 189900, market: 209900, stock: 42, attrs: [{ key: "Color", values: ["Black"] }, { key: "Memory", values: ["16GB", "32GB"] }] },
|
||
{ n: 7, slug: "nordwind-keys-pro", cat: "c2-2-1", store: "s2", brand: "b2", name: L("Nordwind Keys Pro", "Nordwind Keys Pro 机械键盘"), sub: L("Hot-swap, tri-mode wireless", "热插拔,三模无线"), price: 8900, market: 10900, stock: 520, attrs: [{ key: "Switch", values: ["Linear", "Tactile"] }] },
|
||
{ n: 8, slug: "nordwind-view-27", cat: "c2-2-2", store: "s2", brand: "b2", name: L("Nordwind View 27 Monitor", "Nordwind View 27 显示器"), sub: L("4K IPS, 99% sRGB", "4K IPS,99% sRGB 色域"), price: 32900, market: 39900, stock: 150, attrs: [{ key: "Size", values: ["27in", "32in"] }] },
|
||
{ n: 9, slug: "solace-rice-chef", cat: "c3-1-1", store: "s3", brand: "b5", name: L("Solace Rice Chef 4L", "Solace 智能电饭煲 4L"), sub: L("IH heating, 24h timer", "IH 电磁加热,24 小时预约"), price: 12900, market: 15900, stock: 230, attrs: [{ key: "Color", values: ["White"] }] },
|
||
{ n: 10, slug: "solace-blend-max", cat: "c3-1-2", store: "s3", brand: "b5", name: L("Solace Blend Max", "Solace 破壁机"), sub: L("Quiet motor, self-cleaning", "静音电机,一键自清洗"), price: 14900, market: 18900, stock: 178, attrs: [{ key: "Color", values: ["Cream", "Green"] }] },
|
||
{ n: 11, slug: "solace-vac-v12", cat: "c3-2-1", store: "s3", brand: "b5", name: L("Solace Vac V12", "Solace V12 无线吸尘器"), sub: L("60min runtime, laser dust detect", "60 分钟续航,激光显尘"), price: 39900, market: 45900, stock: 88, attrs: [{ key: "Color", values: ["Gray"] }] },
|
||
{ n: 12, slug: "solace-pure-500", cat: "c3-2-2", store: "s3", brand: "b5", name: L("Solace Pure 500 Air Purifier", "Solace Pure 500 空气净化器"), sub: L("HEPA-13, 60m² coverage", "HEPA-13 滤芯,适用 60㎡"), price: 24900, market: 29900, stock: 140, attrs: [{ key: "Color", values: ["White"] }] },
|
||
{ n: 13, slug: "mikado-field-jacket", cat: "c4-1-1", store: "s1", brand: "b4", name: L("Mikado Field Jacket", "Mikado 工装夹克"), sub: L("Water-repellent, 4 pockets", "防泼水面料,四袋设计"), price: 11900, market: 14900, stock: 310, attrs: [{ key: "Color", values: ["Olive", "Black"] }, { key: "Size", values: ["M", "L", "XL"] }] },
|
||
{ n: 14, slug: "mikado-oxford-shirt", cat: "c4-1-2", store: "s1", brand: "b4", name: L("Mikado Oxford Shirt", "Mikado 牛津纺衬衫"), sub: L("Wrinkle-free cotton", "免烫纯棉"), price: 4900, market: 6900, stock: 460, attrs: [{ key: "Color", values: ["White", "Blue"] }, { key: "Size", values: ["M", "L", "XL"] }] },
|
||
{ n: 15, slug: "mikado-commuter-pack", cat: "c4-2-1", store: "s1", brand: "b4", name: L("Mikado Commuter Pack 20L", "Mikado 通勤双肩包 20L"), sub: L("Fits 16in laptop, USB port", "可装 16 英寸电脑,外置 USB 口"), price: 6900, market: 8900, stock: 275, attrs: [{ key: "Color", values: ["Black", "Navy"] }] },
|
||
{ n: 16, slug: "mikado-voyage-24", cat: "c4-2-2", store: "s1", brand: "b4", name: L("Mikado Voyage 24 Luggage", "Mikado Voyage 24 英寸旅行箱"), sub: L("PC shell, silent wheels", "PC 箱体,静音万向轮"), price: 13900, market: 16900, stock: 132, attrs: [{ key: "Color", values: ["Silver", "Black"] }] },
|
||
{ n: 17, slug: "solace-glow-serum", cat: "c5-1-1", store: "s3", brand: "b5", name: L("Solace Glow Serum 30ml", "Solace 焕亮精华 30ml"), sub: L("Vitamin C + E, daily glow", "维 C + 维 E,日常焕亮"), price: 3900, market: 4900, stock: 640, attrs: [{ key: "Size", values: ["30ml", "50ml"] }] },
|
||
{ n: 18, slug: "solace-hydra-mask", cat: "c5-1-2", store: "s3", brand: "b5", name: L("Solace Hydra Mask 10pcs", "Solace 补水面膜 10 片装"), sub: L("Hyaluronic acid boost", "玻尿酸深层补水"), price: 1900, market: 2900, stock: 900, attrs: [{ key: "Type", values: ["Hydrating", "Soothing"] }] },
|
||
{ n: 19, slug: "hexon-shave-s9", cat: "c5-2-1", store: "s2", brand: "b3", name: L("Hexon Shave S9", "Hexon S9 电动剃须刀"), sub: L("3-head flex, wet & dry", "三刀头浮动,干湿两用"), price: 9900, market: 12900, stock: 205, attrs: [{ key: "Color", values: ["Black"] }] },
|
||
{ n: 20, slug: "hexon-brush-t5", cat: "c5-2-2", store: "s2", brand: "b3", name: L("Hexon Brush T5", "Hexon T5 电动牙刷"), sub: L("Sonic, 90-day battery", "声波震动,90 天续航"), price: 4900, market: 6900, stock: 380, attrs: [{ key: "Color", values: ["White", "Pink"] }] },
|
||
{ n: 21, slug: "terra-mixed-nuts", cat: "c6-1-1", store: "s4", brand: "b6", name: L("Terra Mixed Nuts 1kg", "Terra 混合坚果 1kg"), sub: L("6 kinds, no added salt", "6 种坚果,无添加盐"), price: 2900, market: 3900, stock: 720, attrs: [{ key: "Pack", values: ["1kg", "500g"] }] },
|
||
{ n: 22, slug: "terra-butter-cookies", cat: "c6-1-2", store: "s4", brand: "b6", name: L("Terra Butter Cookies 908g", "Terra 黄油曲奇 908g"), sub: L("Danish recipe, gift tin", "丹麦配方,礼盒铁罐装"), price: 2400, market: 3200, stock: 540, attrs: [{ key: "Pack", values: ["908g"] }] },
|
||
{ n: 23, slug: "terra-fuji-apples", cat: "c6-2-1", store: "s4", brand: "b6", name: L("Terra Fuji Apples 5kg", "Terra 红富士苹果 5kg"), sub: L("Crisp & sweet, farm direct", "脆甜多汁,产地直发"), price: 1900, market: 2600, stock: 460, attrs: [{ key: "Size", values: ["Medium", "Large"] }] },
|
||
{ n: 24, slug: "terra-salmon-fillet", cat: "c6-2-2", store: "s4", brand: "b6", name: L("Terra Salmon Fillet 500g", "Terra 三文鱼中段 500g"), sub: L("Cold-chain, sashimi grade", "冷链直达,刺身级"), price: 3400, market: 4200, stock: 190, attrs: [{ key: "Cut", values: ["Fillet", "Steak"] }] },
|
||
];
|
||
|
||
function buildProducts(): Product[] {
|
||
const now = "2026-09-01T00:00:00.000Z";
|
||
return PRODUCT_SPECS.map((spec, i) => {
|
||
const id = `p${i + 1}`;
|
||
PRODUCT_BRAND[id] = spec.brand;
|
||
const img = `/mock/product-${spec.n}.svg`;
|
||
const skus: Sku[] = [];
|
||
const combos = spec.attrs.reduce<string[][]>(
|
||
(acc, attr) => acc.flatMap((prefix) => attr.values.map((v) => [...prefix, `${attr.key}:${v}`])),
|
||
[[]],
|
||
);
|
||
combos.forEach((combo, k) => {
|
||
const attributes: Record<string, string> = {};
|
||
for (const pair of combo) {
|
||
const idx = pair.indexOf(":");
|
||
attributes[pair.slice(0, idx)] = pair.slice(idx + 1);
|
||
}
|
||
skus.push({
|
||
id: `${id}-sku${k + 1}`,
|
||
product_id: id,
|
||
sku_code: `${spec.slug.toUpperCase().replaceAll("-", "_")}_${k + 1}`,
|
||
attributes,
|
||
price_minor: spec.price + k * 1000,
|
||
currency: BASE_CURRENCY,
|
||
stock: Math.max(0, Math.floor(spec.stock / combos.length) - (k % 3)),
|
||
active: true,
|
||
});
|
||
});
|
||
return {
|
||
id,
|
||
shop_id: spec.store,
|
||
category_id: spec.cat,
|
||
slug: spec.slug,
|
||
name: spec.name,
|
||
description: spec.sub,
|
||
images: [img, img, img],
|
||
status: "published",
|
||
created_at: now,
|
||
skus,
|
||
} satisfies Product;
|
||
});
|
||
}
|
||
|
||
export const MOCK_PRODUCTS: Product[] = buildProducts();
|
||
|
||
export function productById(idOrSlug: string): Product | null {
|
||
return MOCK_PRODUCTS.find((p) => p.id === idOrSlug || p.slug === idOrSlug) ?? null;
|
||
}
|
||
|
||
export function storeById(idOrSlug: string): MockStore | null {
|
||
return MOCK_STORES.find((s) => s.id === idOrSlug || s.slug === idOrSlug) ?? null;
|
||
}
|
||
|
||
import { lowestSku } from "~/utils/product";
|
||
|
||
/** Kept for the mock-era pages; live pages import it from `~/utils/product`. */
|
||
export { lowestSku };
|
||
|
||
export interface MockSearchQuery {
|
||
q?: string;
|
||
categoryId?: string;
|
||
brandId?: string;
|
||
shopId?: string;
|
||
sort?: "default" | "price" | "sales" | "comments";
|
||
order?: "asc" | "desc";
|
||
page?: number;
|
||
perPage?: number;
|
||
}
|
||
|
||
// Deterministic pseudo stats so sort orders are stable. Seeded by hashing the
|
||
// id, not by parsing digits out of it: mock ids are "p1" but live ids are UUIDs,
|
||
// and `Number("f8a1...")` is NaN, which rendered as "NaN sold".
|
||
function seedOf(id: string): number {
|
||
let hash = 0;
|
||
for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
|
||
return hash;
|
||
}
|
||
|
||
export function salesOf(p: Product): number {
|
||
return 50 + ((seedOf(p.id) * 137) % 950);
|
||
}
|
||
export function commentCountOf(p: Product): number {
|
||
return 5 + ((seedOf(p.id) * 61) % 240);
|
||
}
|
||
|
||
export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } {
|
||
const page = query.page && query.page > 0 ? query.page : 1;
|
||
const perPage = query.perPage && query.perPage > 0 ? query.perPage : 20;
|
||
let list = MOCK_PRODUCTS.filter((p) => p.status === "published");
|
||
if (query.shopId) list = list.filter((p) => p.shop_id === query.shopId);
|
||
if (query.categoryId) {
|
||
const ids = categorySubtreeIds(query.categoryId);
|
||
list = list.filter((p) => p.category_id !== null && ids.has(p.category_id));
|
||
}
|
||
if (query.brandId) list = list.filter((p) => PRODUCT_BRAND[p.id] === query.brandId);
|
||
const kw = query.q?.trim().toLowerCase();
|
||
if (kw) {
|
||
list = list.filter((p) =>
|
||
[p.name.en, p.name.zh, p.description.en, p.description.zh]
|
||
.filter((v): v is string => typeof v === "string")
|
||
.some((v) => v.toLowerCase().includes(kw)),
|
||
);
|
||
}
|
||
const dir = query.order === "asc" ? 1 : -1;
|
||
switch (query.sort) {
|
||
case "price":
|
||
list = [...list].sort((a, b) => dir * ((lowestSku(a)?.price_minor ?? 0) - (lowestSku(b)?.price_minor ?? 0)));
|
||
break;
|
||
case "sales":
|
||
list = [...list].sort((a, b) => dir * (salesOf(a) - salesOf(b)));
|
||
break;
|
||
case "comments":
|
||
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
|
||
break;
|
||
default:
|
||
list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id));
|
||
}
|
||
const total = list.length;
|
||
const items = list.slice((page - 1) * perPage, page * perPage);
|
||
return { items, total, page, per_page: perPage };
|
||
}
|
||
|
||
// ---------- home content ----------
|
||
|
||
export const MOCK_BANNERS: MockBanner[] = [
|
||
{ image: "/mock/banner-1.svg", url: "/search?sort=sales&order=desc" },
|
||
{ image: "/mock/banner-2.svg", url: "/seckill" },
|
||
{ image: "/mock/banner-3.svg", url: "/collective" },
|
||
];
|
||
|
||
const GLYPHS = {
|
||
shield: "M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z",
|
||
coin: "M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z",
|
||
gift: "M20 7h-3.2A3 3 0 0012 3.5 3 3 0 007.2 7H4v4h7V9h2v2h7V7zM4 13v8h7v-8H4zm9 8h7v-8h-7v8z",
|
||
bolt: "M13 2L4 14h6l-1 8 9-12h-6l1-8z",
|
||
bell: "M12 22a2.5 2.5 0 002.5-2.5h-5A2.5 2.5 0 0012 22zm7-6v-5a7 7 0 00-5-6.7V4a2 2 0 00-4 0v.3A7 7 0 005 11v5l-2 2v1h18v-1l-2-2z",
|
||
shop: "M4 4h16l2 5v2a3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 01-3 3 3 3 0 01-3-3V9l2-5zm0 10.7V20h7v-5h2v5h7v-5.3a4.98 4.98 0 01-2 .4V20H6v-5.7a4.98 4.98 0 01-2 .4z",
|
||
};
|
||
|
||
export const MOCK_QUICK_LINKS: MockQuickLink[] = [
|
||
{ label: L("Verification", "实名认证"), url: "/user", glyph: GLYPHS.shield },
|
||
{ label: L("Points Mall", "积分商城"), url: "/integral", glyph: GLYPHS.coin },
|
||
{ label: L("Group Buy", "优惠团购"), url: "/collective", glyph: GLYPHS.gift },
|
||
{ label: L("Flash Sale", "秒杀活动"), url: "/seckill", glyph: GLYPHS.bolt },
|
||
{ label: L("Notice", "商城公告"), url: "/user", glyph: GLYPHS.bell },
|
||
{ label: L("Become a Seller", "入驻商家"), url: "/stores", glyph: GLYPHS.shop },
|
||
];
|
||
|
||
export const MOCK_PROMOS: MockPromo[] = [
|
||
{ image: "/mock/promo-1.svg", url: "/search?category=c1" },
|
||
{ image: "/mock/promo-2.svg", url: "/search?category=c3" },
|
||
{ image: "/mock/promo-3.svg", url: "/search?category=c6" },
|
||
];
|
||
|
||
// ---------- product detail extras ----------
|
||
|
||
export const MOCK_COUPONS: MockCoupon[] = [
|
||
{ id: "cp1", title: L("$5 off over $59", "满 59 减 5"), amountMinor: 500, thresholdMinor: 5900, currency: BASE_CURRENCY, expiresAt: "2026-12-31" },
|
||
{ id: "cp2", title: L("$15 off over $199", "满 199 减 15"), amountMinor: 1500, thresholdMinor: 19900, currency: BASE_CURRENCY, expiresAt: "2026-11-30" },
|
||
{ id: "cp3", title: L("$40 off over $499", "满 499 减 40"), amountMinor: 4000, thresholdMinor: 49900, currency: BASE_CURRENCY, expiresAt: "2026-10-31" },
|
||
];
|
||
|
||
export function commentsFor(productId: string): MockComment[] {
|
||
const seed = Number(productId.replace(/\D/g, "")) || 1;
|
||
const pool: { author: string; text: LocalizedText; rating: number }[] = [
|
||
{ author: "A***a", text: L("Great quality, exactly as described. Fast shipping!", "质量很好,和描述一致,发货快!"), rating: 5 },
|
||
{ author: "M***e", text: L("Good value for the price. Would buy again.", "性价比不错,会回购。"), rating: 5 },
|
||
{ author: "J***n", text: L("Decent, but packaging could be better.", "还行,包装可以更好。"), rating: 4 },
|
||
{ author: "S***y", text: L("Average experience overall.", "整体一般。"), rating: 3 },
|
||
];
|
||
const count = 2 + (seed % 3);
|
||
const out: MockComment[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const p = pool[(seed + i) % pool.length];
|
||
out.push({
|
||
id: `${productId}-cm${i + 1}`,
|
||
productId,
|
||
author: p.author,
|
||
avatar: "/mock/avatar.svg",
|
||
rating: p.rating,
|
||
content: p.text,
|
||
images: [],
|
||
reply:
|
||
p.rating <= 3
|
||
? L("Sorry for the inconvenience, please contact support.", "很抱歉带来不便,请联系在线客服处理。")
|
||
: null,
|
||
createdAt: `2026-0${(seed % 8) + 1}-1${i}`,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } {
|
||
// Derived from the id itself so it matches commentCountOf for live UUID ids
|
||
// too, instead of falling back to an arbitrary mock product.
|
||
const all = 5 + ((seedOf(productId) * 61) % 240);
|
||
const good = Math.round(all * 0.92);
|
||
const medium = Math.round(all * 0.06);
|
||
const bad = all - good - medium;
|
||
return { all, good, medium, bad, goodRate: Math.round((good / Math.max(1, all)) * 100) };
|
||
}
|
||
|
||
export function salesRankFor(shopId: string): Product[] {
|
||
return MOCK_PRODUCTS.filter((p) => p.shop_id === shopId)
|
||
.sort((a, b) => salesOf(b) - salesOf(a))
|
||
.slice(0, 5);
|
||
}
|
||
|
||
export function productDetail(idOrSlug: string): ProductDetail | null {
|
||
const product = productById(idOrSlug);
|
||
if (!product) return null;
|
||
const store = storeById(product.shop_id) ?? MOCK_STORES[0];
|
||
return {
|
||
product,
|
||
store,
|
||
comments: commentsFor(product.id),
|
||
commentStats: commentStats(product.id),
|
||
coupons: MOCK_COUPONS,
|
||
salesRank: salesRankFor(product.shop_id),
|
||
};
|
||
}
|
||
|
||
export function storeDetail(idOrSlug: string): StoreDetail | null {
|
||
const store = storeById(idOrSlug);
|
||
if (!store) return null;
|
||
return {
|
||
store,
|
||
products: MOCK_PRODUCTS.filter((p) => p.shop_id === store.id),
|
||
salesRank: salesRankFor(store.id),
|
||
};
|
||
}
|
||
|
||
// ---------- marketing ----------
|
||
|
||
export const SECKILL_SESSIONS: SeckillSession[] = [
|
||
{ startHour: 0, endHour: 8, label: "00:00" },
|
||
{ startHour: 8, endHour: 12, label: "08:00" },
|
||
{ startHour: 12, endHour: 18, label: "12:00" },
|
||
{ startHour: 18, endHour: 24, label: "18:00" },
|
||
];
|
||
|
||
export function seckillProducts(): { product: Product; seckillPriceMinor: number; soldPct: number }[] {
|
||
return MOCK_PRODUCTS.slice(0, 8).map((p, i) => {
|
||
const sku = lowestSku(p);
|
||
const base = sku?.price_minor ?? 1000;
|
||
return {
|
||
product: p,
|
||
seckillPriceMinor: Math.round(base * (0.5 + (i % 3) * 0.1)),
|
||
soldPct: 20 + ((i * 17) % 75),
|
||
};
|
||
});
|
||
}
|
||
|
||
export function collectiveProducts(): { product: Product; need: number; joined: number }[] {
|
||
return MOCK_PRODUCTS.slice(8, 16).map((p, i) => ({
|
||
product: p,
|
||
need: 2 + (i % 3),
|
||
joined: 10 + ((i * 23) % 80),
|
||
}));
|
||
}
|
||
|
||
export const INTEGRAL_PRODUCTS: IntegralProduct[] = [
|
||
{ id: "ip1", name: L("Insulated Mug", "保温杯"), image: "/mock/product-9.svg", points: 1200, marketPriceMinor: 4900, currency: BASE_CURRENCY, stock: 300 },
|
||
{ id: "ip2", name: L("Canvas Tote", "帆布手提袋"), image: "/mock/product-15.svg", points: 600, marketPriceMinor: 1900, currency: BASE_CURRENCY, stock: 500 },
|
||
{ id: "ip3", name: L("Phone Stand", "手机支架"), image: "/mock/product-7.svg", points: 400, marketPriceMinor: 1200, currency: BASE_CURRENCY, stock: 800 },
|
||
{ id: "ip4", name: L("Cable Organizer Set", "数据线收纳套装"), image: "/mock/product-3.svg", points: 300, marketPriceMinor: 900, currency: BASE_CURRENCY, stock: 1000 },
|
||
{ id: "ip5", name: L("Notebook A5", "A5 笔记本"), image: "/mock/product-14.svg", points: 250, marketPriceMinor: 700, currency: BASE_CURRENCY, stock: 1200 },
|
||
{ id: "ip6", name: L("Umbrella", "折叠雨伞"), image: "/mock/product-13.svg", points: 900, marketPriceMinor: 2900, currency: BASE_CURRENCY, stock: 260 },
|
||
];
|
||
|
||
// ---------- user center ----------
|
||
|
||
export const MOCK_USER: User = {
|
||
id: "u1",
|
||
email: "shopper@vmall.dev",
|
||
display_name: "Demo Shopper",
|
||
role: "customer",
|
||
shop_id: null,
|
||
locale: "en",
|
||
created_at: "2026-01-15T08:00:00.000Z",
|
||
};
|
||
|
||
export const MOCK_ADDRESSES: MockAddress[] = [
|
||
{ id: "a1", recipient: "Demo Shopper", phone: "+1 555 0100", region: "California", city: "Cupertino", line1: "1 Infinite Loop", postalCode: "95014", isDefault: true },
|
||
{ id: "a2", recipient: "Demo Shopper", phone: "+1 555 0100", region: "New York", city: "New York", line1: "88 Fifth Ave", postalCode: "10011", isDefault: false },
|
||
];
|
||
|
||
export function defaultAddress(): Address {
|
||
const a = MOCK_ADDRESSES.find((x) => x.isDefault) ?? MOCK_ADDRESSES[0];
|
||
return {
|
||
recipient: a.recipient,
|
||
phone: a.phone,
|
||
country: "US",
|
||
region: a.region,
|
||
city: a.city,
|
||
line1: a.line1,
|
||
postal_code: a.postalCode,
|
||
};
|
||
}
|
||
|
||
export const MOCK_FAVORITES: MockFavorite[] = [
|
||
{ id: "f1", kind: "product", refId: "p1", createdAt: "2026-08-20" },
|
||
{ id: "f2", kind: "product", refId: "p5", createdAt: "2026-08-25" },
|
||
{ id: "f3", kind: "store", refId: "s1", createdAt: "2026-09-01" },
|
||
];
|
||
|
||
export const USER_STATS = { balanceMinor: 12800, points: 2680, frozenMinor: 0 };
|
||
|
||
export interface DashboardCounts {
|
||
pendingPayment: number;
|
||
pendingShipment: number;
|
||
shipped: number;
|
||
pendingComment: number;
|
||
afterSale: number;
|
||
}
|
||
|
||
// ---------- seeded orders / shipments / invoices ----------
|
||
|
||
function orderItemFrom(productIndex: number, qty: number): { id: string; sku: Sku; product: Product; qty: number } {
|
||
const product = MOCK_PRODUCTS[productIndex];
|
||
const sku = lowestSku(product) ?? product.skus?.[0];
|
||
if (!sku) throw new Error("mock seed: product has no sku");
|
||
return { id: `oi-${productIndex + 1}`, sku, product, qty };
|
||
}
|
||
|
||
export interface MockOrderSeed {
|
||
orders: Order[];
|
||
shipments: Shipment[];
|
||
invoices: Invoice[];
|
||
}
|
||
|
||
export function seedOrders(userId: string): MockOrderSeed {
|
||
const mk = (
|
||
id: string,
|
||
orderNo: string,
|
||
picks: number[],
|
||
status: Order["status"],
|
||
createdAt: string,
|
||
): Order => {
|
||
const items = picks.map((pi, k) => {
|
||
const { sku, product, qty } = orderItemFrom(pi, k + 1);
|
||
return {
|
||
id: `${id}-it${k + 1}`,
|
||
sku_id: sku.id,
|
||
product_name: product.name,
|
||
sku_code: sku.sku_code,
|
||
unit_price_minor: sku.price_minor,
|
||
qty,
|
||
image: product.images[0] ?? null,
|
||
};
|
||
});
|
||
const total = items.reduce((sum, it) => sum + it.unit_price_minor * it.qty, 0);
|
||
return {
|
||
id,
|
||
order_no: orderNo,
|
||
shop_id: MOCK_PRODUCTS[picks[0]].shop_id,
|
||
user_id: userId,
|
||
status,
|
||
currency: BASE_CURRENCY,
|
||
total_minor: total,
|
||
items,
|
||
shipping_address: defaultAddress(),
|
||
created_at: createdAt,
|
||
};
|
||
};
|
||
const orders: Order[] = [
|
||
mk("o1", "VM20260910001", [0], "shipped", "2026-09-10T10:00:00.000Z"),
|
||
mk("o2", "VM20260912002", [2, 6], "completed", "2026-09-12T15:30:00.000Z"),
|
||
mk("o3", "VM20260915003", [20], "pending_payment", "2026-09-15T09:12:00.000Z"),
|
||
];
|
||
const shipments: Shipment[] = [
|
||
{
|
||
id: "sh1",
|
||
shipment_no: "SH20260910001",
|
||
order_id: "o1",
|
||
order_no: "VM20260910001",
|
||
carrier: "SF Express",
|
||
tracking_no: "SF1000000001",
|
||
status: "shipped",
|
||
items: [{ order_item_id: "o1-it1", qty: 1 }],
|
||
shipped_at: "2026-09-10T18:00:00.000Z",
|
||
delivered_at: null,
|
||
created_at: "2026-09-10T17:30:00.000Z",
|
||
},
|
||
];
|
||
const invoices: Invoice[] = [
|
||
{
|
||
id: "inv1",
|
||
invoice_no: "INV20260912001",
|
||
order_id: "o2",
|
||
order_no: "VM20260912002",
|
||
title: "Demo Shopper",
|
||
tax_no: null,
|
||
kind: "personal",
|
||
amount_minor: orders[1].total_minor,
|
||
currency: BASE_CURRENCY,
|
||
status: "issued",
|
||
issued_at: "2026-09-13T09:00:00.000Z",
|
||
created_at: "2026-09-12T16:00:00.000Z",
|
||
},
|
||
];
|
||
return { orders, shipments, invoices };
|
||
}
|