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

137 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 type { Address, Cart, Order } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const { $api } = useNuxtApp();
const { locale } = useI18n();
const { currency, convertAmount, formatAmount } = usePrice();
const cart = ref<Cart | null>(null);
const converted = ref<Record<string, number>>({});
const subtotalDisplay = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
const placedOrders = ref<Order[] | null>(null);
const address = reactive<Address>({ recipient: "", phone: "", country: "", region: "", city: "", line1: "", postal_code: "" });
const subtotalMinor = computed(() => (cart.value?.items ?? []).reduce((sum, item) => sum + (converted.value[item.sku_id] ?? 0) * item.qty, 0));
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function refreshPrices(): Promise<void> {
if (!cart.value) return;
const entries = await Promise.all(cart.value.items.map(async (item) => [item.sku_id, await convertAmount(item.unit_price_minor, item.currency)] as const));
converted.value = Object.fromEntries(entries);
subtotalDisplay.value = await formatAmount(subtotalMinor.value, currency.value);
}
async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
try {
cart.value = await $api.getCart();
await refreshPrices();
} catch (value) {
error.value = errorMessage(value);
} finally {
loading.value = false;
}
}
function validateAddress(): boolean {
const required: (keyof Address)[] = ["recipient", "phone", "country", "city", "line1"];
return required.every((key) => address[key].trim().length > 0);
}
async function placeOrder(): Promise<void> {
if (!validateAddress()) {
error.value = $t("common.required");
return;
}
if (!cart.value || cart.value.items.length === 0) return;
submitting.value = true;
error.value = "";
try {
placedOrders.value = await $api.checkout({ ...address }, currency.value);
await $api.getCart();
} catch (value) {
error.value = errorMessage(value);
} finally {
submitting.value = false;
}
}
watch(currency, () => void refreshPrices());
onMounted(() => void loadCart());
definePageMeta({ middleware: "auth" });
</script>
<template>
<section>
<h1 class="page-title">{{ placedOrders ? $t("mall.orderSuccess") : $t("mall.checkoutTitle") }}</h1>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-if="placedOrders" class="card success-card">
<h2>{{ $t("mall.orderNumbers") }}</h2>
<ul>
<li v-for="order in placedOrders" :key="order.id"><NuxtLink :to="`/orders/${order.id}`">{{ order.order_no }}</NuxtLink></li>
</ul>
<div class="row">
<NuxtLink class="btn primary" to="/orders">{{ $t("mall.viewOrders") }}</NuxtLink>
<NuxtLink class="btn" to="/">{{ $t("mall.backToShop") }}</NuxtLink>
</div>
</div>
<template v-else-if="cart && cart.items.length > 0">
<div class="checkout-layout">
<form class="card" @submit.prevent="placeOrder">
<h2>{{ $t("order.shippingAddress") }}</h2>
<div class="field-row">
<div class="field"><label for="recipient">{{ $t("order.recipient") }}</label><input id="recipient" v-model="address.recipient" required></div>
<div class="field"><label for="phone">{{ $t("order.phone") }}</label><input id="phone" v-model="address.phone" required></div>
</div>
<div class="field-row">
<div class="field"><label for="country">{{ $t("order.country") }}</label><input id="country" v-model="address.country" required></div>
<div class="field"><label for="region">{{ $t("order.region") }} <span class="muted">({{ $t("mall.shippingOptional") }})</span></label><input id="region" v-model="address.region"></div>
</div>
<div class="field-row">
<div class="field"><label for="city">{{ $t("order.city") }}</label><input id="city" v-model="address.city" required></div>
<div class="field"><label for="postal-code">{{ $t("order.postalCode") }} <span class="muted">({{ $t("mall.shippingOptional") }})</span></label><input id="postal-code" v-model="address.postal_code"></div>
</div>
<div class="field"><label for="line1">{{ $t("order.line1") }}</label><input id="line1" v-model="address.line1" required></div>
<button class="btn primary" type="submit" :disabled="submitting">{{ submitting ? $t("common.loading") : $t("mall.placeOrder") }}</button>
</form>
<aside class="card preview">
<h2>{{ $t("mall.orderPreview") }}</h2>
<div v-for="item in cart.items" :key="item.sku_id" class="preview-line">
<div><strong>{{ localized(item.product_name, locale) }}</strong><span class="muted">{{ item.sku_code }} × {{ item.qty }}</span></div>
<PriceText :amount-minor="(converted[item.sku_id] ?? 0) * item.qty" :currency="currency" />
</div>
<div class="preview-total row between"><strong>{{ $t("cart.subtotal") }}</strong><strong>{{ subtotalDisplay || $t("common.loading") }}</strong></div>
</aside>
</div>
</template>
<div v-else-if="!loading" class="card empty-state">
{{ $t("cart.emptyCart") }}
<NuxtLink class="btn mt" to="/">{{ $t("mall.backToShop") }}</NuxtLink>
</div>
</section>
</template>
<style scoped>
.checkout-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr); gap: 20px; align-items: start; }
.card h2 { font-size: 17px; margin: 0 0 16px; }
.preview-line { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.preview-line > div { display: grid; gap: 2px; }
.preview-total { padding-top: 14px; color: var(--primary); }
.success-card { max-width: 580px; }
.empty-state { text-align: center; }
@media (max-width: 760px) { .checkout-layout { grid-template-columns: 1fr; } }
</style>