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,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>
|
||||
|
||||
@@ -118,6 +118,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="order.discount_minor > 0" class="order-discount">
|
||||
{{ t("checkout.couponDiscount") }}:
|
||||
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
|
||||
</p>
|
||||
<p class="order-total">{{ t("user.total") }}: <PriceText :amount-minor="order.total_minor" :currency="order.currency" /></p>
|
||||
</article>
|
||||
|
||||
@@ -218,6 +222,12 @@ onMounted(() => {
|
||||
border: 1px solid var(--mall-line);
|
||||
object-fit: contain;
|
||||
}
|
||||
.order-discount {
|
||||
margin: 16px 0 0;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
color: var(--mall-red);
|
||||
}
|
||||
.order-total {
|
||||
margin: 16px 0 0;
|
||||
text-align: right;
|
||||
|
||||
Reference in New Issue
Block a user