Opening or joining a group now sets the activity SKU to quantity 1 instead of accumulating, so a retry after a failed checkout - or a second click - can no longer push the line to quantity 2 and then fail the quantity-1 intent with an opaque error. The stashed group intent is cleared when the checkout page unmounts without submitting, and when its SKU is no longer in the cart, so an abandoned intent cannot silently reprice a later order or keep the coupon picker hidden.
301 lines
8.1 KiB
Vue
301 lines
8.1 KiB
Vue
<script setup lang="ts">
|
|
import { ApiError, t as pick } from "@vmall/shared";
|
|
import type { GroupBuyingActivityView, GroupBuyIntent } from "@vmall/shared";
|
|
|
|
const { locale, t } = useI18n();
|
|
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") },
|
|
]);
|
|
|
|
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);
|
|
// The intent is quantity-1 only, so normalise in case the SKU was already in
|
|
// the cart or a previous attempt failed.
|
|
await $api.updateCartItem(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>
|
|
<div class="collective-page">
|
|
<UiBreadcrumb :items="breadcrumb" />
|
|
<section class="w1200 marketing-section">
|
|
<img class="marketing-banner" src="/mock/collective-banner.svg" :alt="t('marketing.collectiveBannerAlt')" />
|
|
<header class="section-heading">
|
|
<h1>{{ t("marketing.collectiveTitle") }}</h1>
|
|
</header>
|
|
<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/${activity.product_slug}`" class="product-name">{{ pick(activity.name, locale) }}</NuxtLink>
|
|
<div class="deal-meta">
|
|
<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">
|
|
<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>
|
|
<UiEmptyState v-else :text="t('marketing.noCollectiveProducts')" />
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.collective-page {
|
|
padding-bottom: 60px;
|
|
}
|
|
.marketing-section {
|
|
background: #fff;
|
|
}
|
|
.marketing-banner {
|
|
display: block;
|
|
width: 100%;
|
|
height: 350px;
|
|
object-fit: cover;
|
|
}
|
|
.section-heading {
|
|
margin-top: 20px;
|
|
border: 1px solid var(--mall-line);
|
|
background: #fafafa;
|
|
padding: 14px 18px;
|
|
}
|
|
.section-heading h1 {
|
|
margin: 0;
|
|
border-left: 3px solid var(--mall-red);
|
|
padding-left: 10px;
|
|
font-size: 17px;
|
|
font-weight: 500;
|
|
}
|
|
.collective-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 15px;
|
|
margin-top: 20px;
|
|
}
|
|
.collective-card {
|
|
min-width: 0;
|
|
border: 1px solid var(--mall-line);
|
|
background: #fff;
|
|
}
|
|
.product-image {
|
|
display: flex;
|
|
height: 180px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 12px;
|
|
}
|
|
.product-image img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
.product-body {
|
|
padding: 0 12px 13px;
|
|
}
|
|
.product-name {
|
|
display: block;
|
|
height: 36px;
|
|
overflow: hidden;
|
|
color: var(--mall-ink);
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
text-decoration: none;
|
|
}
|
|
.product-name:hover {
|
|
color: var(--mall-red);
|
|
}
|
|
.deal-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 5px;
|
|
margin-top: 11px;
|
|
}
|
|
.deal-price {
|
|
color: var(--mall-red);
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
}
|
|
.group-tag {
|
|
flex: 0 0 auto;
|
|
border: 1px solid #f2b9bc;
|
|
background: #fff4f4;
|
|
padding: 2px 5px;
|
|
color: var(--mall-red);
|
|
font-size: 11px;
|
|
}
|
|
.joined {
|
|
margin: 7px 0 10px;
|
|
color: var(--mall-faint);
|
|
font-size: 12px;
|
|
}
|
|
.deal-link {
|
|
display: block;
|
|
border-top: 1px solid var(--mall-line);
|
|
padding-top: 9px;
|
|
color: var(--mall-red);
|
|
font-size: 12px;
|
|
text-decoration: none;
|
|
}
|
|
.deal-link span {
|
|
float: right;
|
|
font-size: 16px;
|
|
line-height: 11px;
|
|
}
|
|
@media (max-width: 1240px) {
|
|
.w1200 {
|
|
width: calc(100% - 32px);
|
|
}
|
|
}
|
|
@media (max-width: 900px) {
|
|
.collective-grid {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
}
|
|
@media (max-width: 600px) {
|
|
.marketing-banner {
|
|
height: 220px;
|
|
}
|
|
.collective-grid {
|
|
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>
|