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.
This commit is contained in:
2026-09-18 13:22:47 +00:00
parent a7bc476251
commit e540b91491
9 changed files with 338 additions and 26 deletions
+73 -1
View File
@@ -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<string, string> = {},
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<string, CartItem[]> = {};
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(),