137 lines
6.0 KiB
Vue
137 lines
6.0 KiB
Vue
<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>
|