feat(mall): claim and redeem shop coupons from the storefront

The product page loads a shop's claimable coupons through the shared client and
claims them in place; the buyer coupon list reads owned snapshots with their
claimed/redeemed/expired state. Checkout offers each shop order at most one
owned coupon and submits only the choice; the pay page and order detail render
the server-persisted discount and reduced total, never a client figure.

Coupon fixtures leave the pages; the fixed-data adapter still serves the coupon
domain as the rollback path. The demo seed creates two deterministic templates
and claims them for the demo customer.
This commit is contained in:
2026-09-18 12:12:51 +00:00
parent 23955434c6
commit 0eb94f2ba1
9 changed files with 249 additions and 16 deletions
+62 -5
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { ApiError } from "@vmall/shared";
import type { Category, Product, Sku } from "@vmall/shared";
import { MOCK_COUPONS } from "~/mock/data";
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
import { lowestSku } from "~/utils/product";
import { useCartStore } from "~/stores/cart";
import { useSessionStore } from "~/stores/session";
type AttributeGroup = { key: string; values: string[] };
@@ -13,6 +13,7 @@ const router = useRouter();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const cart = useCartStore();
const session = useSessionStore();
const routeId = computed(() => {
const value = route.params.id;
@@ -33,9 +34,15 @@ const { data: pageData } = await useAsyncData(
shop_id: current.shop_id,
per_page: 6,
});
// Claimable coupons are live; the store card stays local display content.
// A coupon failure must not blank the product page.
const coupons = await $api
.listShopCouponTemplates(current.shop_id)
.catch(() => [] as CouponTemplate[]);
return {
product: current,
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
coupons,
};
},
{ watch: [routeId], default: () => null },
@@ -54,11 +61,32 @@ const detail = computed(() => {
product: current,
// Null when the shop has no public profile; the card is then not rendered.
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
coupons: MOCK_COUPONS,
coupons: pageData.value?.coupons ?? [],
salesRank: related.value,
};
});
const claimingId = ref("");
const claimError = ref("");
const claimedIds = ref<Set<string>>(new Set());
async function claim(coupon: CouponTemplate): Promise<void> {
if (!session.isLoggedIn) {
await navigateTo("/login");
return;
}
claimingId.value = coupon.id;
claimError.value = "";
try {
const owned = await $api.claimCoupon(coupon.id);
claimedIds.value = new Set(claimedIds.value).add(owned.template_id ?? coupon.id);
} catch {
claimError.value = t("product.claimFailed");
} finally {
claimingId.value = "";
}
}
const store = computed(() => detail.value?.store ?? null);
const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1);
@@ -243,13 +271,22 @@ const detailImages = computed(() => product.value?.images ?? []);
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
</div>
<div class="coupon-row">
<div v-if="detail.coupons.length > 0" class="coupon-row">
<span class="label">{{ t("product.coupons") }}</span>
<div class="coupon-list">
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
{{ pick(coupon.title, locale) }}
<small>{{ t("product.expires", { date: coupon.expiresAt }) }}</small>
<small>{{ t("product.expires", { date: coupon.ends_at.slice(0, 10) }) }}</small>
<button
type="button"
class="coupon-claim"
:disabled="claimingId === coupon.id || claimedIds.has(coupon.id)"
@click="claim(coupon)"
>
{{ claimedIds.has(coupon.id) ? t("product.claimed") : t("product.claim") }}
</button>
</span>
<span v-if="claimError" class="coupon-error">{{ claimError }}</span>
</div>
</div>
@@ -490,6 +527,26 @@ h1 {
color: var(--mall-muted);
font-size: 10px;
}
.coupon-claim {
margin-top: 3px;
padding: 2px 8px;
border: 1px solid var(--mall-red);
background: transparent;
color: var(--mall-red);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.coupon-claim:disabled {
border-color: var(--mall-line-dark);
color: var(--mall-faint);
cursor: default;
}
.coupon-error {
align-self: center;
color: #b42318;
font-size: 11px;
}
.attribute-values button {
min-width: 66px;
border: 1px solid var(--mall-line-dark);