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:
@@ -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: "暂无待支付订单。",
|
||||
|
||||
@@ -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: "数量",
|
||||
|
||||
@@ -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: "订单号",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user