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 @@