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