From 21e99bb52bd16a42ec7bafdbae2fd695c10ec5e4 Mon Sep 17 00:00:00 2001 From: Chengdong Zhang Date: Thu, 17 Sep 2026 19:06:21 +0800 Subject: [PATCH] feat(mall): classic B2B2C PC storefront with fixed mock API layer - Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted cart/orders/invoices), runtime switch via mockApi flag (default on) - Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs, stores, brands, banners, floors, seckill/collective/integral, comments, coupons, addresses, seeded orders/shipments/invoices - B2B2C mall shell: top bar, logo/search/cart header, dark nav + category mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme - UI primitives replacing element-plus: carousel, pagination, breadcrumb, qty stepper, rating stars, modal, tabs, step bar, product card - Pages: home floors, search (filters/sort/paging), goods detail (SKU picker, store rail, review tabs), cart -> checkout -> pay -> success, auth pages, user center (dashboard/orders/addresses/favorites/coupons/ invoices), stores, seckill, collective, integral - i18n split into per-domain locale modules (en+zh) - OpenSpec change mall-pc-storefront-replica archived; all specs green --- .gitignore | 1 + apps/mall/app.vue | 49 +- apps/mall/assets/mall.css | 161 ++++ apps/mall/components/shell/CategoryMenu.vue | 134 +++ apps/mall/components/shell/SiteFooter.vue | 73 ++ apps/mall/components/shell/SiteHeader.vue | 223 +++++ apps/mall/components/shell/TopBar.vue | 79 ++ apps/mall/components/ui/Breadcrumb.vue | 35 + apps/mall/components/ui/Carousel.vue | 82 ++ apps/mall/components/ui/EmptyState.vue | 22 + apps/mall/components/ui/Modal.vue | 65 ++ apps/mall/components/ui/Pagination.vue | 59 ++ apps/mall/components/ui/ProductCard.vue | 89 ++ apps/mall/components/ui/QtyStepper.vue | 57 ++ apps/mall/components/ui/RatingStars.vue | 19 + apps/mall/components/ui/StepBar.vue | 50 + apps/mall/components/ui/Tabs.vue | 45 + apps/mall/locales-extra.ts | 152 +-- apps/mall/locales/auth.ts | 72 ++ apps/mall/locales/cart.ts | 46 + apps/mall/locales/checkout.ts | 68 ++ apps/mall/locales/home.ts | 17 + apps/mall/locales/marketing.ts | 82 ++ apps/mall/locales/product.ts | 93 ++ apps/mall/locales/search.ts | 40 + apps/mall/locales/shell.ts | 79 ++ apps/mall/locales/stores.ts | 60 ++ apps/mall/locales/user.ts | 184 ++++ apps/mall/mock/api.ts | 340 +++++++ apps/mall/mock/data.ts | 887 ++++++++++++++++++ apps/mall/nuxt.config.ts | 3 +- apps/mall/pages/cart.vue | 388 ++++++-- apps/mall/pages/checkout.vue | 136 --- apps/mall/pages/checkout/index.vue | 350 +++++++ apps/mall/pages/checkout/pay.vue | 272 ++++++ apps/mall/pages/checkout/success.vue | 68 ++ apps/mall/pages/collective.vue | 171 ++++ apps/mall/pages/forgot-password.vue | 238 +++++ apps/mall/pages/goods/[id].vue | 701 ++++++++++++++ apps/mall/pages/index.vue | 256 ++--- apps/mall/pages/integral.vue | 302 ++++++ apps/mall/pages/invoices.vue | 65 -- apps/mall/pages/login.vue | 169 +++- apps/mall/pages/orders/[id].vue | 197 ---- apps/mall/pages/orders/index.vue | 78 -- apps/mall/pages/products/[id].vue | 143 --- apps/mall/pages/register.vue | 223 ++++- apps/mall/pages/search.vue | 266 ++++++ apps/mall/pages/seckill.vue | 302 ++++++ apps/mall/pages/stores/[id].vue | 371 ++++++++ apps/mall/pages/stores/index.vue | 213 +++++ apps/mall/pages/user.vue | 160 ++++ apps/mall/pages/user/addresses.vue | 223 +++++ apps/mall/pages/user/coupons.vue | 39 + apps/mall/pages/user/favorites.vue | 185 ++++ apps/mall/pages/user/index.vue | 278 ++++++ apps/mall/pages/user/invoices.vue | 66 ++ apps/mall/pages/user/orders/[id].vue | 294 ++++++ apps/mall/pages/user/orders/index.vue | 254 +++++ apps/mall/plugins/api.ts | 14 +- apps/mall/public/mock/avatar.svg | 7 + apps/mall/public/mock/banner-1.svg | 7 + apps/mall/public/mock/banner-2.svg | 7 + apps/mall/public/mock/banner-3.svg | 7 + apps/mall/public/mock/collective-banner.svg | 7 + apps/mall/public/mock/floor-adv-1.svg | 7 + apps/mall/public/mock/floor-adv-2.svg | 7 + apps/mall/public/mock/floor-adv-3.svg | 7 + apps/mall/public/mock/floor-adv-4.svg | 7 + apps/mall/public/mock/floor-adv-5.svg | 7 + apps/mall/public/mock/floor-adv-6.svg | 7 + apps/mall/public/mock/integral-banner.svg | 7 + apps/mall/public/mock/login-bg.svg | 7 + apps/mall/public/mock/product-1.svg | 7 + apps/mall/public/mock/product-10.svg | 7 + apps/mall/public/mock/product-11.svg | 7 + apps/mall/public/mock/product-12.svg | 7 + apps/mall/public/mock/product-13.svg | 7 + apps/mall/public/mock/product-14.svg | 7 + apps/mall/public/mock/product-15.svg | 7 + apps/mall/public/mock/product-16.svg | 7 + apps/mall/public/mock/product-17.svg | 7 + apps/mall/public/mock/product-18.svg | 7 + apps/mall/public/mock/product-19.svg | 7 + apps/mall/public/mock/product-2.svg | 7 + apps/mall/public/mock/product-20.svg | 7 + apps/mall/public/mock/product-21.svg | 7 + apps/mall/public/mock/product-22.svg | 7 + apps/mall/public/mock/product-23.svg | 7 + apps/mall/public/mock/product-24.svg | 7 + apps/mall/public/mock/product-3.svg | 7 + apps/mall/public/mock/product-4.svg | 7 + apps/mall/public/mock/product-5.svg | 7 + apps/mall/public/mock/product-6.svg | 7 + apps/mall/public/mock/product-7.svg | 7 + apps/mall/public/mock/product-8.svg | 7 + apps/mall/public/mock/product-9.svg | 7 + apps/mall/public/mock/promo-1.svg | 7 + apps/mall/public/mock/promo-2.svg | 7 + apps/mall/public/mock/promo-3.svg | 7 + apps/mall/public/mock/store-1.svg | 7 + apps/mall/public/mock/store-2.svg | 7 + apps/mall/public/mock/store-3.svg | 7 + apps/mall/public/mock/store-4.svg | 7 + apps/mall/stores/cart.ts | 20 + .../agent-contracts.md | 39 + .../design-reference.md | 139 +++ .../proposal.md | 27 + .../specs/frontend-mall/spec.md | 75 ++ .../tasks.md | 34 + openspec/specs/frontend-mall/spec.md | 63 +- 111 files changed, 9458 insertions(+), 1035 deletions(-) create mode 100644 apps/mall/assets/mall.css create mode 100644 apps/mall/components/shell/CategoryMenu.vue create mode 100644 apps/mall/components/shell/SiteFooter.vue create mode 100644 apps/mall/components/shell/SiteHeader.vue create mode 100644 apps/mall/components/shell/TopBar.vue create mode 100644 apps/mall/components/ui/Breadcrumb.vue create mode 100644 apps/mall/components/ui/Carousel.vue create mode 100644 apps/mall/components/ui/EmptyState.vue create mode 100644 apps/mall/components/ui/Modal.vue create mode 100644 apps/mall/components/ui/Pagination.vue create mode 100644 apps/mall/components/ui/ProductCard.vue create mode 100644 apps/mall/components/ui/QtyStepper.vue create mode 100644 apps/mall/components/ui/RatingStars.vue create mode 100644 apps/mall/components/ui/StepBar.vue create mode 100644 apps/mall/components/ui/Tabs.vue create mode 100644 apps/mall/locales/auth.ts create mode 100644 apps/mall/locales/cart.ts create mode 100644 apps/mall/locales/checkout.ts create mode 100644 apps/mall/locales/home.ts create mode 100644 apps/mall/locales/marketing.ts create mode 100644 apps/mall/locales/product.ts create mode 100644 apps/mall/locales/search.ts create mode 100644 apps/mall/locales/shell.ts create mode 100644 apps/mall/locales/stores.ts create mode 100644 apps/mall/locales/user.ts create mode 100644 apps/mall/mock/api.ts create mode 100644 apps/mall/mock/data.ts delete mode 100644 apps/mall/pages/checkout.vue create mode 100644 apps/mall/pages/checkout/index.vue create mode 100644 apps/mall/pages/checkout/pay.vue create mode 100644 apps/mall/pages/checkout/success.vue create mode 100644 apps/mall/pages/collective.vue create mode 100644 apps/mall/pages/forgot-password.vue create mode 100644 apps/mall/pages/goods/[id].vue create mode 100644 apps/mall/pages/integral.vue delete mode 100644 apps/mall/pages/invoices.vue delete mode 100644 apps/mall/pages/orders/[id].vue delete mode 100644 apps/mall/pages/orders/index.vue delete mode 100644 apps/mall/pages/products/[id].vue create mode 100644 apps/mall/pages/search.vue create mode 100644 apps/mall/pages/seckill.vue create mode 100644 apps/mall/pages/stores/[id].vue create mode 100644 apps/mall/pages/stores/index.vue create mode 100644 apps/mall/pages/user.vue create mode 100644 apps/mall/pages/user/addresses.vue create mode 100644 apps/mall/pages/user/coupons.vue create mode 100644 apps/mall/pages/user/favorites.vue create mode 100644 apps/mall/pages/user/index.vue create mode 100644 apps/mall/pages/user/invoices.vue create mode 100644 apps/mall/pages/user/orders/[id].vue create mode 100644 apps/mall/pages/user/orders/index.vue create mode 100644 apps/mall/public/mock/avatar.svg create mode 100644 apps/mall/public/mock/banner-1.svg create mode 100644 apps/mall/public/mock/banner-2.svg create mode 100644 apps/mall/public/mock/banner-3.svg create mode 100644 apps/mall/public/mock/collective-banner.svg create mode 100644 apps/mall/public/mock/floor-adv-1.svg create mode 100644 apps/mall/public/mock/floor-adv-2.svg create mode 100644 apps/mall/public/mock/floor-adv-3.svg create mode 100644 apps/mall/public/mock/floor-adv-4.svg create mode 100644 apps/mall/public/mock/floor-adv-5.svg create mode 100644 apps/mall/public/mock/floor-adv-6.svg create mode 100644 apps/mall/public/mock/integral-banner.svg create mode 100644 apps/mall/public/mock/login-bg.svg create mode 100644 apps/mall/public/mock/product-1.svg create mode 100644 apps/mall/public/mock/product-10.svg create mode 100644 apps/mall/public/mock/product-11.svg create mode 100644 apps/mall/public/mock/product-12.svg create mode 100644 apps/mall/public/mock/product-13.svg create mode 100644 apps/mall/public/mock/product-14.svg create mode 100644 apps/mall/public/mock/product-15.svg create mode 100644 apps/mall/public/mock/product-16.svg create mode 100644 apps/mall/public/mock/product-17.svg create mode 100644 apps/mall/public/mock/product-18.svg create mode 100644 apps/mall/public/mock/product-19.svg create mode 100644 apps/mall/public/mock/product-2.svg create mode 100644 apps/mall/public/mock/product-20.svg create mode 100644 apps/mall/public/mock/product-21.svg create mode 100644 apps/mall/public/mock/product-22.svg create mode 100644 apps/mall/public/mock/product-23.svg create mode 100644 apps/mall/public/mock/product-24.svg create mode 100644 apps/mall/public/mock/product-3.svg create mode 100644 apps/mall/public/mock/product-4.svg create mode 100644 apps/mall/public/mock/product-5.svg create mode 100644 apps/mall/public/mock/product-6.svg create mode 100644 apps/mall/public/mock/product-7.svg create mode 100644 apps/mall/public/mock/product-8.svg create mode 100644 apps/mall/public/mock/product-9.svg create mode 100644 apps/mall/public/mock/promo-1.svg create mode 100644 apps/mall/public/mock/promo-2.svg create mode 100644 apps/mall/public/mock/promo-3.svg create mode 100644 apps/mall/public/mock/store-1.svg create mode 100644 apps/mall/public/mock/store-2.svg create mode 100644 apps/mall/public/mock/store-3.svg create mode 100644 apps/mall/public/mock/store-4.svg create mode 100644 apps/mall/stores/cart.ts create mode 100644 openspec/changes/archive/2026-09-17-mall-pc-storefront-replica/agent-contracts.md create mode 100644 openspec/changes/archive/2026-09-17-mall-pc-storefront-replica/design-reference.md create mode 100644 openspec/changes/archive/2026-09-17-mall-pc-storefront-replica/proposal.md create mode 100644 openspec/changes/archive/2026-09-17-mall-pc-storefront-replica/specs/frontend-mall/spec.md create mode 100644 openspec/changes/archive/2026-09-17-mall-pc-storefront-replica/tasks.md diff --git a/.gitignore b/.gitignore index fe96a20..81f9ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ .nuxt/ .output/ .data/ +.playwright-cli/ dist/ *.log .env diff --git a/apps/mall/app.vue b/apps/mall/app.vue index 7bac7dd..c5d0e31 100644 --- a/apps/mall/app.vue +++ b/apps/mall/app.vue @@ -1,56 +1,15 @@ diff --git a/apps/mall/assets/mall.css b/apps/mall/assets/mall.css new file mode 100644 index 0000000..9d17730 --- /dev/null +++ b/apps/mall/assets/mall.css @@ -0,0 +1,161 @@ +/* B2B2C mall-style PC mall theme: 1200px grid, #ca151e brand red, compact controls. */ + +:root { + --mall-red: #ca151e; + --mall-red-dark: #a8111a; + --mall-ink: #333; + --mall-muted: #666; + --mall-faint: #999; + --mall-line: #efefef; + --mall-line-dark: #d9d9d9; + --mall-bg: #f5f5f5; + --mall-panel: #fff; + --mall-green: #67c23a; + --mall-yellow: #e6a23c; + --mall-nav: #333; + --mall-radius: 3px; + --mall-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); +} + +body { + background: #fff; + color: var(--mall-ink); + font-size: 14px; +} + +.w1200 { + width: 1200px; + margin: 0 auto; +} + +.mall-main { + min-height: 60vh; +} + +/* buttons */ +.mbtn { + display: inline-block; + box-sizing: border-box; + padding: 6px 15px; + border: 1px solid var(--mall-line-dark); + border-radius: var(--mall-radius); + background: #fff; + color: var(--mall-muted); + font-size: 12px; + line-height: 1.6; + cursor: pointer; + text-decoration: none; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); +} +.mbtn:hover { + color: var(--mall-red); + border-color: var(--mall-red); +} +.mbtn.red { + background: var(--mall-red); + border-color: var(--mall-red); + color: #fff; +} +.mbtn.red:hover { + background: var(--mall-red-dark); +} +.mbtn.gray { + background: #909399; + border-color: #909399; + color: #fff; +} +.mbtn.block { + display: block; + width: 100%; + text-align: center; + padding: 10px 15px; + font-size: 14px; +} +.mbtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* white content panel (user center etc.) */ +.mpanel { + background: var(--mall-panel); + padding: 20px; + margin-bottom: 20px; +} +.mpanel-title { + font-size: 16px; + border-left: 3px solid var(--mall-red); + padding-left: 12px; + margin: 0 0 20px; + display: flex; + align-items: center; + justify-content: space-between; +} +.mpanel-title .more { + font-size: 12px; + color: var(--mall-muted); + text-decoration: none; +} +.mpanel-title .more:hover { + color: var(--mall-red); +} + +.hr20 { + height: 1px; + background: var(--mall-line); + margin: 20px 0; + border: 0; +} + +/* form controls */ +.minput { + box-sizing: border-box; + width: 100%; + height: 38px; + padding: 8px 12px; + border: 1px solid var(--mall-line-dark); + border-radius: var(--mall-radius); + font-size: 13px; + outline: none; +} +.minput:focus { + border-color: var(--mall-red); +} + +/* gray page section background (home floors) */ +.section-bg { + background: var(--mall-bg); + padding: 20px 0 60px; +} + +/* simple table */ +.mtable { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.mtable th { + background: #fafafa; + border-bottom: 1px solid var(--mall-line); + padding: 10px 12px; + text-align: left; + color: var(--mall-muted); + font-weight: normal; +} +.mtable td { + border-bottom: 1px solid var(--mall-line); + padding: 12px; + vertical-align: middle; +} +.mtable tr:hover td { + background: #fcfcfc; +} + +/* product card hover lift */ +.hover-lift { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.hover-lift:hover { + transform: translateY(-3px); + box-shadow: var(--mall-shadow); +} diff --git a/apps/mall/components/shell/CategoryMenu.vue b/apps/mall/components/shell/CategoryMenu.vue new file mode 100644 index 0000000..b83fb45 --- /dev/null +++ b/apps/mall/components/shell/CategoryMenu.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/apps/mall/components/shell/SiteFooter.vue b/apps/mall/components/shell/SiteFooter.vue new file mode 100644 index 0000000..157d75f --- /dev/null +++ b/apps/mall/components/shell/SiteFooter.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/apps/mall/components/shell/SiteHeader.vue b/apps/mall/components/shell/SiteHeader.vue new file mode 100644 index 0000000..97aacf9 --- /dev/null +++ b/apps/mall/components/shell/SiteHeader.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/apps/mall/components/shell/TopBar.vue b/apps/mall/components/shell/TopBar.vue new file mode 100644 index 0000000..64a1fdd --- /dev/null +++ b/apps/mall/components/shell/TopBar.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/apps/mall/components/ui/Breadcrumb.vue b/apps/mall/components/ui/Breadcrumb.vue new file mode 100644 index 0000000..6dab77c --- /dev/null +++ b/apps/mall/components/ui/Breadcrumb.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/apps/mall/components/ui/Carousel.vue b/apps/mall/components/ui/Carousel.vue new file mode 100644 index 0000000..0a03b9d --- /dev/null +++ b/apps/mall/components/ui/Carousel.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/apps/mall/components/ui/EmptyState.vue b/apps/mall/components/ui/EmptyState.vue new file mode 100644 index 0000000..08c9ac7 --- /dev/null +++ b/apps/mall/components/ui/EmptyState.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/apps/mall/components/ui/Modal.vue b/apps/mall/components/ui/Modal.vue new file mode 100644 index 0000000..12a51e2 --- /dev/null +++ b/apps/mall/components/ui/Modal.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/apps/mall/components/ui/Pagination.vue b/apps/mall/components/ui/Pagination.vue new file mode 100644 index 0000000..ea04352 --- /dev/null +++ b/apps/mall/components/ui/Pagination.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/mall/components/ui/ProductCard.vue b/apps/mall/components/ui/ProductCard.vue new file mode 100644 index 0000000..3528a4c --- /dev/null +++ b/apps/mall/components/ui/ProductCard.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/apps/mall/components/ui/QtyStepper.vue b/apps/mall/components/ui/QtyStepper.vue new file mode 100644 index 0000000..42530ea --- /dev/null +++ b/apps/mall/components/ui/QtyStepper.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/apps/mall/components/ui/RatingStars.vue b/apps/mall/components/ui/RatingStars.vue new file mode 100644 index 0000000..e2171cc --- /dev/null +++ b/apps/mall/components/ui/RatingStars.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/apps/mall/components/ui/StepBar.vue b/apps/mall/components/ui/StepBar.vue new file mode 100644 index 0000000..f10bf8e --- /dev/null +++ b/apps/mall/components/ui/StepBar.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/apps/mall/components/ui/Tabs.vue b/apps/mall/components/ui/Tabs.vue new file mode 100644 index 0000000..3b15753 --- /dev/null +++ b/apps/mall/components/ui/Tabs.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/mall/locales-extra.ts b/apps/mall/locales-extra.ts index 1d112c2..d815624 100644 --- a/apps/mall/locales-extra.ts +++ b/apps/mall/locales-extra.ts @@ -1,111 +1,53 @@ // App-local message extensions, deep-merged over @vmall/shared locales. -// Add keys here (never edit the shared locale files from an app agent). -export const enExtra = { - mall: { - catalogTitle: "Shop products", - searchPlaceholder: "Search products", - categoryPlaceholder: "Choose a category", - allCategories: "All categories", - clear: "Clear", - viewDetails: "View details", - noImage: "No image", - noProducts: "No products found", - productDescription: "Description", - attributes: "Attributes", - sku: "SKU", - chooseSku: "Choose an option", - quantity: "Quantity", - available: "available", - unavailable: "Unavailable", - addedToCart: "Added to cart", - cartItems: "Cart items", - lineTotal: "Line total", - orderPreview: "Order preview", - checkoutTitle: "Checkout", - placeOrder: "Place order", - orderSuccess: "Your order has been placed", - orderNumbers: "Order numbers", - viewOrders: "View orders", - backToShop: "Continue shopping", - noOrders: "No orders found", - created: "Created", - items: "Items", - address: "Address", - shipments: "Shipments", - invoices: "Invoices", - noInvoice: "No invoice requested", - invoiceRequested: "Invoice requested", - notAvailable: "—", - companyTaxRequired: "Tax number is required for company invoices", - invalidQuantity: "Enter a valid quantity", - loadFailed: "Unable to load data", - signInRequired: "Please sign in to continue", - filter: "Filter", - details: "Details", - update: "Update", - confirm: "Confirm", - totalItems: "Total items", - shippingOptional: "Optional", - orderDetails: "Order details", - payment: "Payment", - requestInvoiceForOrder: "Request an invoice for this order", - }, - auth: { - wrongRole: "This account is not a customer account", - }, -} as Record; +// Each domain module owns its own namespace; register new domains below. +// (Never edit the shared locale files from an app agent.) -export const zhExtra = { +import shell from "./locales/shell"; +import home from "./locales/home"; +import search from "./locales/search"; +import product from "./locales/product"; +import cart from "./locales/cart"; +import checkout from "./locales/checkout"; +import auth from "./locales/auth"; +import user from "./locales/user"; +import stores from "./locales/stores"; +import marketing from "./locales/marketing"; + +type Tree = Record; + +function deepMerge(base: T, extra: Tree): T { + const out: Tree = { ...base }; + for (const [k, v] of Object.entries(extra)) { + const b = out[k]; + out[k] = b && v && typeof b === "object" && typeof v === "object" + ? deepMerge(b as Tree, v as Tree) + : v; + } + return out as T; +} + +// Keys referenced by retained shared components (PriceText) and generic handlers. +const legacyEn: Tree = { mall: { - catalogTitle: "选购商品", - searchPlaceholder: "搜索商品", - categoryPlaceholder: "选择分类", - allCategories: "全部分类", - clear: "清除", - viewDetails: "查看详情", - noImage: "暂无图片", - noProducts: "未找到商品", - productDescription: "商品描述", - attributes: "属性", - sku: "SKU", - chooseSku: "选择规格", - quantity: "数量", - available: "件可用", - unavailable: "暂不可用", - addedToCart: "已加入购物车", - cartItems: "购物车商品", - lineTotal: "行合计", - orderPreview: "订单预览", - checkoutTitle: "结算", - placeOrder: "提交订单", - orderSuccess: "订单提交成功", - orderNumbers: "订单号", - viewOrders: "查看订单", - backToShop: "继续购物", - noOrders: "暂无订单", - created: "创建时间", - items: "商品数", - address: "地址", - shipments: "发货信息", - invoices: "发票", - noInvoice: "尚未申请发票", - invoiceRequested: "发票已申请", notAvailable: "—", - companyTaxRequired: "企业发票必须填写税号", - invalidQuantity: "请输入有效数量", - loadFailed: "无法加载数据", - signInRequired: "请先登录", - filter: "筛选", - details: "详情", - update: "更新", - confirm: "确认", - totalItems: "商品总数", - shippingOptional: "可选", - orderDetails: "订单详情", - payment: "支付", - requestInvoiceForOrder: "申请本订单发票", + loadFailed: "Unable to load data", }, - auth: { - wrongRole: "此账号不是买家账号", +}; + +const legacyZh: Tree = { + mall: { + notAvailable: "—", + loadFailed: "加载失败", }, -} as Record; +}; + +const domains = [shell, home, search, product, cart, checkout, auth, user, stores, marketing]; + +export const enExtra: Tree = domains.reduce( + (acc, m) => deepMerge(acc, m.en as Tree), + legacyEn, +); +export const zhExtra: Tree = domains.reduce( + (acc, m) => deepMerge(acc, m.zh as Tree), + legacyZh, +); diff --git a/apps/mall/locales/auth.ts b/apps/mall/locales/auth.ts new file mode 100644 index 0000000..8870b8c --- /dev/null +++ b/apps/mall/locales/auth.ts @@ -0,0 +1,72 @@ +export default { + en: { + auth: { + loginTitle: "Account sign in", + registerTitle: "Create account", + forgotTitle: "Reset password", + loginTab: "Account sign in", + registerTab: "Create account", + forgotTab: "Forgot password", + emailPlaceholder: "Enter your email", + passwordPlaceholder: "Enter your password", + newPassword: "New password", + displayNamePlaceholder: "Enter your display name", + confirmPassword: "Confirm password", + confirmPasswordPlaceholder: "Enter your password again", + smsCode: "Verification code", + smsCodePlaceholder: "Enter the code", + getCode: "Get code", + codeCountdown: "Retry in {n}s", + loginAction: "Sign in", + registerAction: "Create account", + resetAction: "Reset password", + forgotPassword: "Forgot password?", + noAccount: "Don't have an account?", + haveAccount: "Already have an account?", + registerNow: "Create one now", + loginNow: "Sign in now", + resetSuccess: "Password reset", + resetSuccessHint: "Your password has been reset. Sign in with your new password.", + validationRequired: "Please complete all required fields.", + validationEmail: "Enter a valid email address.", + validationPassword: "Password must be at least 6 characters.", + validationMismatch: "Passwords do not match.", + requestFailed: "Unable to complete this request. Please try again.", + }, + }, + zh: { + auth: { + loginTitle: "账号登录", + registerTitle: "注册账号", + forgotTitle: "忘记密码", + loginTab: "账号登录", + registerTab: "注册账号", + forgotTab: "忘记密码", + emailPlaceholder: "请输入邮箱", + passwordPlaceholder: "请输入密码", + newPassword: "新密码", + displayNamePlaceholder: "请输入昵称", + confirmPassword: "确认密码", + confirmPasswordPlaceholder: "请再次输入密码", + smsCode: "验证码", + smsCodePlaceholder: "请输入验证码", + getCode: "获取验证码", + codeCountdown: "{n} 秒后重试", + loginAction: "登录", + registerAction: "注册", + resetAction: "重置密码", + forgotPassword: "忘记密码?", + noAccount: "还没有账号?", + haveAccount: "已有账号?", + registerNow: "立即注册", + loginNow: "立即登录", + resetSuccess: "密码重置成功", + resetSuccessHint: "您的密码已重置,请使用新密码登录。", + validationRequired: "请填写所有必填项。", + validationEmail: "请输入有效的邮箱地址。", + validationPassword: "密码至少需要 6 位。", + validationMismatch: "两次输入的密码不一致。", + requestFailed: "操作失败,请稍后重试。", + }, + }, +}; diff --git a/apps/mall/locales/cart.ts b/apps/mall/locales/cart.ts new file mode 100644 index 0000000..bc7f8b9 --- /dev/null +++ b/apps/mall/locales/cart.ts @@ -0,0 +1,46 @@ +export default { + en: { + cart: { + title: "Shopping cart", + steps: { cart: "Cart", order: "Confirm order", payment: "Payment", complete: "Complete" }, + empty: "Your cart is empty", + continueShopping: "Continue shopping", + selectAll: "Select all", + selectedCount: "{n} selected", + product: "Product", + spec: "Specification", + unitPrice: "Unit price", + quantity: "Quantity", + subtotal: "Subtotal", + actions: "Actions", + remove: "Remove", + shop: "Shop", + unknownStore: "Store", + checkout: "Checkout", + loadError: "Unable to load your cart.", + updateError: "Unable to update your cart.", + }, + }, + zh: { + cart: { + title: "购物车", + steps: { cart: "购物车", order: "确认订单", payment: "支付", complete: "完成" }, + empty: "购物车还是空的", + continueShopping: "继续购物", + selectAll: "全选", + selectedCount: "已选 {n} 件", + product: "商品", + spec: "规格", + unitPrice: "单价", + quantity: "数量", + subtotal: "小计", + actions: "操作", + remove: "移除", + shop: "店铺", + unknownStore: "店铺", + checkout: "去结算", + loadError: "购物车加载失败,请稍后重试。", + updateError: "购物车更新失败,请稍后重试。", + }, + }, +}; diff --git a/apps/mall/locales/checkout.ts b/apps/mall/locales/checkout.ts new file mode 100644 index 0000000..edb512a --- /dev/null +++ b/apps/mall/locales/checkout.ts @@ -0,0 +1,68 @@ +export default { + en: { + checkout: { + title: "Confirm order", + steps: { cart: "Cart", order: "Confirm order", payment: "Payment", complete: "Complete" }, + address: "Shipping address", + defaultAddress: "Default", + orderPreview: "Order details", + shop: "Shop", + remark: "Order remark", + remarkPlaceholder: "Add a note for the seller (optional)", + total: "Order total", + submit: "Place order", + noItems: "There are no items to check out.", + backToCart: "Back to cart", + loadError: "Unable to load the order.", + submitError: "Unable to place the order.", + paymentTitle: "Choose payment", + balance: "Balance payment", + wechat: "WeChat Pay", + alipay: "Alipay", + payTitle: "Pay for your order", + orderNo: "Order no.", + grandTotal: "Total payment", + confirmPay: "Confirm payment", + noPendingOrders: "There are no orders waiting for payment.", + backHome: "Back to home", + payError: "Unable to complete payment.", + successTitle: "Payment successful", + successMessage: "Your order has been paid successfully.", + viewOrders: "View orders", + continueShopping: "Continue shopping", + }, + }, + zh: { + checkout: { + title: "确认订单", + steps: { cart: "购物车", order: "确认订单", payment: "支付", complete: "完成" }, + address: "收货地址", + defaultAddress: "默认地址", + orderPreview: "订单商品", + shop: "店铺", + remark: "订单备注", + remarkPlaceholder: "给卖家留言(选填)", + total: "订单总额", + submit: "提交订单", + noItems: "暂无可结算的商品。", + backToCart: "返回购物车", + loadError: "订单信息加载失败,请稍后重试。", + submitError: "订单提交失败,请稍后重试。", + paymentTitle: "选择支付方式", + balance: "余额支付", + wechat: "微信支付", + alipay: "支付宝", + payTitle: "订单支付", + orderNo: "订单号", + grandTotal: "应付总额", + confirmPay: "确认支付", + noPendingOrders: "暂无待支付订单。", + backHome: "返回首页", + payError: "支付失败,请稍后重试。", + successTitle: "订单支付成功", + successMessage: "您的订单已支付成功。", + viewOrders: "查看订单", + continueShopping: "继续购物", + }, + }, +}; diff --git a/apps/mall/locales/home.ts b/apps/mall/locales/home.ts new file mode 100644 index 0000000..1325d7c --- /dev/null +++ b/apps/mall/locales/home.ts @@ -0,0 +1,17 @@ +// Home page domain strings. +export default { + en: { + home: { + viewMore: "View more", + quickLinks: "Quick links", + hotPromo: "Featured", + }, + }, + zh: { + home: { + viewMore: "查看更多", + quickLinks: "快捷入口", + hotPromo: "精选活动", + }, + }, +}; diff --git a/apps/mall/locales/marketing.ts b/apps/mall/locales/marketing.ts new file mode 100644 index 0000000..3315b30 --- /dev/null +++ b/apps/mall/locales/marketing.ts @@ -0,0 +1,82 @@ +export default { + en: { + marketing: { + seckillTitle: "Flash sale", + seckillBreadcrumb: "Flash sale", + currentSession: "Now", + upcoming: "Coming soon", + ended: "Ended", + countdown: "Ends in {time}", + seckillPrice: "Flash price", + originalPrice: "Original price", + progress: "Sold {n}%", + buyNow: "Grab now", + noSeckillProducts: "No flash-sale products right now.", + collectiveTitle: "Group deals", + collectiveBreadcrumb: "Group deals", + collectiveBannerAlt: "Group deal promotion", + peopleGroup: "{n} people group", + peopleJoined: "{n} people joined", + joinGroup: "View deal", + noCollectiveProducts: "No group deals right now.", + integralTitle: "Points mall", + integralBreadcrumb: "Points mall", + integralBannerAlt: "Points mall promotion", + loginPrompt: "Sign in to see your points", + pointsBalance: "My points", + customize: "Personal customization", + shipping: "Ships within 24 hours", + support: "Priority support", + recommendation: "Popular rewards", + points: "{n} points", + marketPrice: "Market price", + stock: "Stock {n}", + redeem: "Redeem now", + redeemed: "Redeemed", + mockNotice: "Demo redemption completed", + login: "Sign in", + register: "Create account", + noIntegralProducts: "No rewards available right now.", + }, + }, + zh: { + marketing: { + seckillTitle: "限时秒杀", + seckillBreadcrumb: "秒杀活动", + currentSession: "正在进行", + upcoming: "即将开始", + ended: "已结束", + countdown: "距结束 {time}", + seckillPrice: "秒杀价", + originalPrice: "原价", + progress: "已抢 {n}%", + buyNow: "马上抢", + noSeckillProducts: "当前暂无秒杀商品。", + collectiveTitle: "拼团购", + collectiveBreadcrumb: "拼团", + collectiveBannerAlt: "拼团活动", + peopleGroup: "{n}人团", + peopleJoined: "{n}人已拼", + joinGroup: "查看详情", + noCollectiveProducts: "当前暂无拼团商品。", + integralTitle: "积分商城", + integralBreadcrumb: "积分商城", + integralBannerAlt: "积分商城活动", + loginPrompt: "登录后查看您的积分", + pointsBalance: "我的积分", + customize: "私人定制", + shipping: "24小时发货", + support: "优先客服", + recommendation: "热门推荐", + points: "{n}积分", + marketPrice: "市场价", + stock: "库存 {n}", + redeem: "立即兑换", + redeemed: "已兑换", + mockNotice: "模拟兑换成功", + login: "登录", + register: "注册", + noIntegralProducts: "当前暂无积分商品。", + }, + }, +} as const; diff --git a/apps/mall/locales/product.ts b/apps/mall/locales/product.ts new file mode 100644 index 0000000..c4ba660 --- /dev/null +++ b/apps/mall/locales/product.ts @@ -0,0 +1,93 @@ +// Product browse/detail domain strings (foundation-owned base keys; extend, don't remove). +export default { + en: { + product: { + salesCount: "{n} sold", + commentCount: "{n} reviews", + home: "Home", + detail: "Product details", + currentPrice: "Sale price", + marketPrice: "Market price", + stock: "In stock: {n}", + sold: "{n} sold", + coupon: "Coupon", + coupons: "Available coupons", + claim: "Claim", + choose: "Choose", + selected: "Selected", + quantity: "Quantity", + buyNow: "Buy now", + addCart: "Add to cart", + addedCart: "Added to cart", + favorite: "Favorite", + unfavorite: "Unfavorite", + noStock: "Out of stock", + unavailable: "This combination is unavailable", + store: "Store", + quality: "Product quality", + company: "Company", + region: "Region", + rates: "Store ratings", + overall: "Overall", + service: "Service", + delivery: "Delivery", + enterStore: "Visit store", + contact: "Contact seller", + salesRank: "Best sellers", + tabsDetail: "Product details", + tabsReviews: "Customer reviews", + tabsAfterSale: "After-sales service", + description: "Description", + reviewSummary: "{rate}% positive reviews ({n} total)", + reviewAll: "All reviews", + reply: "Seller reply", + expires: "Expires {date}", + productNotFound: "Product not found", + }, + }, + zh: { + product: { + salesCount: "已售 {n} 件", + commentCount: "{n} 条评价", + home: "首页", + detail: "商品详情", + currentPrice: "活动价", + marketPrice: "市场价", + stock: "库存 {n} 件", + sold: "已售 {n} 件", + coupon: "优惠券", + coupons: "可用优惠券", + claim: "领取", + choose: "选择", + selected: "已选", + quantity: "数量", + buyNow: "立即购买", + addCart: "加入购物车", + addedCart: "已加入购物车", + favorite: "收藏", + unfavorite: "取消收藏", + noStock: "暂时缺货", + unavailable: "该规格暂不可用", + store: "店铺", + quality: "商品质量", + company: "公司", + region: "所在地区", + rates: "店铺评分", + overall: "描述相符", + service: "服务态度", + delivery: "物流服务", + enterStore: "进入店铺", + contact: "联系客服", + salesRank: "销售排行", + tabsDetail: "商品详情", + tabsReviews: "用户评价", + tabsAfterSale: "售后服务", + description: "商品描述", + reviewSummary: "好评率 {rate}%(共 {n} 条评价)", + reviewAll: "全部评价", + reply: "商家回复", + expires: "有效期至 {date}", + productNotFound: "商品不存在", + }, + }, +}; diff --git a/apps/mall/locales/search.ts b/apps/mall/locales/search.ts new file mode 100644 index 0000000..5d1e963 --- /dev/null +++ b/apps/mall/locales/search.ts @@ -0,0 +1,40 @@ +export default { + en: { + search: { + home: "Home", + searchResults: 'Results for "{keyword}"', + productList: "Product list", + category: "Category", + all: "All", + brand: "Brand", + sort: "Sort", + defaultSort: "Default", + price: "Price", + sales: "Sales", + comments: "Reviews", + ascending: "Ascending", + descending: "Descending", + empty: "No products found", + resultCount: "{n} products", + }, + }, + zh: { + search: { + home: "首页", + searchResults: '搜索 “{keyword}” 结果列表', + productList: "商品列表", + category: "商品分类", + all: "全部", + brand: "品牌筛选", + sort: "筛选排序", + defaultSort: "默认", + price: "价格", + sales: "销量", + comments: "评论", + ascending: "升序", + descending: "降序", + empty: "暂无相关商品", + resultCount: "共 {n} 件商品", + }, + }, +}; diff --git a/apps/mall/locales/shell.ts b/apps/mall/locales/shell.ts new file mode 100644 index 0000000..dd6223a --- /dev/null +++ b/apps/mall/locales/shell.ts @@ -0,0 +1,79 @@ +// Shell chrome: top bar, header/search/nav, category menu, footer. +export default { + en: { + shell: { + officialSite: "Official Site", + mallHome: "Mall Home", + contact: "Contact Us", + about: "About Us", + login: "Sign in", + register: "Register", + welcome: "Welcome,", + userCenter: "My Account", + logout: "Sign out", + sellerJoin: "Sell on VMall", + mobile: "Mobile", + appDownload: "App", + city: "San Francisco", + searchPlaceholder: "phones, laptops, clothing", + myMall: "My VMall", + myCart: "My Cart", + allCategories: "All Categories", + nav: { + home: "Home", + stores: "Stores", + seckill: "Flash Sale", + collective: "Group Buy", + integral: "Points Mall", + help: "Help Center", + }, + footer: { + v1: "Fortune-500 quality", + v2: "7-day returns, 15-day exchanges", + v3: "Free shipping over $99", + v4: "448 service centers nationwide", + copyright: "VMall Demo Mall — all content is fixed mock data.", + icp: "Mock ICP 2026-0001", + }, + backToTop: "Top", + }, + }, + zh: { + shell: { + officialSite: "官网首页", + mallHome: "商城首页", + contact: "联系我们", + about: "关于我们", + login: "登录", + register: "注册", + welcome: "欢迎您,", + userCenter: "个人中心", + logout: "退出登录", + sellerJoin: "商家入驻", + mobile: "手机端", + appDownload: "APP下载", + city: "旧金山", + searchPlaceholder: "手机 笔记本电脑 衣服", + myMall: "我的商城", + myCart: "我的购物车", + allCategories: "全部商品分类", + nav: { + home: "首页", + stores: "店铺街", + seckill: "秒杀", + collective: "拼团", + integral: "积分商城", + help: "帮助中心", + }, + footer: { + v1: "500强企业 品质保证", + v2: "7天退货 15天换货", + v3: "满99美元免运费", + v4: "448家维修网点 全国联保", + copyright: "VMall 演示商城 —— 所有内容均为固定 mock 数据。", + icp: "模拟备案号 2026-0001", + }, + backToTop: "顶部", + }, + }, +}; diff --git a/apps/mall/locales/stores.ts b/apps/mall/locales/stores.ts new file mode 100644 index 0000000..6bcd2cc --- /dev/null +++ b/apps/mall/locales/stores.ts @@ -0,0 +1,60 @@ +export default { + en: { + stores: { + breadcrumbHome: "Home", + directory: "Store directory", + sortDefault: "Default", + sortDistance: "Distance", + distance: "{n} km away", + positiveRate: "Positive reviews 100%", + visitStore: "Visit store", + company: "Company", + region: "Region", + address: "Address", + storeProfile: "Store profile", + score: "Overall", + agreement: "Product", + service: "Service", + speed: "Delivery", + favorite: "Follow store", + favorited: "Following", + salesRank: "Top sellers", + storeProducts: "Store products", + sortPrice: "Price", + sortSales: "Sales", + sortComments: "Reviews", + noProducts: "This store has no products yet.", + unknownStore: "Store not found", + backToStores: "Back to stores", + }, + }, + zh: { + stores: { + breadcrumbHome: "首页", + directory: "店铺街", + sortDefault: "默认", + sortDistance: "距离", + distance: "距离 {n} 公里", + positiveRate: "好评率 100%", + visitStore: "进入店铺", + company: "公司", + region: "地区", + address: "地址", + storeProfile: "店铺信息", + score: "综合评分", + agreement: "商品质量", + service: "服务态度", + speed: "发货速度", + favorite: "收藏店铺", + favorited: "已收藏", + salesRank: "本店销量排行", + storeProducts: "店内商品", + sortPrice: "价格", + sortSales: "销量", + sortComments: "评论", + noProducts: "店铺暂时没有商品。", + unknownStore: "店铺不存在", + backToStores: "返回店铺街", + }, + }, +} as const; diff --git a/apps/mall/locales/user.ts b/apps/mall/locales/user.ts new file mode 100644 index 0000000..a99a5ff --- /dev/null +++ b/apps/mall/locales/user.ts @@ -0,0 +1,184 @@ +export default { + en: { + user: { + title: "My account", + editProfile: "Edit profile", + orderCenter: "Orders", + memberCenter: "Membership", + dashboard: "Account overview", + myOrders: "My orders", + addresses: "Shipping addresses", + coupons: "My coupons", + favorites: "Favorites", + invoices: "My invoices", + statsBalance: "Balance", + statsPoints: "Points", + statsFrozen: "Frozen funds", + pendingPayment: "To pay", + pendingShipment: "To ship", + pendingReceipt: "To receive", + completed: "Completed", + afterSale: "After-sale", + recentOrders: "Recent orders", + favoriteProducts: "Favorite products", + viewAll: "View all", + noOrders: "No orders yet.", + noFavorites: "No favorites yet.", + orderNo: "Order no.", + createdAt: "Created", + shop: "Shop", + orderTotal: "Order total", + viewDetails: "View details", + payNow: "Pay now", + cancelOrder: "Cancel order", + ordersTitle: "My orders", + filterAll: "All", + filterPendingPayment: "To pay", + filterPendingShipment: "To ship", + filterPendingReceipt: "To receive", + filterCompleted: "Completed", + filterCancelled: "Cancelled", + orderDetail: "Order details", + shippingAddress: "Shipping address", + orderItems: "Order items", + total: "Total", + product: "Product", + shipments: "Shipments", + shipmentNo: "Shipment no.", + carrier: "Carrier", + trackingNo: "Tracking no.", + confirmDelivery: "Confirm delivery", + noShipments: "No shipment information yet.", + invoiceRequest: "Request invoice", + invoiceTitle: "Invoice title", + taxNo: "Tax number (optional)", + invoiceKind: "Invoice type", + personal: "Personal", + company: "Company", + submitInvoice: "Request invoice", + invoiceSuccess: "Invoice request submitted.", + addAddress: "Add address", + editAddress: "Edit address", + recipient: "Recipient", + phone: "Phone", + region: "State / Province", + city: "City", + line1: "Street address", + postalCode: "Postal code", + defaultAddress: "Default", + setDefault: "Set as default", + saveAddress: "Save address", + noAddresses: "No shipping addresses yet.", + favoritesTitle: "Favorites", + favoriteProductsTab: "Products", + favoriteStoresTab: "Stores", + removeFavorite: "Remove favorite", + enterStore: "Enter store", + noFavoriteStores: "No favorite stores yet.", + couponsTitle: "My coupons", + couponTitle: "Coupon", + amount: "Amount", + threshold: "Minimum spend", + expiresAt: "Expires", + noCoupons: "No coupons yet.", + invoicesTitle: "My invoices", + invoiceNo: "Invoice no.", + invoiceOrderNo: "Order no.", + invoiceIssuedAt: "Issued at", + noInvoices: "No invoices yet.", + validationRequired: "Please complete the required fields.", + loadFailed: "Unable to load your account data.", + }, + }, + zh: { + user: { + title: "我的账户", + editProfile: "编辑资料", + orderCenter: "订单中心", + memberCenter: "会员中心", + dashboard: "个人中心", + myOrders: "我的订单", + addresses: "收货地址", + coupons: "我的优惠券", + favorites: "收藏/关注", + invoices: "我的发票", + statsBalance: "账户余额", + statsPoints: "账户积分", + statsFrozen: "冻结资金", + pendingPayment: "待付款", + pendingShipment: "待发货", + pendingReceipt: "待收货", + completed: "已完成", + afterSale: "售后中", + recentOrders: "最近订单", + favoriteProducts: "收藏的宝贝", + viewAll: "查看全部", + noOrders: "暂无订单。", + noFavorites: "暂无收藏。", + orderNo: "订单号", + createdAt: "创建时间", + shop: "店铺", + orderTotal: "订单总额", + viewDetails: "查看详情", + payNow: "付款", + cancelOrder: "取消订单", + ordersTitle: "我的订单", + filterAll: "全部", + filterPendingPayment: "待付款", + filterPendingShipment: "待发货", + filterPendingReceipt: "待收货", + filterCompleted: "已完成", + filterCancelled: "已取消", + orderDetail: "订单详情", + shippingAddress: "收货地址", + orderItems: "商品信息", + total: "合计", + product: "商品", + shipments: "物流信息", + shipmentNo: "发货单号", + carrier: "承运商", + trackingNo: "物流单号", + confirmDelivery: "确认收货", + noShipments: "暂无物流信息。", + invoiceRequest: "申请发票", + invoiceTitle: "发票抬头", + taxNo: "税号(选填)", + invoiceKind: "发票类型", + personal: "个人", + company: "企业", + submitInvoice: "提交申请", + invoiceSuccess: "发票申请已提交。", + addAddress: "新增地址", + editAddress: "编辑地址", + recipient: "收货人", + phone: "联系电话", + region: "省/州", + city: "城市", + line1: "详细地址", + postalCode: "邮编", + defaultAddress: "默认", + setDefault: "设为默认", + saveAddress: "保存地址", + noAddresses: "暂无收货地址。", + favoritesTitle: "我的收藏", + favoriteProductsTab: "商品", + favoriteStoresTab: "店铺", + removeFavorite: "取消收藏", + enterStore: "进入店铺", + noFavoriteStores: "暂无关注店铺。", + couponsTitle: "我的优惠券", + couponTitle: "优惠券", + amount: "优惠金额", + threshold: "使用门槛", + expiresAt: "有效期至", + noCoupons: "暂无优惠券。", + invoicesTitle: "我的发票", + invoiceNo: "发票号", + invoiceOrderNo: "订单号", + invoiceIssuedAt: "开具时间", + noInvoices: "暂无发票。", + validationRequired: "请填写必填项。", + loadFailed: "账户数据加载失败。", + }, + }, +}; diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts new file mode 100644 index 0000000..a619055 --- /dev/null +++ b/apps/mall/mock/api.ts @@ -0,0 +1,340 @@ +// Mock API adapter: implements the full @vmall/shared ApiClient surface with +// fixed data + in-memory cart/order state. Pages call $api exactly as they +// would against the live backend, so switching back is a config flip. + +import { ApiError } from "@vmall/shared"; +import type { + Address, + ApiClient, + AuthTokens, + Cart, + CartItem, + Invoice, + InvoiceKind, + Order, + Product, + Shipment, + User, +} from "@vmall/shared"; +import { + BASE_CURRENCY, + MOCK_CATEGORIES, + MOCK_CURRENCIES, + MOCK_USER, + defaultAddress, + mockConvertMinor, + productById, + searchMockProducts, + seedOrders, +} from "./data"; + +interface MockState { + token: string | null; + cart: CartItem[]; + orders: Order[]; + shipments: Shipment[]; + invoices: Invoice[]; + orderSeq: number; + invoiceSeq: number; +} + +const STORAGE_KEY = "vmall.mock.state.v1"; + +type PersistedState = Pick; + +// Load cart/order session state persisted by a previous page load (client only). +function loadPersisted(): PersistedState | null { + if (!import.meta.client) return null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const p = parsed as Partial; + if (!Array.isArray(p.cart) || !Array.isArray(p.orders)) return null; + if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null; + if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null; + return p as PersistedState; + } catch { + return null; + } +} + +function initialState(): MockState { + const persisted = loadPersisted(); + if (persisted) return { token: null, ...persisted }; + const seed = seedOrders(MOCK_USER.id); + return { + token: null, + cart: [], + orders: seed.orders, + shipments: seed.shipments, + invoices: seed.invoices, + orderSeq: 100, + invoiceSeq: 100, + }; +} + +function unsupported(): never { + throw new ApiError(501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock."); +} + + +function skuIndex(): Record { + const index: Record = {}; + for (const p of searchMockProducts({ perPage: 1000 }).items) { + for (const s of p.skus ?? []) index[s.id] = { product: p, skuId: s.id }; + } + return index; +} + +export function createMockApi(): ApiClient { + const state = initialState(); + function persist(): void { + if (!import.meta.client) return; + try { + const snapshot: PersistedState = { + cart: state.cart, + orders: state.orders, + shipments: state.shipments, + invoices: state.invoices, + orderSeq: state.orderSeq, + invoiceSeq: state.invoiceSeq, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); + } catch { + /* storage unavailable: keep in-memory only */ + } + } + + function tokens(): AuthTokens { + state.token = `mock-token-${Date.now()}`; + return { token: state.token, user: MOCK_USER }; + } + + function cartSnapshot(): Cart { + return { items: state.cart.map((i) => ({ ...i })) }; + } + + function nextOrderNo(): string { + state.orderSeq += 1; + return `VM20260917${String(state.orderSeq).padStart(3, "0")}`; + } + + return { + register: () => Promise.resolve(tokens()), + login: () => Promise.resolve(tokens()), + me: (): Promise => Promise.resolve(MOCK_USER), + + listProducts: (q = {}) => + Promise.resolve( + searchMockProducts({ + q: q.q, + categoryId: q.category_id, + shopId: q.shop_id, + page: q.page, + perPage: q.per_page, + }), + ), + + getProduct: (idOrSlug) => { + const product = productById(idOrSlug); + if (!product) return Promise.reject(new ApiError(404, "NOT_FOUND", "Product not found")); + return Promise.resolve(product); + }, + + listCategories: () => Promise.resolve(MOCK_CATEGORIES), + listCurrencies: () => Promise.resolve(MOCK_CURRENCIES), + convert: (amountMinor, from, to) => + Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }), + + getCart: () => Promise.resolve(cartSnapshot()), + + addCartItem: (skuId, qty) => { + const entry = skuIndex()[skuId]; + if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "SKU not found")); + const sku = entry.product.skus?.find((s) => s.id === skuId); + if (!sku || !sku.active) return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available")); + const existing = state.cart.find((i) => i.sku_id === skuId); + const nextQty = (existing?.qty ?? 0) + qty; + if (nextQty > sku.stock) return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock")); + if (existing) existing.qty = nextQty; + else { + state.cart.push({ + sku_id: skuId, + product_id: entry.product.id, + product_name: entry.product.name, + sku_code: sku.sku_code, + image: entry.product.images[0] ?? null, + unit_price_minor: sku.price_minor, + currency: sku.currency, + qty, + }); + } + persist(); + return Promise.resolve(cartSnapshot()); + }, + + updateCartItem: (skuId, qty) => { + const item = state.cart.find((i) => i.sku_id === skuId); + if (!item) return Promise.reject(new ApiError(404, "NOT_FOUND", "Cart item not found")); + if (qty <= 0) state.cart = state.cart.filter((i) => i.sku_id !== skuId); + else item.qty = qty; + persist(); + return Promise.resolve(cartSnapshot()); + }, + + removeCartItem: (skuId) => { + state.cart = state.cart.filter((i) => i.sku_id !== skuId); + persist(); + return Promise.resolve(cartSnapshot()); + }, + + checkout: (shippingAddress: Address, currency: string) => { + if (state.cart.length === 0) { + return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty")); + } + const byShop: Record = {}; + for (const item of state.cart) { + const product = productById(item.product_id); + const shopId = product?.shop_id ?? "unknown"; + (byShop[shopId] ??= []).push(item); + } + const created: Order[] = Object.entries(byShop).map(([shopId, items]) => { + const order: Order = { + id: `o-${Date.now()}-${shopId}`, + order_no: nextOrderNo(), + shop_id: shopId, + user_id: MOCK_USER.id, + status: "pending_payment", + currency, + total_minor: items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0), + items: items.map((i, k) => ({ + id: `oi-${Date.now()}-${k}`, + sku_id: i.sku_id, + product_name: i.product_name, + sku_code: i.sku_code, + unit_price_minor: i.unit_price_minor, + qty: i.qty, + image: i.image, + })), + shipping_address: shippingAddress, + created_at: new Date().toISOString(), + }; + return order; + }); + state.orders = [...created, ...state.orders]; + state.cart = []; + persist(); + return Promise.resolve(created); + }, + + listMyOrders: (page = 1) => { + const perPage = 10; + const start = (page - 1) * perPage; + return Promise.resolve({ + items: state.orders.slice(start, start + perPage), + total: state.orders.length, + page, + per_page: perPage, + }); + }, + + getOrder: (id) => { + const order = state.orders.find((o) => o.id === id || o.order_no === id); + if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); + return Promise.resolve(order); + }, + + cancelOrder: (id) => { + const order = state.orders.find((o) => o.id === id); + if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); + if (order.status !== "pending_payment") { + return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled")); + } + order.status = "cancelled"; + persist(); + return Promise.resolve(order); + }, + + payOrder: (id) => { + const order = state.orders.find((o) => o.id === id); + if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); + if (order.status !== "pending_payment") { + return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be paid")); + } + order.status = "paid"; + persist(); + return Promise.resolve(order); + }, + + confirmDelivered: (shipmentId) => { + const shipment = state.shipments.find((s) => s.id === shipmentId); + if (!shipment) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shipment not found")); + shipment.status = "delivered"; + shipment.delivered_at = new Date().toISOString(); + const order = state.orders.find((o) => o.id === shipment.order_id); + if (order) order.status = "completed"; + persist(); + return Promise.resolve(shipment); + }, + + listMyShipments: () => Promise.resolve(state.shipments.map((s) => ({ ...s }))), + + requestInvoice: (orderId: string, title: string, taxNo: string | null, kind: InvoiceKind) => { + const order = state.orders.find((o) => o.id === orderId); + if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); + state.invoiceSeq += 1; + const invoice: Invoice = { + id: `inv-${Date.now()}`, + invoice_no: `INV20260917${String(state.invoiceSeq).padStart(3, "0")}`, + order_id: order.id, + order_no: order.order_no, + title, + tax_no: taxNo, + kind, + amount_minor: order.total_minor, + currency: order.currency, + status: "requested", + issued_at: null, + created_at: new Date().toISOString(), + }; + state.invoices = [invoice, ...state.invoices]; + persist(); + return Promise.resolve(invoice); + }, + + listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))), + + shop: { + getMyShop: () => unsupported(), + listMyProducts: () => unsupported(), + getProduct: () => unsupported(), + createProduct: () => unsupported(), + updateProduct: () => unsupported(), + publish: () => unsupported(), + unpublish: () => unsupported(), + upsertSku: () => unsupported(), + listOrders: () => unsupported(), + getOrder: () => unsupported(), + createShipment: () => unsupported(), + listShipments: () => unsupported(), + markShipped: () => unsupported(), + listInvoices: () => unsupported(), + issueInvoice: () => unsupported(), + }, + admin: { + listUsers: () => unsupported(), + setUserRole: () => unsupported(), + listShops: () => unsupported(), + createShop: () => unsupported(), + setShopStatus: () => unsupported(), + listOrders: () => unsupported(), + listCurrencies: () => unsupported(), + upsertCurrency: () => unsupported(), + setRate: () => unsupported(), + }, + }; +} + +export { defaultAddress }; diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts new file mode 100644 index 0000000..914a672 --- /dev/null +++ b/apps/mall/mock/data.ts @@ -0,0 +1,887 @@ +// 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 MockBrand { + id: string; + name: string; +} + +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 HomeFloor { + categoryId: string; + name: LocalizedText; + advImage: string; + advUrl: string; + products: Product[]; +} + +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 = { 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 { + const ids = new Set([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; +} + +export function topCategories(): Category[] { + return MOCK_CATEGORIES.filter((c) => c.parent_id === null).sort((a, b) => a.position - b.position); +} + +export function childCategories(parentId: string): Category[] { + return MOCK_CATEGORIES.filter((c) => c.parent_id === parentId).sort((a, b) => a.position - b.position); +} + +// ---------- brands ---------- + +export const MOCK_BRANDS: MockBrand[] = [ + { id: "b1", name: "Aurora" }, + { id: "b2", name: "Nordwind" }, + { id: "b3", name: "Hexon" }, + { id: "b4", name: "Mikado" }, + { id: "b5", name: "Solace" }, + { id: "b6", name: "Terra" }, +]; + +const PRODUCT_BRAND: Record = {}; + +export function brandOf(productId: string): MockBrand | null { + const id = PRODUCT_BRAND[productId]; + return MOCK_BRANDS.find((b) => b.id === id) ?? null; +} + +// ---------- 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( + (acc, attr) => acc.flatMap((prefix) => attr.values.map((v) => [...prefix, `${attr.key}:${v}`])), + [[]], + ); + combos.forEach((combo, k) => { + const attributes: Record = {}; + 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; +} + +export function lowestSku(product: Product): Sku | null { + const active = (product.skus ?? []).filter((s) => s.active && s.stock > 0); + return ( + active.reduce( + (low, s) => (low === null || s.price_minor < low.price_minor ? s : low), + null, + ) ?? (product.skus ?? []).find((s) => s.active) ?? null + ); +} + +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 +export function salesOf(p: Product): number { + return 50 + ((Number(p.id.slice(1)) * 137) % 950); +} +export function commentCountOf(p: Product): number { + return 5 + ((Number(p.id.slice(1)) * 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) => Number(a.id.slice(1)) - Number(b.id.slice(1))); + } + 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" }, +]; + +export function homeFloors(): HomeFloor[] { + return topCategories().map((cat, i) => { + const ids = categorySubtreeIds(cat.id); + const products = MOCK_PRODUCTS.filter((p) => p.category_id !== null && ids.has(p.category_id)).slice(0, 8); + return { + categoryId: cat.id, + name: cat.name, + advImage: `/mock/floor-adv-${i + 1}.svg`, + advUrl: `/search?category=${cat.id}`, + products, + }; + }); +} + +// ---------- 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 } { + const all = commentCountOf(productById(productId) ?? MOCK_PRODUCTS[0]); + 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 }; +} diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 9c34e28..1730e2e 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -1,11 +1,12 @@ export default defineNuxtConfig({ modules: ["@pinia/nuxt", "@nuxtjs/i18n"], - css: ["@vmall/shared/ui.css"], + css: ["@vmall/shared/ui.css", "~/assets/mall.css"], devtools: { enabled: false }, devServer: { port: 3000 }, runtimeConfig: { public: { apiBase: "http://localhost:8080/api", + mockApi: true, appName: "mall", }, }, diff --git a/apps/mall/pages/cart.vue b/apps/mall/pages/cart.vue index 161c567..e08f433 100644 --- a/apps/mall/pages/cart.vue +++ b/apps/mall/pages/cart.vue @@ -1,134 +1,350 @@ diff --git a/apps/mall/pages/checkout.vue b/apps/mall/pages/checkout.vue deleted file mode 100644 index 81f711b..0000000 --- a/apps/mall/pages/checkout.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - - - diff --git a/apps/mall/pages/checkout/index.vue b/apps/mall/pages/checkout/index.vue new file mode 100644 index 0000000..e09a192 --- /dev/null +++ b/apps/mall/pages/checkout/index.vue @@ -0,0 +1,350 @@ + + +