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",
|
alipay: "Alipay",
|
||||||
payTitle: "Pay for your order",
|
payTitle: "Pay for your order",
|
||||||
orderNo: "Order no.",
|
orderNo: "Order no.",
|
||||||
|
coupon: "Coupon",
|
||||||
|
noCoupon: "No coupon",
|
||||||
|
couponDiscount: "Coupon discount",
|
||||||
grandTotal: "Total payment",
|
grandTotal: "Total payment",
|
||||||
confirmPay: "Confirm payment",
|
confirmPay: "Confirm payment",
|
||||||
noPendingOrders: "There are no orders waiting for payment.",
|
noPendingOrders: "There are no orders waiting for payment.",
|
||||||
@@ -58,6 +61,9 @@ export default {
|
|||||||
alipay: "支付宝",
|
alipay: "支付宝",
|
||||||
payTitle: "订单支付",
|
payTitle: "订单支付",
|
||||||
orderNo: "订单号",
|
orderNo: "订单号",
|
||||||
|
coupon: "优惠券",
|
||||||
|
noCoupon: "不使用优惠券",
|
||||||
|
couponDiscount: "优惠券抵扣",
|
||||||
grandTotal: "应付总额",
|
grandTotal: "应付总额",
|
||||||
confirmPay: "确认支付",
|
confirmPay: "确认支付",
|
||||||
noPendingOrders: "暂无待支付订单。",
|
noPendingOrders: "暂无待支付订单。",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export default {
|
|||||||
coupon: "Coupon",
|
coupon: "Coupon",
|
||||||
coupons: "Available coupons",
|
coupons: "Available coupons",
|
||||||
claim: "Claim",
|
claim: "Claim",
|
||||||
|
claimed: "Claimed",
|
||||||
|
claimFailed: "Unable to claim this coupon",
|
||||||
|
loginToClaim: "Sign in to claim",
|
||||||
choose: "Choose",
|
choose: "Choose",
|
||||||
selected: "Selected",
|
selected: "Selected",
|
||||||
quantity: "Quantity",
|
quantity: "Quantity",
|
||||||
@@ -58,6 +61,9 @@ export default {
|
|||||||
coupon: "优惠券",
|
coupon: "优惠券",
|
||||||
coupons: "可用优惠券",
|
coupons: "可用优惠券",
|
||||||
claim: "领取",
|
claim: "领取",
|
||||||
|
claimed: "已领取",
|
||||||
|
claimFailed: "优惠券领取失败",
|
||||||
|
loginToClaim: "登录后领取",
|
||||||
choose: "选择",
|
choose: "选择",
|
||||||
selected: "已选",
|
selected: "已选",
|
||||||
quantity: "数量",
|
quantity: "数量",
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export default {
|
|||||||
threshold: "Minimum spend",
|
threshold: "Minimum spend",
|
||||||
expiresAt: "Expires",
|
expiresAt: "Expires",
|
||||||
noCoupons: "No coupons yet.",
|
noCoupons: "No coupons yet.",
|
||||||
|
couponStatus: "Status",
|
||||||
|
couponClaimed: "Claimed",
|
||||||
|
couponRedeemed: "Used",
|
||||||
|
couponExpired: "Expired",
|
||||||
invoicesTitle: "My invoices",
|
invoicesTitle: "My invoices",
|
||||||
invoiceNo: "Invoice no.",
|
invoiceNo: "Invoice no.",
|
||||||
invoiceOrderNo: "Order no.",
|
invoiceOrderNo: "Order no.",
|
||||||
@@ -176,6 +180,10 @@ export default {
|
|||||||
threshold: "使用门槛",
|
threshold: "使用门槛",
|
||||||
expiresAt: "有效期至",
|
expiresAt: "有效期至",
|
||||||
noCoupons: "暂无优惠券。",
|
noCoupons: "暂无优惠券。",
|
||||||
|
couponStatus: "状态",
|
||||||
|
couponClaimed: "未使用",
|
||||||
|
couponRedeemed: "已使用",
|
||||||
|
couponExpired: "已过期",
|
||||||
invoicesTitle: "我的发票",
|
invoicesTitle: "我的发票",
|
||||||
invoiceNo: "发票号",
|
invoiceNo: "发票号",
|
||||||
invoiceOrderNo: "订单号",
|
invoiceOrderNo: "订单号",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
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 = {
|
type CheckoutGroup = {
|
||||||
shopId: string;
|
shopId: string;
|
||||||
@@ -19,6 +19,9 @@ const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
|
|||||||
|
|
||||||
const items = ref<CartItem[]>([]);
|
const items = ref<CartItem[]>([]);
|
||||||
const addresses = ref<AddressBookEntry[]>([]);
|
const addresses = ref<AddressBookEntry[]>([]);
|
||||||
|
/** Owned, still-claimable coupons, by shop. */
|
||||||
|
const coupons = ref<Coupon[]>([]);
|
||||||
|
const selectedCoupon = reactive<Record<string, string>>({});
|
||||||
const selectedAddressId = ref("");
|
const selectedAddressId = ref("");
|
||||||
const remark = ref("");
|
const remark = ref("");
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -67,6 +70,17 @@ function imageFor(item: CartItem): string {
|
|||||||
return item.image ?? "/mock/product-1.svg";
|
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 {
|
function selectedAddress(): Address | null {
|
||||||
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
|
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -92,9 +106,15 @@ async function loadCart(): Promise<void> {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
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;
|
items.value = snapshot.items;
|
||||||
addresses.value = list;
|
addresses.value = list;
|
||||||
|
coupons.value = couponList;
|
||||||
selectedAddressId.value =
|
selectedAddressId.value =
|
||||||
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
|
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
|
||||||
if (items.value.length === 0) {
|
if (items.value.length === 0) {
|
||||||
@@ -116,7 +136,11 @@ async function submitOrder(): Promise<void> {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
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);
|
pendingOrderIds.value = orders.map((order) => order.id);
|
||||||
const snapshot = await $api.getCart();
|
const snapshot = await $api.getCart();
|
||||||
items.value = snapshot.items;
|
items.value = snapshot.items;
|
||||||
@@ -192,6 +216,22 @@ onMounted(() => void loadCart());
|
|||||||
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" />
|
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" />
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -348,10 +388,27 @@ onMounted(() => void loadCart());
|
|||||||
color: var(--mall-red);
|
color: var(--mall-red);
|
||||||
text-align: right;
|
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 {
|
.remark-panel {
|
||||||
padding: 0 0 16px;
|
padding: 0 0 16px;
|
||||||
}
|
}.remark-input {
|
||||||
.remark-input {
|
|
||||||
display: block;
|
display: block;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: calc(100% - 36px);
|
width: calc(100% - 36px);
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ onMounted(() => void loadOrders());
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<footer class="order-total">
|
<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>
|
<span>{{ t("checkout.total") }}</span>
|
||||||
<strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong>
|
<strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -224,6 +230,9 @@ onMounted(() => void loadOrders());
|
|||||||
color: var(--mall-red);
|
color: var(--mall-red);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
.discount-value {
|
||||||
|
color: var(--mall-red);
|
||||||
|
}
|
||||||
.payment-panel {
|
.payment-panel {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 0 18px 16px;
|
padding: 0 18px 16px;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import { ApiError } from "@vmall/shared";
|
import { ApiError } from "@vmall/shared";
|
||||||
import type { Category, Product, Sku } from "@vmall/shared";
|
import type { Category, CouponTemplate, Product, Sku } from "@vmall/shared";
|
||||||
import { MOCK_COUPONS } from "~/mock/data";
|
|
||||||
import { lowestSku } from "~/utils/product";
|
import { lowestSku } from "~/utils/product";
|
||||||
import { useCartStore } from "~/stores/cart";
|
import { useCartStore } from "~/stores/cart";
|
||||||
|
import { useSessionStore } from "~/stores/session";
|
||||||
|
|
||||||
type AttributeGroup = { key: string; values: string[] };
|
type AttributeGroup = { key: string; values: string[] };
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ const router = useRouter();
|
|||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const { $api } = useNuxtApp();
|
const { $api } = useNuxtApp();
|
||||||
const cart = useCartStore();
|
const cart = useCartStore();
|
||||||
|
const session = useSessionStore();
|
||||||
|
|
||||||
const routeId = computed(() => {
|
const routeId = computed(() => {
|
||||||
const value = route.params.id;
|
const value = route.params.id;
|
||||||
@@ -33,9 +34,15 @@ const { data: pageData } = await useAsyncData(
|
|||||||
shop_id: current.shop_id,
|
shop_id: current.shop_id,
|
||||||
per_page: 6,
|
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 {
|
return {
|
||||||
product: current,
|
product: current,
|
||||||
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
|
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
|
||||||
|
coupons,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ watch: [routeId], default: () => null },
|
{ watch: [routeId], default: () => null },
|
||||||
@@ -54,11 +61,32 @@ const detail = computed(() => {
|
|||||||
product: current,
|
product: current,
|
||||||
// Null when the shop has no public profile; the card is then not rendered.
|
// 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,
|
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
|
||||||
coupons: MOCK_COUPONS,
|
coupons: pageData.value?.coupons ?? [],
|
||||||
salesRank: related.value,
|
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 store = computed(() => detail.value?.store ?? null);
|
||||||
const selectedAttributes = reactive<Record<string, string>>({});
|
const selectedAttributes = reactive<Record<string, string>>({});
|
||||||
const quantity = ref(1);
|
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>
|
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="coupon-row">
|
<div v-if="detail.coupons.length > 0" class="coupon-row">
|
||||||
<span class="label">{{ t("product.coupons") }}</span>
|
<span class="label">{{ t("product.coupons") }}</span>
|
||||||
<div class="coupon-list">
|
<div class="coupon-list">
|
||||||
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
|
<span v-for="coupon in detail.coupons" :key="coupon.id" class="coupon">
|
||||||
{{ pick(coupon.title, locale) }}
|
{{ 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>
|
||||||
|
<span v-if="claimError" class="coupon-error">{{ claimError }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -490,6 +527,26 @@ h1 {
|
|||||||
color: var(--mall-muted);
|
color: var(--mall-muted);
|
||||||
font-size: 10px;
|
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 {
|
.attribute-values button {
|
||||||
min-width: 66px;
|
min-width: 66px;
|
||||||
border: 1px solid var(--mall-line-dark);
|
border: 1px solid var(--mall-line-dark);
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import { MOCK_COUPONS } from "~/mock/data";
|
import type { Coupon } from "@vmall/shared";
|
||||||
|
|
||||||
definePageMeta({ middleware: "auth" });
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mpanel coupons-panel">
|
<section class="mpanel coupons-panel">
|
||||||
<h1 class="mpanel-title">{{ t("user.couponsTitle") }}</h1>
|
<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">
|
<table v-else class="mtable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -18,14 +44,16 @@ const { locale, t } = useI18n();
|
|||||||
<th>{{ t("user.amount") }}</th>
|
<th>{{ t("user.amount") }}</th>
|
||||||
<th>{{ t("user.threshold") }}</th>
|
<th>{{ t("user.threshold") }}</th>
|
||||||
<th>{{ t("user.expiresAt") }}</th>
|
<th>{{ t("user.expiresAt") }}</th>
|
||||||
|
<th>{{ t("user.couponStatus") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>{{ pick(coupon.title, locale) }}</td>
|
||||||
<td><PriceText :amount-minor="coupon.amountMinor" :currency="coupon.currency" /></td>
|
<td><PriceText :amount-minor="coupon.amount_minor" :currency="coupon.currency" /></td>
|
||||||
<td><PriceText :amount-minor="coupon.thresholdMinor" :currency="coupon.currency" /></td>
|
<td><PriceText :amount-minor="coupon.threshold_minor" :currency="coupon.currency" /></td>
|
||||||
<td>{{ coupon.expiresAt }}</td>
|
<td>{{ coupon.ends_at.slice(0, 10) }}</td>
|
||||||
|
<td>{{ statusLabels[coupon.status] ?? coupon.status }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -36,4 +64,15 @@ const { locale, t } = useI18n();
|
|||||||
.coupons-panel {
|
.coupons-panel {
|
||||||
min-height: 560px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
<p class="order-total">{{ t("user.total") }}: <PriceText :amount-minor="order.total_minor" :currency="order.currency" /></p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -218,6 +222,12 @@ onMounted(() => {
|
|||||||
border: 1px solid var(--mall-line);
|
border: 1px solid var(--mall-line);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
.order-discount {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mall-red);
|
||||||
|
}
|
||||||
.order-total {
|
.order-total {
|
||||||
margin: 16px 0 0;
|
margin: 16px 0 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -173,6 +173,34 @@ for (const def of SHOPS) {
|
|||||||
ownerTokens.set(def.slug, await ensureShop(def));
|
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
|
// 3. demo customer
|
||||||
await call("POST", "/auth/register", {
|
await call("POST", "/auth/register", {
|
||||||
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
|
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);
|
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)
|
// 4. categories (reference data from the migrations)
|
||||||
|
|||||||
Reference in New Issue
Block a user