From 2125ae437fcc65f8bc543f546a7b789bc540d70e Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Fri, 18 Sep 2026 12:53:26 +0000 Subject: [PATCH] feat(mall): live flash-sale page and activity price snapshots The flash-sale page loads active sessions and their items through the shared client with live sale price, remaining activity stock, sell-through, and a countdown, and adds the SKU to the existing cart so checkout resolves the authoritative price. Payment and order detail tag and render the snapshotted activity unit price. The flash-sale fixtures leave the page; the fixed-data adapter still serves the domain as the rollback path. --- apps/mall/locales/marketing.ts | 4 + apps/mall/mock/api.ts | 57 +++++++++++ apps/mall/mock/data.ts | 1 + apps/mall/nuxt.config.ts | 2 +- apps/mall/pages/checkout/pay.vue | 8 ++ apps/mall/pages/seckill.vue | 146 ++++++++++++++++----------- apps/mall/pages/user/orders/[id].vue | 8 ++ apps/mall/plugins/api.ts | 5 +- packages/shared/src/api.ts | 24 +++++ packages/shared/src/types.ts | 88 ++++++++++++++++ 10 files changed, 283 insertions(+), 60 deletions(-) diff --git a/apps/mall/locales/marketing.ts b/apps/mall/locales/marketing.ts index 4290d8b..e40f6b0 100644 --- a/apps/mall/locales/marketing.ts +++ b/apps/mall/locales/marketing.ts @@ -11,6 +11,8 @@ export default { originalPrice: "Original price", progress: "Sold {n}%", buyNow: "Grab now", + grabFailed: "Unable to add this item right now.", + flashTag: "Flash sale", noSeckillProducts: "No flash-sale products right now.", collectiveTitle: "Group deals", collectiveBreadcrumb: "Group deals", @@ -69,6 +71,8 @@ export default { originalPrice: "原价", progress: "已抢 {n}%", buyNow: "马上抢", + grabFailed: "加入购物车失败,请稍后重试。", + flashTag: "秒杀价", noSeckillProducts: "当前暂无秒杀商品。", collectiveTitle: "拼团购", collectiveBreadcrumb: "拼团", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index da601cb..d9ba45e 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -15,6 +15,10 @@ import type { Coupon, CouponTemplate, CouponTemplateInput, + FlashSaleItem, + FlashSaleItemInput, + FlashSaleSession, + FlashSaleSessionInput, HomeContent, IntegralOrder, IntegralProduct, @@ -23,6 +27,7 @@ import type { InvoiceKind, Order, Product, + PublicFlashSaleSession, RedeemPointsBody, Shipment, ShopProfile, @@ -43,9 +48,11 @@ import { USER_STATS, MOCK_ADDRESSES, defaultAddress, + lowestSku, mockConvertMinor, productById, searchMockProducts, + seckillProducts, seedOrders, storeById, } from "./data"; @@ -150,6 +157,46 @@ function seedPointsProducts(): IntegralProduct[] { })); } +/** One synthesized active session covering the fixed seckill fixtures. */ +function mockFlashSales(): PublicFlashSaleSession[] { + const now = Date.now(); + const items = seckillProducts().map((entry, index) => { + const sku = lowestSku(entry.product); + const stock = sku?.stock ?? 100; + const sold = Math.round((stock * entry.soldPct) / 100); + return { + id: `fsi${index + 1}`, + session_id: "fs1", + sku_id: sku?.id ?? "", + product_id: entry.product.id, + product_slug: entry.product.slug, + product_name: entry.product.name, + image: entry.product.images[0] ?? null, + sku_code: sku?.sku_code ?? "", + sale_price_minor: entry.seckillPriceMinor, + currency: sku?.currency ?? BASE_CURRENCY, + original_price_minor: sku?.price_minor ?? entry.seckillPriceMinor, + original_currency: sku?.currency ?? BASE_CURRENCY, + reserved_stock: Math.max(stock - sold, 0), + sold_count: sold, + per_customer_limit: 2, + }; + }); + return [ + { + id: "fs1", + shop_id: MOCK_STORES[0].id, + label: { en: "Flash sale", zh: "限时秒杀" }, + starts_at: new Date(now - 3_600_000).toISOString(), + ends_at: new Date(now + 6 * 3_600_000).toISOString(), + enabled: true, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + items, + }, + ]; +} + function initialState(): MockState { const persisted = loadPersisted(); // Coupons and points are session-only, so a restored snapshot re-seeds them. @@ -397,6 +444,7 @@ export function createMockApi(): ApiClient { unit_price_minor: i.unit_price_minor, qty: i.qty, image: i.image, + flash_sale_item_id: null, })), shipping_address: shippingAddress, created_at: new Date().toISOString(), @@ -630,6 +678,8 @@ export function createMockApi(): ApiClient { state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })), ), + listFlashSales: () => Promise.resolve(mockFlashSales()), + listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))), redeemPoints: (body: RedeemPointsBody) => { @@ -693,6 +743,13 @@ export function createMockApi(): ApiClient { createCouponTemplate: (_body: CouponTemplateInput) => unsupported(), updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(), deleteCouponTemplate: (_id: string) => unsupported(), + listFlashSales: () => unsupported(), + createFlashSale: (_body: FlashSaleSessionInput) => unsupported(), + updateFlashSale: (_id: string, _body: FlashSaleSessionInput) => unsupported(), + deleteFlashSale: (_id: string) => unsupported(), + addFlashSaleItem: (_sessionId: string, _body: FlashSaleItemInput) => unsupported(), + updateFlashSaleItem: (_id: string, _body: FlashSaleItemInput) => unsupported(), + deleteFlashSaleItem: (_id: string) => unsupported(), }, admin: { listUsers: () => unsupported(), diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index d88d844..912b340 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -692,6 +692,7 @@ export function seedOrders(userId: string): MockOrderSeed { unit_price_minor: sku.price_minor, qty, image: product.images[0] ?? null, + flash_sale_item_id: null, }; }); const total = items.reduce((sum, it) => sum + it.unit_price_minor * it.qty, 0); diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index aec03f0..3742909 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -9,7 +9,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales"], appName: "mall", }, }, diff --git a/apps/mall/pages/checkout/pay.vue b/apps/mall/pages/checkout/pay.vue index 51d974b..75cbe12 100644 --- a/apps/mall/pages/checkout/pay.vue +++ b/apps/mall/pages/checkout/pay.vue @@ -99,6 +99,7 @@ onMounted(() => void loadOrders());
{{ pick(item.product_name, locale) }} {{ item.sku_code }} + {{ t("marketing.flashTag") }}
× {{ item.qty }} @@ -233,6 +234,13 @@ onMounted(() => void loadOrders()); .discount-value { color: var(--mall-red); } +.activity-tag { + align-self: flex-start; + padding: 1px 6px; + border: 1px solid var(--mall-red); + color: var(--mall-red); + font-size: 11px; +} .payment-panel { margin-top: 16px; padding: 0 18px 16px; diff --git a/apps/mall/pages/seckill.vue b/apps/mall/pages/seckill.vue index 2405c15..bf1b447 100644 --- a/apps/mall/pages/seckill.vue +++ b/apps/mall/pages/seckill.vue @@ -1,12 +1,16 @@