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
+23 -1
View File
@@ -17,6 +17,10 @@ import type {
FlashSaleItemInput,
FlashSaleSession,
FlashSaleSessionInput,
GroupBuyIntent,
GroupBuyingActivity,
GroupBuyingActivityInput,
GroupBuyingActivityView,
HomeContent,
IntegralOrder,
IntegralProduct,
@@ -182,6 +186,8 @@ export interface ApiClient {
currency: string,
/** At most one owned coupon per generated shop order. */
couponByShop?: Record<string, string>,
/** Optional group-buying intent for one activity SKU at quantity 1. */
groupBuy?: GroupBuyIntent,
): Promise<Order[]>;
listMyOrders(page?: number): Promise<Paged<Order>>;
getOrder(id: string): Promise<Order>;
@@ -216,6 +222,8 @@ export interface ApiClient {
redeemPoints(body: RedeemPointsBody): Promise<IntegralOrder>;
/** Public: active flash-sale sessions with their purchasable items. */
listFlashSales(): Promise<PublicFlashSaleSession[]>;
/** Public: active group-buying activities with their open groups. */
listGroupBuyingActivities(): Promise<GroupBuyingActivityView[]>;
shop: {
getMyShop(): Promise<Shop>;
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
@@ -248,6 +256,13 @@ export interface ApiClient {
addFlashSaleItem(sessionId: string, body: FlashSaleItemInput): Promise<FlashSaleItem>;
updateFlashSaleItem(id: string, body: FlashSaleItemInput): Promise<FlashSaleItem>;
deleteFlashSaleItem(id: string): Promise<void>;
listGroupBuyingActivities(): Promise<GroupBuyingActivityView[]>;
createGroupBuyingActivity(body: GroupBuyingActivityInput): Promise<GroupBuyingActivity>;
updateGroupBuyingActivity(
id: string,
body: GroupBuyingActivityInput,
): Promise<GroupBuyingActivity>;
deleteGroupBuyingActivity(id: string): Promise<void>;
};
admin: {
listUsers(page?: number): Promise<Paged<User>>;
@@ -289,11 +304,12 @@ export function createApi(opts: ApiClientOptions): ApiClient {
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
checkout: (shippingAddress, currency, couponByShop = {}) =>
checkout: (shippingAddress, currency, couponByShop = {}, groupBuy) =>
r("POST", "/orders/checkout", {
shipping_address: shippingAddress,
currency,
coupon_by_shop: couponByShop,
group_buy: groupBuy ?? null,
}),
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
getOrder: (id) => r("GET", `/orders/${id}`),
@@ -319,6 +335,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
listMyRedemptions: () => r("GET", "/points/redemptions"),
redeemPoints: (body) => r("POST", "/points/redemptions", body),
listFlashSales: () => r("GET", "/flash-sales"),
listGroupBuyingActivities: () => r("GET", "/group-buying/activities"),
shop: {
getMyShop: () => r("GET", "/shop/profile"),
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
@@ -352,6 +369,11 @@ export function createApi(opts: ApiClientOptions): ApiClient {
r("POST", `/shop/flash-sales/${sessionId}/items`, body),
updateFlashSaleItem: (id, body) => r("PUT", `/shop/flash-sale-items/${id}`, body),
deleteFlashSaleItem: (id) => r("DELETE", `/shop/flash-sale-items/${id}`),
listGroupBuyingActivities: () => r("GET", "/shop/group-buying-activities"),
createGroupBuyingActivity: (body) => r("POST", "/shop/group-buying-activities", body),
updateGroupBuyingActivity: (id, body) =>
r("PUT", `/shop/group-buying-activities/${id}`, body),
deleteGroupBuyingActivity: (id) => r("DELETE", `/shop/group-buying-activities/${id}`),
},
admin: {
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
+67
View File
@@ -161,6 +161,10 @@ export interface Order {
discount_minor: number;
/** The coupon this order redeemed, or null. */
coupon_id: string | null;
/** The group-buying activity this order joined, or null. */
group_activity_id: string | null;
/** The concrete group this order belongs to, or null. */
group_id: string | null;
items: OrderItem[];
shipping_address: Address;
created_at: string;
@@ -398,6 +402,69 @@ export interface PublicFlashSaleSession extends FlashSaleSession {
items: PublicFlashSaleItem[];
}
// ---- group buying ----
export type CollectiveGroupStatus = "open" | "successful" | "expired" | "cancelled";
/** Shop-owned timed activity on one SKU; checkout takes it at quantity 1. */
export interface GroupBuyingActivity {
id: string;
shop_id: string;
sku_id: string;
name: LocalizedText;
description: LocalizedText | null;
image: string | null;
group_price_minor: number;
currency: string;
required_members: number;
starts_at: string;
ends_at: string;
group_lifetime_hours: number;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface GroupBuyingActivityInput {
sku_id: string;
name: LocalizedText;
description?: LocalizedText | null;
image?: string | null;
group_price_minor: number;
currency: string;
required_members: number;
starts_at: string;
ends_at: string;
group_lifetime_hours: number;
enabled?: boolean;
}
/** A group a shopper can join. */
export interface OpenGroup {
id: string;
paid_member_count: number;
required_members: number;
expires_at: string;
}
/** Activity plus the SKU/catalog details both surfaces need. */
export interface GroupBuyingActivityView extends GroupBuyingActivity {
sku_code: string;
product_id: string;
product_slug: string;
product_name: LocalizedText;
original_price_minor: number;
original_currency: string;
open_groups: OpenGroup[];
}
/** Checkout intent: one activity SKU at quantity 1, joining or opening. */
export interface GroupBuyIntent {
activity_id: string;
sku_id: string;
group_id?: string | null;
}
export type ShipmentStatus = "pending" | "shipped" | "delivered";export interface Shipment {
id: string;
shipment_no: string;