feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs

This commit is contained in:
Chengdong Zhang
2026-09-17 13:34:38 +08:00
parent dc9fd31c5e
commit 653f13161a
85 changed files with 4327 additions and 106 deletions
+136
View File
@@ -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>