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
+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)