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:
@@ -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);
|
||||
|
||||
@@ -107,6 +107,12 @@ onMounted(() => void loadOrders());
|
||||
</strong>
|
||||
</div>
|
||||
<footer class="order-total">
|
||||
<template v-if="order.discount_minor > 0">
|
||||
<span>{{ t("checkout.couponDiscount") }}</span>
|
||||
<span class="discount-value">
|
||||
−<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ t("checkout.total") }}</span>
|
||||
<strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong>
|
||||
</footer>
|
||||
@@ -224,6 +230,9 @@ onMounted(() => void loadOrders());
|
||||
color: var(--mall-red);
|
||||
font-size: 16px;
|
||||
}
|
||||
.discount-value {
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.payment-panel {
|
||||
margin-top: 16px;
|
||||
padding: 0 18px 16px;
|
||||
|
||||
Reference in New Issue
Block a user