feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user