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
+6
View File
@@ -23,6 +23,9 @@ export default {
alipay: "Alipay",
payTitle: "Pay for your order",
orderNo: "Order no.",
coupon: "Coupon",
noCoupon: "No coupon",
couponDiscount: "Coupon discount",
grandTotal: "Total payment",
confirmPay: "Confirm payment",
noPendingOrders: "There are no orders waiting for payment.",
@@ -58,6 +61,9 @@ export default {
alipay: "支付宝",
payTitle: "订单支付",
orderNo: "订单号",
coupon: "优惠券",
noCoupon: "不使用优惠券",
couponDiscount: "优惠券抵扣",
grandTotal: "应付总额",
confirmPay: "确认支付",
noPendingOrders: "暂无待支付订单。",
+6
View File
@@ -13,6 +13,9 @@ export default {
coupon: "Coupon",
coupons: "Available coupons",
claim: "Claim",
claimed: "Claimed",
claimFailed: "Unable to claim this coupon",
loginToClaim: "Sign in to claim",
choose: "Choose",
selected: "Selected",
quantity: "Quantity",
@@ -58,6 +61,9 @@ export default {
coupon: "优惠券",
coupons: "可用优惠券",
claim: "领取",
claimed: "已领取",
claimFailed: "优惠券领取失败",
loginToClaim: "登录后领取",
choose: "选择",
selected: "已选",
quantity: "数量",
+8
View File
@@ -83,6 +83,10 @@ export default {
threshold: "Minimum spend",
expiresAt: "Expires",
noCoupons: "No coupons yet.",
couponStatus: "Status",
couponClaimed: "Claimed",
couponRedeemed: "Used",
couponExpired: "Expired",
invoicesTitle: "My invoices",
invoiceNo: "Invoice no.",
invoiceOrderNo: "Order no.",
@@ -176,6 +180,10 @@ export default {
threshold: "使用门槛",
expiresAt: "有效期至",
noCoupons: "暂无优惠券。",
couponStatus: "状态",
couponClaimed: "未使用",
couponRedeemed: "已使用",
couponExpired: "已过期",
invoicesTitle: "我的发票",
invoiceNo: "发票号",
invoiceOrderNo: "订单号",
+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);
+9
View File
@@ -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;
+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);
+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>
+10
View File
@@ -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;
+41
View File
@@ -173,6 +173,34 @@ for (const def of SHOPS) {
ownerTokens.set(def.slug, await ensureShop(def));
}
// 2b. shop coupons: deterministic, idempotent by localized title.
const DEMO_COUPONS = [
{ title: { en: "$5 off over $59", zh: "满 59 减 5" }, amount_minor: 500, threshold_minor: 5900, stock: 200 },
{ title: { en: "$15 off over $199", zh: "满 199 减 15" }, amount_minor: 1500, threshold_minor: 19900, stock: 100 },
];
let demoShopId = "";
{
const owner = ownerTokens.get("demo-store");
const profile = await call("GET", "/shop/profile", { token: owner });
if (profile.status !== 200) fail("coupon shop profile", profile);
demoShopId = profile.data.id;
const listed = await call("GET", "/shop/coupon-templates", { token: owner });
if (listed.status !== 200) fail("list coupon templates", listed);
const known = new Set(listed.data.map((t) => t.title.en));
const starts_at = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
const ends_at = new Date(Date.now() + 365 * 24 * 3600 * 1000).toISOString();
for (const coupon of DEMO_COUPONS) {
if (known.has(coupon.title.en)) continue;
const res = await call("POST", "/shop/coupon-templates", {
token: owner,
body: { ...coupon, currency: "USD", enabled: true, starts_at, ends_at },
});
if (res.status !== 201) fail(`seed coupon ${coupon.title.en}`, res);
}
console.log(`coupons ready: ${DEMO_COUPONS.length}`);
}
// 3. demo customer
await call("POST", "/auth/register", {
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
@@ -194,6 +222,19 @@ await call("POST", "/auth/register", {
if (r.status !== 200 && r.status !== 201) fail("seed address", r);
}
}
// Claim the demo shop's coupons so the buyer starts with live ones.
const claimable = (await call("GET", `/shops/${demoShopId}/coupon-templates`)).data;
if (Array.isArray(claimable)) {
for (const template of claimable) {
const res = await call("POST", "/me/coupons", {
token: customerToken,
body: { template_id: template.id },
});
// 409 means a previous seed run already claimed it.
if (res.status !== 201 && res.status !== 409) fail(`claim coupon ${template.id}`, res);
}
}
}
// 4. categories (reference data from the migrations)