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,6 +1,6 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Address, AddressBookEntry, CartItem, LocalizedText } from "@vmall/shared";
import type { Address, AddressBookEntry, CartItem, Coupon, LocalizedText } from "@vmall/shared";
type CheckoutGroup = {
shopId: string;
@@ -19,6 +19,9 @@ const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
const items = ref<CartItem[]>([]);
const addresses = ref<AddressBookEntry[]>([]);
/** Owned, still-claimable coupons, by shop. */
const coupons = ref<Coupon[]>([]);
const selectedCoupon = reactive<Record<string, string>>({});
const selectedAddressId = ref("");
const remark = ref("");
const loading = ref(true);
@@ -67,6 +70,17 @@ function imageFor(item: CartItem): string {
return item.image ?? "/mock/product-1.svg";
}
/** One coupon per shop order; the server decides eligibility and the amount. */
function couponsForShop(shopId: string): Coupon[] {
return coupons.value.filter((c) => c.shop_id === shopId && c.status === "claimed");
}
function selectedCouponFor(shopId: string): Coupon | null {
const id = selectedCoupon[shopId];
if (!id) return null;
return coupons.value.find((c) => c.id === id) ?? null;
}
function selectedAddress(): Address | null {
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
if (saved) {
@@ -92,9 +106,15 @@ async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
try {
const [snapshot, list] = await Promise.all([$api.getCart(), $api.listMyAddresses()]);
const [snapshot, list, couponList] = await Promise.all([
$api.getCart(),
$api.listMyAddresses(),
// Coupons are optional: a failure must not block checkout.
$api.listMyCoupons().catch(() => [] as Coupon[]),
]);
items.value = snapshot.items;
addresses.value = list;
coupons.value = couponList;
selectedAddressId.value =
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
if (items.value.length === 0) {
@@ -116,7 +136,11 @@ async function submitOrder(): Promise<void> {
submitting.value = true;
error.value = "";
try {
const orders = await $api.checkout(address, currency.value);
const couponByShop: Record<string, string> = {};
for (const [shopId, couponId] of Object.entries(selectedCoupon)) {
if (couponId) couponByShop[shopId] = couponId;
}
const orders = await $api.checkout(address, currency.value, couponByShop);
pendingOrderIds.value = orders.map((order) => order.id);
const snapshot = await $api.getCart();
items.value = snapshot.items;
@@ -192,6 +216,22 @@ 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">
<span class="coupon-label">{{ t("checkout.coupon") }}</span>
<select v-model="selectedCoupon[group.shopId]" class="minput coupon-select">
<option value="">{{ t("checkout.noCoupon") }}</option>
<option v-for="coupon in couponsForShop(group.shopId)" :key="coupon.id" :value="coupon.id">
{{ pick(coupon.title, locale) }}
</option>
</select>
<span v-if="selectedCouponFor(group.shopId)" class="coupon-value">
<PriceText
:amount-minor="selectedCouponFor(group.shopId)?.amount_minor ?? 0"
:currency="selectedCouponFor(group.shopId)?.currency ?? sourceCurrency"
/>
</span>
</div>
</div>
</section>
@@ -348,10 +388,27 @@ onMounted(() => void loadCart());
color: var(--mall-red);
text-align: right;
}
.coupon-picker {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px 14px;
border-top: 1px solid #f5f5f5;
}
.coupon-label {
color: var(--mall-muted);
font-size: 13px;
}
.coupon-select {
max-width: 320px;
}
.coupon-value {
color: var(--mall-red);
font-size: 13px;
}
.remark-panel {
padding: 0 0 16px;
}
.remark-input {
}.remark-input {
display: block;
box-sizing: border-box;
width: calc(100% - 36px);