From e540b91491eedf853d197082a5d8dc01d45915ff Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Fri, 18 Sep 2026 13:22:47 +0000 Subject: [PATCH] feat(mall): live group-buying page with open or join intent The group-buying page lists active activities, the group price against the catalog price, and each open group with its paid-member count and expiry. A shopper opens a group or picks one to join; either way the activity SKU goes into the cart and the intent is carried to checkout, which submits it and hides the coupon picker for that order. Payment and order detail show the snapshotted group price. The group-buying fixtures leave the page; the fixed-data adapter still serves the domain as the rollback path. --- apps/mall/locales/marketing.ts | 14 ++- apps/mall/mock/api.ts | 74 ++++++++++++- apps/mall/mock/data.ts | 2 + apps/mall/nuxt.config.ts | 2 +- apps/mall/pages/checkout/index.vue | 14 ++- apps/mall/pages/collective.vue | 160 ++++++++++++++++++++++++++--- apps/mall/plugins/api.ts | 7 +- packages/shared/src/api.ts | 24 ++++- packages/shared/src/types.ts | 67 ++++++++++++ 9 files changed, 338 insertions(+), 26 deletions(-) diff --git a/apps/mall/locales/marketing.ts b/apps/mall/locales/marketing.ts index e40f6b0..c128d84 100644 --- a/apps/mall/locales/marketing.ts +++ b/apps/mall/locales/marketing.ts @@ -19,7 +19,12 @@ export default { collectiveBannerAlt: "Group deal promotion", peopleGroup: "{n} people group", peopleJoined: "{n} people joined", - joinGroup: "View deal", + joinGroup: "Join", + openGroup: "Open a group", + chooseGroup: "Choose a group to join.", + groupFailed: "Unable to start the group order.", + paidMembers: "{n}/{total} joined", + noOpenGroups: "No open groups yet.", noCollectiveProducts: "No group deals right now.", integralTitle: "Points mall", integralBreadcrumb: "Points mall", @@ -79,7 +84,12 @@ export default { collectiveBannerAlt: "拼团活动", peopleGroup: "{n}人团", peopleJoined: "{n}人已拼", - joinGroup: "查看详情", + joinGroup: "去拼团", + openGroup: "我要开团", + chooseGroup: "请选择一个团。", + groupFailed: "拼团下单失败,请稍后重试。", + paidMembers: "已参团 {n}/{total}", + noOpenGroups: "暂无进行中的团。", noCollectiveProducts: "当前暂无拼团商品。", integralTitle: "积分商城", integralBreadcrumb: "积分商城", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index d9ba45e..9ace0c8 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -19,6 +19,10 @@ import type { FlashSaleItemInput, FlashSaleSession, FlashSaleSessionInput, + GroupBuyIntent, + GroupBuyingActivity, + GroupBuyingActivityInput, + GroupBuyingActivityView, HomeContent, IntegralOrder, IntegralProduct, @@ -36,6 +40,7 @@ import type { import { BASE_CURRENCY, MOCK_BANNERS, + collectiveProducts, MOCK_BRANDS, MOCK_CATEGORIES, MOCK_COUPONS, @@ -197,6 +202,46 @@ function mockFlashSales(): PublicFlashSaleSession[] { ]; } +/** Synthesized activities from the fixed collective fixtures. */ +function mockGroupBuying(): GroupBuyingActivityView[] { + const now = Date.now(); + return collectiveProducts().map((entry, index) => { + const sku = lowestSku(entry.product); + const price = sku?.price_minor ?? 1000; + return { + id: `gb${index + 1}`, + shop_id: entry.product.shop_id, + sku_id: sku?.id ?? "", + name: { en: `${entry.need}-person group`, zh: `${entry.need}人团` }, + description: null, + image: entry.product.images[0] ?? null, + group_price_minor: Math.max(1, Math.round(price * 0.8)), + currency: sku?.currency ?? BASE_CURRENCY, + required_members: entry.need, + starts_at: new Date(now - 3_600_000).toISOString(), + ends_at: new Date(now + 7 * 24 * 3_600_000).toISOString(), + group_lifetime_hours: 24, + enabled: true, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + sku_code: sku?.sku_code ?? "", + product_id: entry.product.id, + product_slug: entry.product.slug, + product_name: entry.product.name, + original_price_minor: price, + original_currency: sku?.currency ?? BASE_CURRENCY, + open_groups: [ + { + id: `g${index + 1}`, + paid_member_count: Math.min(1, entry.need - 1), + required_members: entry.need, + expires_at: new Date(now + 6 * 3_600_000).toISOString(), + }, + ], + }; + }); +} + function initialState(): MockState { const persisted = loadPersisted(); // Coupons and points are session-only, so a restored snapshot re-seeds them. @@ -399,12 +444,25 @@ export function createMockApi(): ApiClient { shippingAddress: Address, currency: string, couponByShop: Record = {}, + groupBuy?: GroupBuyIntent, ) => { if (state.cart.length === 0) { return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty")); } + // A group-buy intent repriced exactly one SKU at the activity price. + const activity = groupBuy + ? mockGroupBuying().find((entry) => entry.id === groupBuy.activity_id) + : undefined; + if (groupBuy && !activity) { + return Promise.reject(new ApiError(409, "CONFLICT", "Group activity is not available")); + } + const cartItems = state.cart.map((item) => + activity && groupBuy && item.sku_id === groupBuy.sku_id + ? { ...item, unit_price_minor: activity.group_price_minor } + : item, + ); const byShop: Record = {}; - for (const item of state.cart) { + for (const item of cartItems) { const product = productById(item.product_id); const shopId = product?.shop_id ?? "unknown"; (byShop[shopId] ??= []).push(item); @@ -412,8 +470,14 @@ export function createMockApi(): ApiClient { const created: Order[] = []; for (const [shopId, items] of Object.entries(byShop)) { const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0); + const groupHere = activity && groupBuy && items.some((i) => i.sku_id === groupBuy.sku_id); const couponId = couponByShop[shopId]; let discount = 0; + if (couponId && groupHere) { + return Promise.reject( + new ApiError(409, "CONFLICT", "A coupon cannot be combined with group pricing"), + ); + } if (couponId) { const coupon = state.coupons.find((c) => c.id === couponId); if (!coupon || coupon.status !== "claimed" || coupon.shop_id !== shopId) { @@ -436,6 +500,8 @@ export function createMockApi(): ApiClient { total_minor: subtotal - discount, discount_minor: discount, coupon_id: couponId ?? null, + group_activity_id: groupHere ? activity.id : null, + group_id: groupHere ? `g-${Date.now()}` : null, items: items.map((i, k) => ({ id: `oi-${Date.now()}-${k}`, sku_id: i.sku_id, @@ -680,6 +746,8 @@ export function createMockApi(): ApiClient { listFlashSales: () => Promise.resolve(mockFlashSales()), + listGroupBuyingActivities: () => Promise.resolve(mockGroupBuying()), + listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))), redeemPoints: (body: RedeemPointsBody) => { @@ -750,6 +818,10 @@ export function createMockApi(): ApiClient { addFlashSaleItem: (_sessionId: string, _body: FlashSaleItemInput) => unsupported(), updateFlashSaleItem: (_id: string, _body: FlashSaleItemInput) => unsupported(), deleteFlashSaleItem: (_id: string) => unsupported(), + listGroupBuyingActivities: () => unsupported(), + createGroupBuyingActivity: (_body: GroupBuyingActivityInput) => unsupported(), + updateGroupBuyingActivity: (_id: string, _body: GroupBuyingActivityInput) => unsupported(), + deleteGroupBuyingActivity: (_id: string) => unsupported(), }, admin: { listUsers: () => unsupported(), diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index 912b340..2e29162 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -706,6 +706,8 @@ export function seedOrders(userId: string): MockOrderSeed { total_minor: total, discount_minor: 0, coupon_id: null, + group_activity_id: null, + group_id: null, items, shipping_address: defaultAddress(), created_at: createdAt, diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 3742909..33f8b7f 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", "flashSales"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points", "flashSales", "groupBuying"], appName: "mall", }, }, diff --git a/apps/mall/pages/checkout/index.vue b/apps/mall/pages/checkout/index.vue index 63e05a3..5698fb1 100644 --- a/apps/mall/pages/checkout/index.vue +++ b/apps/mall/pages/checkout/index.vue @@ -1,6 +1,6 @@