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
+12 -2
View File
@@ -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: "积分商城",
+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(),
+2
View File
@@ -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,
+1 -1
View File
@@ -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",
},
},
+11 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Address, AddressBookEntry, CartItem, Coupon, LocalizedText } from "@vmall/shared";
import type { Address, AddressBookEntry, CartItem, Coupon, GroupBuyIntent, LocalizedText } from "@vmall/shared";
type CheckoutGroup = {
shopId: string;
@@ -16,6 +16,8 @@ const { currency } = usePrefs();
const cartStore = useCartStore();
const router = useRouter();
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
/** Group-buying intent chosen on the group-buying page, if any. */
const groupIntent = useState<GroupBuyIntent | null>("checkout-group-intent", () => null);
const items = ref<CartItem[]>([]);
const addresses = ref<AddressBookEntry[]>([]);
@@ -140,7 +142,13 @@ async function submitOrder(): Promise<void> {
for (const [shopId, couponId] of Object.entries(selectedCoupon)) {
if (couponId) couponByShop[shopId] = couponId;
}
const orders = await $api.checkout(address, currency.value, couponByShop);
const orders = await $api.checkout(
address,
currency.value,
couponByShop,
groupIntent.value ?? undefined,
);
groupIntent.value = null;
pendingOrderIds.value = orders.map((order) => order.id);
const snapshot = await $api.getCart();
items.value = snapshot.items;
@@ -216,7 +224,7 @@ onMounted(() => void loadCart());
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" />
</strong>
</div>
<div v-if="couponsForShop(group.shopId).length > 0" class="coupon-picker">
<div v-if="!groupIntent && couponsForShop(group.shopId).length > 0" class="coupon-picker">
<span class="coupon-label">{{ t("checkout.coupon") }}</span>
<select v-model="selectedCoupon[group.shopId]" class="minput coupon-select">
<option value="">{{ t("checkout.noCoupon") }}</option>
+143 -17
View File
@@ -1,22 +1,78 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { collectiveProducts, lowestSku } from "~/mock/data";
import { ApiError, t as pick } from "@vmall/shared";
import type { GroupBuyingActivityView, GroupBuyIntent } from "@vmall/shared";
const { locale, t } = useI18n();
const products = collectiveProducts();
const { $api } = useNuxtApp();
const session = useSessionStore();
const cart = useCartStore();
const router = useRouter();
const activities = ref<GroupBuyingActivityView[]>([]);
const loading = ref(true);
const error = ref("");
const workingId = ref("");
const selectedGroup = reactive<Record<string, string>>({});
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
{ label: t("marketing.collectiveBreadcrumb") },
]);
function currencyOf(product: (typeof products)[number]["product"]): string {
return lowestSku(product)?.currency ?? "USD";
}
function priceMinorOf(product: (typeof products)[number]["product"]): number {
return lowestSku(product)?.price_minor ?? 0;
async function load(): Promise<void> {
loading.value = true;
try {
activities.value = await $api.listGroupBuyingActivities();
} catch {
activities.value = [];
} finally {
loading.value = false;
}
}
function chosenGroup(activityId: string): string {
return selectedGroup[activityId] ?? "";
}
function joined(activity: GroupBuyingActivityView, groupId: string): string {
const group = activity.open_groups.find((entry) => entry.id === groupId);
return group ? `${group.paid_member_count}/${group.required_members}` : "";
}
// Opening or joining both add the activity SKU to the cart and stash the
// intent; the server prices and validates it at checkout.
async function start(activity: GroupBuyingActivityView, open: boolean): Promise<void> {
if (!session.isLoggedIn) {
await navigateTo("/login");
return;
}
error.value = "";
const groupId = open ? null : chosenGroup(activity.id);
if (!open && !groupId) {
error.value = t("marketing.chooseGroup");
return;
}
workingId.value = activity.id;
try {
await $api.addCartItem(activity.sku_id, 1);
await cart.refresh();
useState<GroupBuyIntent | null>("checkout-group-intent", () => null).value = {
activity_id: activity.id,
sku_id: activity.sku_id,
group_id: groupId,
};
await router.push("/checkout");
} catch (err: unknown) {
error.value =
err instanceof ApiError ? err.message : t("marketing.groupFailed");
} finally {
workingId.value = "";
}
}
onMounted(() => {
session.hydrate();
void load();
});
</script>
<template>
@@ -27,19 +83,53 @@ function priceMinorOf(product: (typeof products)[number]["product"]): number {
<header class="section-heading">
<h1>{{ t("marketing.collectiveTitle") }}</h1>
</header>
<div v-if="products.length" class="collective-grid">
<article v-for="item in products" :key="item.product.id" class="collective-card hover-lift">
<NuxtLink :to="`/goods/${item.product.slug}`" class="product-image">
<img :src="item.product.images[0]" :alt="pick(item.product.name, locale)" loading="lazy" />
<p v-if="error" class="collective-error" role="alert">{{ error }}</p>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<div v-else-if="activities.length" class="collective-grid">
<article v-for="activity in activities" :key="activity.id" class="collective-card hover-lift">
<NuxtLink :to="`/goods/${activity.product_slug}`" class="product-image">
<img :src="activity.image || '/mock/product-1.svg'" :alt="pick(activity.product_name, locale)" loading="lazy" />
</NuxtLink>
<div class="product-body">
<NuxtLink :to="`/goods/${item.product.slug}`" class="product-name">{{ pick(item.product.name, locale) }}</NuxtLink>
<NuxtLink :to="`/goods/${activity.product_slug}`" class="product-name">{{ pick(activity.name, locale) }}</NuxtLink>
<div class="deal-meta">
<span class="deal-price"><PriceText :amount-minor="priceMinorOf(item.product)" :currency="currencyOf(item.product)" /></span>
<span class="group-tag">{{ t("marketing.peopleGroup", { n: item.need }) }}</span>
<span class="deal-price"><PriceText :amount-minor="activity.group_price_minor" :currency="activity.currency" /></span>
<span class="group-tag">{{ t("marketing.peopleGroup", { n: activity.required_members }) }}</span>
</div>
<p class="joined">{{ t("marketing.peopleJoined", { n: item.joined }) }}</p>
<NuxtLink :to="`/goods/${item.product.slug}`" class="deal-link">{{ t("marketing.joinGroup") }} <span aria-hidden="true"></span></NuxtLink>
<p class="joined">
<s><PriceText :amount-minor="activity.original_price_minor" :currency="activity.original_currency" /></s>
</p>
<div v-if="activity.open_groups.length" class="group-list">
<label v-for="group in activity.open_groups" :key="group.id" class="group-option">
<input v-model="selectedGroup[activity.id]" type="radio" :name="`group-${activity.id}`" :value="group.id" />
<span>{{ t("marketing.paidMembers", { n: group.paid_member_count, total: group.required_members }) }}</span>
<span class="group-expiry">{{ group.expires_at.slice(5, 16).replace('T', ' ') }}</span>
</label>
</div>
<p v-else class="joined">{{ t("marketing.noOpenGroups") }}</p>
<div class="group-actions">
<button
type="button"
class="mbtn red"
:disabled="workingId === activity.id"
@click="start(activity, true)"
>
{{ t("marketing.openGroup") }}
</button>
<button
type="button"
class="mbtn"
:disabled="workingId === activity.id || !chosenGroup(activity.id)"
@click="start(activity, false)"
>
{{ t("marketing.joinGroup") }}
</button>
</div>
<p v-if="chosenGroup(activity.id)" class="joined">
{{ joined(activity, chosenGroup(activity.id)) }}
</p>
</div>
</article>
</div>
@@ -168,4 +258,40 @@ function priceMinorOf(product: (typeof products)[number]["product"]): number {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.collective-error {
margin: 12px 0 0;
padding: 10px 14px;
color: #b42318;
background: #fff1f0;
border: 1px solid #ffd6d2;
}
.group-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px 0;
}
.group-option {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mall-muted);
cursor: pointer;
}
.group-expiry {
margin-left: auto;
color: var(--mall-faint);
font-size: 11px;
}
.group-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.group-actions .mbtn {
flex: 1;
padding: 5px 8px;
text-align: center;
}
</style>
+6 -1
View File
@@ -22,7 +22,8 @@ type LiveDomain =
| "addresses"
| "coupons"
| "points"
| "flashSales";
| "flashSales"
| "groupBuying";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
@@ -80,6 +81,9 @@ const LIVE_PICKS = {
redeemPoints: a.redeemPoints,
}),
flashSales: (a: ApiClient) => ({ listFlashSales: a.listFlashSales }),
groupBuying: (a: ApiClient) => ({
listGroupBuyingActivities: a.listGroupBuyingActivities,
}),
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
@@ -101,6 +105,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"coupons",
"points",
"flashSales",
"groupBuying",
];
export default defineNuxtPlugin(() => {
+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;