Files
vmall/apps/mall/mock/data.ts
T
james d0b6350d2d feat(mall): read the store directory from the API
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
2026-09-17 17:13:32 +00:00

844 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.19kg2.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 IPS99% 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 };
}