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.
283 lines
7.6 KiB
Vue
283 lines
7.6 KiB
Vue
<script setup lang="ts">
|
||
import { t as pick } from "@vmall/shared";
|
||
import type { Order } from "@vmall/shared";
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { $api } = useNuxtApp();
|
||
const { locale, t } = useI18n();
|
||
const { currency } = usePrefs();
|
||
const router = useRouter();
|
||
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
|
||
// Shared with the store directory; orders carry only a shop id.
|
||
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
|
||
|
||
function shopName(shopId: string): string {
|
||
const shop = shops.value.find((entry) => entry.id === shopId);
|
||
return shop ? pick(shop.name, locale.value) : t("checkout.shop");
|
||
}
|
||
|
||
const orders = ref<Order[]>([]);
|
||
const paymentMethod = ref("balance");
|
||
const loading = ref(true);
|
||
const paying = ref(false);
|
||
const error = ref("");
|
||
|
||
const stepLabels = computed(() => [
|
||
t("checkout.steps.cart"),
|
||
t("checkout.steps.order"),
|
||
t("checkout.steps.payment"),
|
||
t("checkout.steps.complete"),
|
||
]);
|
||
|
||
const paymentOptions = computed(() => [
|
||
{ value: "balance", label: t("checkout.balance") },
|
||
{ value: "wechat", label: t("checkout.wechat") },
|
||
{ value: "alipay", label: t("checkout.alipay") },
|
||
]);
|
||
|
||
const grandTotalMinor = computed(() => orders.value.reduce((total, order) => total + order.total_minor, 0));
|
||
const totalCurrency = computed(() => orders.value[0]?.currency ?? currency.value);
|
||
|
||
function imageFor(image: string | null): string {
|
||
return image ?? "/mock/product-1.svg";
|
||
}
|
||
|
||
async function loadOrders(): Promise<void> {
|
||
loading.value = true;
|
||
error.value = "";
|
||
if (pendingOrderIds.value.length === 0) {
|
||
loading.value = false;
|
||
return;
|
||
}
|
||
try {
|
||
orders.value = await Promise.all(pendingOrderIds.value.map((id) => $api.getOrder(id)));
|
||
} catch {
|
||
orders.value = [];
|
||
error.value = t("checkout.loadError");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function confirmPayment(): Promise<void> {
|
||
if (orders.value.length === 0) return;
|
||
paying.value = true;
|
||
error.value = "";
|
||
try {
|
||
for (const order of orders.value) await $api.payOrder(order.id);
|
||
pendingOrderIds.value = [];
|
||
await router.push("/checkout/success");
|
||
} catch {
|
||
error.value = t("checkout.payError");
|
||
} finally {
|
||
paying.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadOrders());
|
||
</script>
|
||
|
||
<template>
|
||
<div class="transaction-page pay-page">
|
||
<div class="w1200">
|
||
<UiStepBar :steps="stepLabels" :active="2" />
|
||
<header class="page-heading">
|
||
<h1>{{ t("checkout.payTitle") }}</h1>
|
||
</header>
|
||
|
||
<p v-if="error" class="error-message">{{ error }}</p>
|
||
<template v-if="!loading && orders.length > 0">
|
||
<section class="order-cards">
|
||
<article v-for="order in orders" :key="order.id" class="mpanel order-card">
|
||
<header class="order-heading">
|
||
<span>{{ t("checkout.orderNo") }}: {{ order.order_no }}</span>
|
||
<span class="shop-name">{{ shopName(order.shop_id) }}</span>
|
||
</header>
|
||
<div v-for="item in order.items" :key="item.id" class="order-item">
|
||
<img :src="imageFor(item.image)" :alt="pick(item.product_name, locale)" />
|
||
<div class="item-info">
|
||
<strong>{{ pick(item.product_name, locale) }}</strong>
|
||
<span class="muted">{{ item.sku_code }}</span>
|
||
</div>
|
||
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
|
||
<span class="qty">× {{ item.qty }}</span>
|
||
<strong class="line-total">
|
||
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" />
|
||
</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>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="mpanel payment-panel">
|
||
<h2 class="section-title">{{ t("checkout.paymentTitle") }}</h2>
|
||
<label v-for="option in paymentOptions" :key="option.value" class="payment-option">
|
||
<input v-model="paymentMethod" type="radio" name="payment-method" :value="option.value" />
|
||
<span>{{ option.label }}</span>
|
||
</label>
|
||
</section>
|
||
|
||
<footer class="submit-bar mpanel">
|
||
<span class="total-label">{{ t("checkout.grandTotal") }}:</span>
|
||
<strong class="total-price"><PriceText :amount-minor="grandTotalMinor" :currency="totalCurrency" /></strong>
|
||
<button class="mbtn red" type="button" :disabled="paying" @click="confirmPayment">
|
||
{{ t("checkout.confirmPay") }}
|
||
</button>
|
||
</footer>
|
||
</template>
|
||
<div v-else-if="!loading" class="empty-wrap mpanel">
|
||
<UiEmptyState :text="t('checkout.noPendingOrders')" />
|
||
<NuxtLink class="mbtn gray" to="/">{{ t("checkout.backHome") }}</NuxtLink>
|
||
</div>
|
||
<div v-else class="loading-state">{{ $t("common.loading") }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.transaction-page {
|
||
padding: 24px 0 56px;
|
||
background: #f5f5f5;
|
||
min-height: 60vh;
|
||
}
|
||
.page-heading {
|
||
margin: 0 0 16px;
|
||
}
|
||
.page-heading h1 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 500;
|
||
}
|
||
.error-message {
|
||
margin: 0 0 16px;
|
||
padding: 10px 14px;
|
||
color: #b42318;
|
||
background: #fff1f0;
|
||
border: 1px solid #ffd6d2;
|
||
}
|
||
.order-card {
|
||
padding: 0;
|
||
overflow: hidden;
|
||
}
|
||
.order-card + .order-card {
|
||
margin-top: 16px;
|
||
}
|
||
.order-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid var(--mall-line);
|
||
font-size: 13px;
|
||
}
|
||
.shop-name {
|
||
color: var(--mall-muted);
|
||
}
|
||
.order-item {
|
||
display: grid;
|
||
grid-template-columns: 56px minmax(0, 1fr) 120px 64px 130px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 18px;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
}
|
||
.order-item > img {
|
||
width: 56px;
|
||
height: 56px;
|
||
object-fit: cover;
|
||
border: 1px solid var(--mall-line);
|
||
}
|
||
.item-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
min-width: 0;
|
||
}
|
||
.item-info strong {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.muted,
|
||
.qty {
|
||
color: var(--mall-muted);
|
||
font-size: 12px;
|
||
}
|
||
.line-total {
|
||
color: var(--mall-red);
|
||
text-align: right;
|
||
}
|
||
.order-total {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 18px;
|
||
padding: 14px 18px;
|
||
color: var(--mall-muted);
|
||
font-size: 13px;
|
||
}
|
||
.order-total strong {
|
||
color: var(--mall-red);
|
||
font-size: 16px;
|
||
}
|
||
.discount-value {
|
||
color: var(--mall-red);
|
||
}
|
||
.payment-panel {
|
||
margin-top: 16px;
|
||
padding: 0 18px 16px;
|
||
}
|
||
.section-title {
|
||
margin: 0 -18px 12px;
|
||
padding: 16px 18px;
|
||
border-bottom: 1px solid var(--mall-line);
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
.payment-option {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-right: 30px;
|
||
padding: 8px 0;
|
||
cursor: pointer;
|
||
}
|
||
.submit-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 18px;
|
||
padding: 14px 18px;
|
||
}
|
||
.total-label {
|
||
color: var(--mall-muted);
|
||
font-size: 13px;
|
||
}
|
||
.total-price {
|
||
color: var(--mall-red);
|
||
font-size: 20px;
|
||
}
|
||
.empty-wrap {
|
||
padding-bottom: 28px;
|
||
text-align: center;
|
||
}
|
||
.empty-wrap :deep(.empty) {
|
||
padding-bottom: 20px;
|
||
}
|
||
.loading-state {
|
||
padding: 70px 0;
|
||
text-align: center;
|
||
color: var(--mall-muted);
|
||
}
|
||
</style>
|