Files
vmall/apps/mall/pages/checkout/pay.vue
T

183 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="bg-bg text-text min-h-screen font-sans">
<div class="max-w-mall mx-auto px-4 py-6">
<UiStepBar :steps="stepLabels" :active="2" />
<header class="mb-4">
<h1 class="m-0 text-xl font-medium">{{ t("checkout.payTitle") }}</h1>
</header>
<p v-if="error" class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5">
{{ error }}
</p>
<template v-if="!loading && orders.length > 0">
<section class="mb-4 space-y-4">
<VCard v-for="order in orders" :key="order.id" :padded="false" class="overflow-hidden">
<header
class="border-border flex items-center justify-between border-b px-4 py-3 text-sm"
>
<span>{{ t("checkout.orderNo") }}: {{ order.order_no }}</span>
<span class="text-muted">{{ shopName(order.shop_id) }}</span>
</header>
<div
v-for="item in order.items"
:key="item.id"
class="border-bg grid grid-cols-[56px_minmax(0,1fr)_auto_auto_auto] items-center gap-3 border-b px-4 py-3"
>
<img
class="border-border h-14 w-14 border object-cover"
:src="imageFor(item.image)"
:alt="pick(item.product_name, locale)"
/>
<div class="flex min-w-0 flex-col gap-1">
<strong class="truncate">{{ pick(item.product_name, locale) }}</strong
><span class="text-muted text-xs">{{ item.sku_code }}</span
><VBadge v-if="item.flash_sale_item_id" tone="orange">{{
t("marketing.flashTag")
}}</VBadge>
</div>
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
<span class="text-muted text-xs">× {{ item.qty }}</span>
<strong class="text-primary text-right"
><PriceText
:amount-minor="item.unit_price_minor * item.qty"
:currency="order.currency"
/></strong>
</div>
<footer class="flex flex-wrap items-center justify-end gap-3 px-4 py-3 text-sm">
<template v-if="order.discount_minor > 0"
><span>{{ t("checkout.couponDiscount") }}</span
><span class="text-danger"
><PriceText
:amount-minor="order.discount_minor"
:currency="order.currency" /></span
></template>
<span>{{ t("checkout.total") }}</span
><strong class="text-primary"
><PriceText :amount-minor="order.total_minor" :currency="order.currency"
/></strong>
</footer>
</VCard>
</section>
<VCard class="mb-4 p-5">
<h2 class="mt-0 mb-4 text-base font-semibold">{{ t("checkout.paymentTitle") }}</h2>
<label
v-for="option in paymentOptions"
:key="option.value"
class="mr-5 inline-flex cursor-pointer items-center gap-2 text-sm"
>
<input
v-model="paymentMethod"
class="accent-primary h-4 w-4"
type="radio"
name="payment-method"
:value="option.value"
/>
<span>{{ option.label }}</span>
</label>
</VCard>
<footer
class="border-border bg-surface mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border p-4 shadow-sm"
>
<span class="text-sm">{{ t("checkout.grandTotal") }}:</span>
<strong class="text-primary text-lg"
><PriceText :amount-minor="grandTotalMinor" :currency="totalCurrency"
/></strong>
<VBtn variant="primary" type="button" :disabled="paying" @click="confirmPayment">{{
t("checkout.confirmPay")
}}</VBtn>
</footer>
</template>
<VCard v-else-if="!loading" class="flex flex-col items-center gap-4 p-8 text-center">
<UiEmptyState :text="t('checkout.noPendingOrders')" />
<NuxtLink
class="border-border bg-surface hover:bg-bg inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium"
to="/"
>{{ t("checkout.backHome") }}</NuxtLink
>
</VCard>
<div v-else class="text-muted py-8 text-center">{{ $t("common.loading") }}</div>
</div>
</div>
</template>