Files
vmall/apps/mall/pages/checkout/pay.vue
T
Chengdong Zhang 0d0e10b97b feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin
- Add @vmall/shared/theme.css tokens with html[data-accent] presets
- Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage,
  VAccentSwatch, useAccent) as a Nuxt module
- Convert all three apps to kit + utilities; delete ui.css/mall.css and
  every <style scoped>; consoles get accent presets, mall locked to red
- Fix VCard boolean prop default (padding) and PDP/store stale
  useAsyncData keys on param navigation
- Archive adopt-tailwind-design-system; new frontend-ui capability spec
2026-09-22 18:22:50 +08:00

130 lines
6.0 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="min-h-screen bg-bg font-sans text-text">
<div class="mx-auto max-w-mall 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="mb-4 border border-danger/30 bg-danger/10 px-3.5 py-2.5 text-danger">{{ 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="flex items-center justify-between border-b border-border 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="grid grid-cols-[56px_minmax(0,1fr)_auto_auto_auto] items-center gap-3 border-b border-bg px-4 py-3">
<img class="h-14 w-14 border border-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-xs text-muted">{{ 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-xs text-muted">× {{ item.qty }}</span>
<strong class="text-right text-primary"><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="mb-4 mt-0 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="h-4 w-4 accent-primary" type="radio" name="payment-method" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</VCard>
<footer class="mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border border-border bg-surface p-4 shadow-sm">
<span class="text-sm">{{ t("checkout.grandTotal") }}:</span>
<strong class="text-lg text-primary"><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="inline-flex items-center justify-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium hover:bg-bg" to="/">{{ t("checkout.backHome") }}</NuxtLink>
</VCard>
<div v-else class="py-8 text-center text-muted">{{ $t("common.loading") }}</div>
</div>
</div>
</template>