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
+45 -6
View File
@@ -1,16 +1,42 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { MOCK_COUPONS } from "~/mock/data";
import type { Coupon } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const coupons = ref<Coupon[]>([]);
const loading = ref(true);
const error = ref("");
const statusLabels = computed<Record<string, string>>(() => ({
claimed: t("user.couponClaimed"),
redeemed: t("user.couponRedeemed"),
expired: t("user.couponExpired"),
}));
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
coupons.value = await $api.listMyCoupons();
} catch {
error.value = t("user.loadFailed");
} finally {
loading.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<section class="mpanel coupons-panel">
<h1 class="mpanel-title">{{ t("user.couponsTitle") }}</h1>
<UiEmptyState v-if="MOCK_COUPONS.length === 0" :text="t('user.noCoupons')" />
<p v-if="error" class="error-message">{{ error }}</p>
<div v-else-if="loading" class="loading-state">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="coupons.length === 0" :text="t('user.noCoupons')" />
<table v-else class="mtable">
<thead>
<tr>
@@ -18,14 +44,16 @@ const { locale, t } = useI18n();
<th>{{ t("user.amount") }}</th>
<th>{{ t("user.threshold") }}</th>
<th>{{ t("user.expiresAt") }}</th>
<th>{{ t("user.couponStatus") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="coupon in MOCK_COUPONS" :key="coupon.id">
<tr v-for="coupon in coupons" :key="coupon.id">
<td>{{ pick(coupon.title, locale) }}</td>
<td><PriceText :amount-minor="coupon.amountMinor" :currency="coupon.currency" /></td>
<td><PriceText :amount-minor="coupon.thresholdMinor" :currency="coupon.currency" /></td>
<td>{{ coupon.expiresAt }}</td>
<td><PriceText :amount-minor="coupon.amount_minor" :currency="coupon.currency" /></td>
<td><PriceText :amount-minor="coupon.threshold_minor" :currency="coupon.currency" /></td>
<td>{{ coupon.ends_at.slice(0, 10) }}</td>
<td>{{ statusLabels[coupon.status] ?? coupon.status }}</td>
</tr>
</tbody>
</table>
@@ -36,4 +64,15 @@ const { locale, t } = useI18n();
.coupons-panel {
min-height: 560px;
}
.error-message {
margin: 0 0 16px;
padding: 10px 14px;
color: #b42318;
background: #fff1f0;
border: 1px solid #ffd6d2;
}
.loading-state {
padding: 40px 0;
color: var(--mall-muted);
}
</style>