feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
}>();
|
||||
|
||||
const { formatPrice, currency } = usePrice();
|
||||
const display = ref("");
|
||||
const failed = ref(false);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (import.meta.server) return;
|
||||
failed.value = false;
|
||||
try {
|
||||
display.value = await formatPrice(props.amountMinor, props.currency, currency.value);
|
||||
} catch {
|
||||
failed.value = true;
|
||||
display.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.amountMinor, () => props.currency, currency],
|
||||
() => void refresh(),
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="failed" class="muted">{{ $t("mall.notAvailable") }}</span>
|
||||
<span v-else>{{ display || $t("common.loading") }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ status: string; kind: "order" | "shipment" | "invoice" }>();
|
||||
|
||||
const tone = computed(() => {
|
||||
if (["completed", "delivered", "issued"].includes(props.status)) return "green";
|
||||
if (["paid", "fulfilling", "shipped"].includes(props.status)) return "blue";
|
||||
if (["cancelled"].includes(props.status)) return "red";
|
||||
return "orange";
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
const root = props.kind === "order" ? "order.status" : props.kind === "shipment" ? "shipment.status" : "invoice.status";
|
||||
return `${root}.${props.status}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="tone">{{ $t(label) }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { formatMoney } from "@vmall/shared";
|
||||
import type { Currency } from "@vmall/shared";
|
||||
|
||||
export function usePrice() {
|
||||
const { currency } = usePrefs();
|
||||
const { locale } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const currencies = useState<Currency[]>("mall-currencies", () => []);
|
||||
const cache = useState<Map<string, number>>("mall-price-cache", () => new Map());
|
||||
const currencyRequest = useState<Promise<Currency[]> | null>("mall-currency-request", () => null);
|
||||
|
||||
async function ensureCurrencies(): Promise<Currency[]> {
|
||||
if (currencies.value.length > 0) return currencies.value;
|
||||
if (!currencyRequest.value) {
|
||||
currencyRequest.value = $api.listCurrencies().then((list) => {
|
||||
currencies.value = list;
|
||||
return list;
|
||||
}).finally(() => {
|
||||
currencyRequest.value = null;
|
||||
});
|
||||
}
|
||||
return currencyRequest.value;
|
||||
}
|
||||
|
||||
function exponentFor(code: string): number {
|
||||
return currencies.value.find((item) => item.code === code)?.exponent ?? 2;
|
||||
}
|
||||
|
||||
async function convertAmount(amountMinor: number, from: string, to = currency.value): Promise<number> {
|
||||
if (from === to) return amountMinor;
|
||||
const key = `${amountMinor}:${from}:${to}`;
|
||||
const cached = cache.value.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const converted = await $api.convert(amountMinor, from, to);
|
||||
cache.value.set(key, converted.amount_minor);
|
||||
return converted.amount_minor;
|
||||
}
|
||||
|
||||
async function formatPrice(amountMinor: number, from: string, to = currency.value): Promise<string> {
|
||||
await ensureCurrencies();
|
||||
const amount = await convertAmount(amountMinor, from, to);
|
||||
return formatMoney(amount, to, exponentFor(to), locale.value);
|
||||
}
|
||||
|
||||
async function formatAmount(amountMinor: number, code: string): Promise<string> {
|
||||
await ensureCurrencies();
|
||||
return formatMoney(amountMinor, code, exponentFor(code), locale.value);
|
||||
}
|
||||
|
||||
return {
|
||||
currency,
|
||||
currencies,
|
||||
cache,
|
||||
ensureCurrencies,
|
||||
exponentFor,
|
||||
convertAmount,
|
||||
formatPrice,
|
||||
formatAmount,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
import { en, zh } from "@vmall/shared/locales";
|
||||
import { en as enBase, zh as zhBase } from "@vmall/shared/locales";
|
||||
import { enExtra, zhExtra } from "./locales-extra";
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(base: T, extra: T): T {
|
||||
const out: Record<string, unknown> = { ...base };
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
const b = out[k];
|
||||
out[k] =
|
||||
b && v && typeof b === "object" && typeof v === "object"
|
||||
? deepMerge(b as Record<string, unknown>, v as Record<string, unknown>)
|
||||
: v;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
export default defineI18nConfig(() => ({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
messages: { en, zh },
|
||||
messages: {
|
||||
en: deepMerge(enBase, enExtra),
|
||||
zh: deepMerge(zhBase, zhExtra),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// App-local message extensions, deep-merged over @vmall/shared locales.
|
||||
// Add keys here (never edit the shared locale files from an app agent).
|
||||
export const enExtra = {
|
||||
mall: {
|
||||
catalogTitle: "Shop products",
|
||||
searchPlaceholder: "Search products",
|
||||
categoryPlaceholder: "Choose a category",
|
||||
allCategories: "All categories",
|
||||
clear: "Clear",
|
||||
viewDetails: "View details",
|
||||
noImage: "No image",
|
||||
noProducts: "No products found",
|
||||
productDescription: "Description",
|
||||
attributes: "Attributes",
|
||||
sku: "SKU",
|
||||
chooseSku: "Choose an option",
|
||||
quantity: "Quantity",
|
||||
available: "available",
|
||||
unavailable: "Unavailable",
|
||||
addedToCart: "Added to cart",
|
||||
cartItems: "Cart items",
|
||||
lineTotal: "Line total",
|
||||
orderPreview: "Order preview",
|
||||
checkoutTitle: "Checkout",
|
||||
placeOrder: "Place order",
|
||||
orderSuccess: "Your order has been placed",
|
||||
orderNumbers: "Order numbers",
|
||||
viewOrders: "View orders",
|
||||
backToShop: "Continue shopping",
|
||||
noOrders: "No orders found",
|
||||
created: "Created",
|
||||
items: "Items",
|
||||
address: "Address",
|
||||
shipments: "Shipments",
|
||||
invoices: "Invoices",
|
||||
noInvoice: "No invoice requested",
|
||||
invoiceRequested: "Invoice requested",
|
||||
notAvailable: "—",
|
||||
companyTaxRequired: "Tax number is required for company invoices",
|
||||
invalidQuantity: "Enter a valid quantity",
|
||||
loadFailed: "Unable to load data",
|
||||
signInRequired: "Please sign in to continue",
|
||||
filter: "Filter",
|
||||
details: "Details",
|
||||
update: "Update",
|
||||
confirm: "Confirm",
|
||||
totalItems: "Total items",
|
||||
shippingOptional: "Optional",
|
||||
orderDetails: "Order details",
|
||||
payment: "Payment",
|
||||
requestInvoiceForOrder: "Request an invoice for this order",
|
||||
},
|
||||
auth: {
|
||||
wrongRole: "This account is not a customer account",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
export const zhExtra = {
|
||||
mall: {
|
||||
catalogTitle: "选购商品",
|
||||
searchPlaceholder: "搜索商品",
|
||||
categoryPlaceholder: "选择分类",
|
||||
allCategories: "全部分类",
|
||||
clear: "清除",
|
||||
viewDetails: "查看详情",
|
||||
noImage: "暂无图片",
|
||||
noProducts: "未找到商品",
|
||||
productDescription: "商品描述",
|
||||
attributes: "属性",
|
||||
sku: "SKU",
|
||||
chooseSku: "选择规格",
|
||||
quantity: "数量",
|
||||
available: "件可用",
|
||||
unavailable: "暂不可用",
|
||||
addedToCart: "已加入购物车",
|
||||
cartItems: "购物车商品",
|
||||
lineTotal: "行合计",
|
||||
orderPreview: "订单预览",
|
||||
checkoutTitle: "结算",
|
||||
placeOrder: "提交订单",
|
||||
orderSuccess: "订单提交成功",
|
||||
orderNumbers: "订单号",
|
||||
viewOrders: "查看订单",
|
||||
backToShop: "继续购物",
|
||||
noOrders: "暂无订单",
|
||||
created: "创建时间",
|
||||
items: "商品数",
|
||||
address: "地址",
|
||||
shipments: "发货信息",
|
||||
invoices: "发票",
|
||||
noInvoice: "尚未申请发票",
|
||||
invoiceRequested: "发票已申请",
|
||||
notAvailable: "—",
|
||||
companyTaxRequired: "企业发票必须填写税号",
|
||||
invalidQuantity: "请输入有效数量",
|
||||
loadFailed: "无法加载数据",
|
||||
signInRequired: "请先登录",
|
||||
filter: "筛选",
|
||||
details: "详情",
|
||||
update: "更新",
|
||||
confirm: "确认",
|
||||
totalItems: "商品总数",
|
||||
shippingOptional: "可选",
|
||||
orderDetails: "订单详情",
|
||||
payment: "支付",
|
||||
requestInvoiceForOrder: "申请本订单发票",
|
||||
},
|
||||
auth: {
|
||||
wrongRole: "此账号不是买家账号",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
@@ -0,0 +1,15 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
if (import.meta.server) return;
|
||||
const token = localStorage.getItem("vmall.token");
|
||||
let role: string | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem("vmall.user");
|
||||
const user: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (user && typeof user === "object" && "role" in user && typeof user.role === "string") {
|
||||
role = user.role;
|
||||
}
|
||||
} catch {
|
||||
role = null;
|
||||
}
|
||||
if (!token || role !== "customer") return navigateTo("/login");
|
||||
});
|
||||
@@ -16,7 +16,8 @@ export default defineNuxtConfig({
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "zh", name: "中文" },
|
||||
],
|
||||
vueI18n: "./i18n.config.ts",
|
||||
vueI18n: "../i18n.config.ts",
|
||||
bundle: { optimizeTranslationDirective: false },
|
||||
},
|
||||
compatibilityDate: "2025-01-01",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import type { Cart, CartItem } 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 error = ref("");
|
||||
const updating = ref<string | null>(null);
|
||||
|
||||
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);
|
||||
cart.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateQuantity(item: CartItem, value: string | number): Promise<void> {
|
||||
const qty = Math.trunc(Number(value));
|
||||
if (!Number.isFinite(qty) || qty < 1) {
|
||||
error.value = $t("mall.invalidQuantity");
|
||||
return;
|
||||
}
|
||||
updating.value = item.sku_id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.updateCartItem(item.sku_id, qty);
|
||||
await loadCart();
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
updating.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: CartItem): Promise<void> {
|
||||
updating.value = item.sku_id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.removeCartItem(item.sku_id);
|
||||
await loadCart();
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
updating.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(currency, () => void refreshPrices());
|
||||
onMounted(() => void loadCart());
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1 class="page-title">{{ $t("cart.title") }}</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="!loading && cart && cart.items.length === 0" class="card empty-state">{{ $t("cart.emptyCart") }}</div>
|
||||
<template v-if="cart && cart.items.length > 0">
|
||||
<div class="card cart-list">
|
||||
<div v-for="item in cart.items" :key="item.sku_id" class="cart-line">
|
||||
<div class="cart-product">
|
||||
<img v-if="item.image" :src="item.image" :alt="localized(item.product_name, locale)">
|
||||
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
|
||||
<div>
|
||||
<NuxtLink :to="`/products/${item.product_id}`" class="product-name">{{ localized(item.product_name, locale) }}</NuxtLink>
|
||||
<div class="muted">{{ item.sku_code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unit-price"><PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" /></div>
|
||||
<div class="quantity-field">
|
||||
<label class="sr-only" :for="`qty-${item.sku_id}`">{{ $t("common.qty") }}</label>
|
||||
<input :id="`qty-${item.sku_id}`" :value="item.qty" type="number" min="1" :disabled="updating === item.sku_id" @change="updateQuantity(item, ($event.target as HTMLInputElement).value)">
|
||||
</div>
|
||||
<div class="line-total"><PriceText :amount-minor="(converted[item.sku_id] ?? 0) * item.qty" :currency="currency" /></div>
|
||||
<button class="btn danger sm" :disabled="updating === item.sku_id" @click="removeItem(item)">{{ $t("cart.remove") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card summary row between">
|
||||
<strong>{{ $t("cart.subtotal") }}</strong>
|
||||
<strong class="total">{{ subtotalDisplay || $t("common.loading") }}</strong>
|
||||
<NuxtLink class="btn primary" to="/checkout">{{ $t("cart.checkout") }}</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cart-list { padding: 0; }
|
||||
.cart-line { display: grid; grid-template-columns: minmax(240px, 1fr) 120px 100px 120px auto; gap: 14px; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.cart-line:last-child { border-bottom: 0; }
|
||||
.cart-product { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.cart-product img, .image-placeholder { width: 56px; height: 56px; object-fit: cover; border-radius: var(--radius); background: #eef1f5; }
|
||||
.image-placeholder { display: grid; place-items: center; color: var(--muted); font-size: 10px; text-align: center; }
|
||||
.product-name { font-weight: 600; }
|
||||
.quantity-field { margin: 0; }
|
||||
.quantity-field input { width: 80px; }
|
||||
.line-total, .unit-price, .total { color: var(--primary); font-weight: 600; }
|
||||
.summary { margin-top: 16px; }
|
||||
.empty-state { text-align: center; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 800px) {
|
||||
.cart-line { grid-template-columns: 1fr auto auto; }
|
||||
.unit-price, .line-total { text-align: right; }
|
||||
.cart-product { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
+142
-4
@@ -1,6 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Product, Paged, Sku } from "@vmall/shared";
|
||||
import { t as localized } from "@vmall/shared";
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const categories = ref<Category[]>([]);
|
||||
const result = ref<Paged<Product> | null>(null);
|
||||
const search = ref(typeof route.query.q === "string" ? route.query.q : "");
|
||||
const categoryId = ref(typeof route.query.category_id === "string" ? route.query.category_id : "");
|
||||
const page = ref(Number(route.query.page) > 0 ? Number(route.query.page) : 1);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function errorMessage(value: unknown): string {
|
||||
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
||||
}
|
||||
|
||||
function lowestSku(product: Product): Sku | null {
|
||||
const active = (product.skus ?? []).filter((sku) => sku.active && sku.stock > 0);
|
||||
return active.reduce<Sku | null>((lowest, sku) => {
|
||||
if (!lowest || sku.price_minor < lowest.price_minor) return sku;
|
||||
return lowest;
|
||||
}, null) ?? (product.skus ?? []).find((sku) => sku.active) ?? null;
|
||||
}
|
||||
|
||||
async function loadCategories(): Promise<void> {
|
||||
try {
|
||||
categories.value = await $api.listCategories();
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProducts(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
result.value = await $api.listProducts({
|
||||
page: page.value,
|
||||
per_page: 12,
|
||||
category_id: categoryId.value || undefined,
|
||||
q: search.value.trim() || undefined,
|
||||
});
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
result.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFilters(): Promise<void> {
|
||||
page.value = 1;
|
||||
await router.replace({ query: {
|
||||
...(search.value.trim() ? { q: search.value.trim() } : {}),
|
||||
...(categoryId.value ? { category_id: categoryId.value } : {}),
|
||||
page: "1",
|
||||
} });
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
const pages = Math.max(1, Math.ceil((result.value?.total ?? 0) / (result.value?.per_page ?? 12)));
|
||||
if (nextPage < 1 || nextPage > pages) return;
|
||||
page.value = nextPage;
|
||||
await router.replace({ query: { ...route.query, page: String(nextPage) } });
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCategories(), loadProducts()]);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((result.value?.total ?? 0) / (result.value?.per_page ?? 12))));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ $t("common.appName") }}</h1>
|
||||
<p class="muted">{{ $t("common.loading") }}</p>
|
||||
</div>
|
||||
<section>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("mall.catalogTitle") }}</h1>
|
||||
</div>
|
||||
|
||||
<form class="card filters" @submit.prevent="applyFilters">
|
||||
<div class="field search-field">
|
||||
<label for="product-search">{{ $t("common.search") }}</label>
|
||||
<input id="product-search" v-model="search" type="search" :placeholder="$t('mall.searchPlaceholder')">
|
||||
</div>
|
||||
<div class="field category-field">
|
||||
<label for="product-category">{{ $t("mall.filter") }}</label>
|
||||
<select id="product-category" v-model="categoryId">
|
||||
<option value="">{{ $t("mall.allCategories") }}</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ localized(category.name, locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn primary filter-button" type="submit">{{ $t("common.search") }}</button>
|
||||
</form>
|
||||
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<p v-if="loading" class="muted" aria-live="polite">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="result && result.items.length === 0" class="card empty-state">{{ $t("common.empty") }}</div>
|
||||
<div v-else-if="result" class="grid products">
|
||||
<article v-for="product in result.items" :key="product.id" class="card product-card">
|
||||
<NuxtLink :to="`/products/${product.id}`">
|
||||
<img v-if="product.images[0]" :src="product.images[0]" :alt="localized(product.name, locale)">
|
||||
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
|
||||
</NuxtLink>
|
||||
<div class="body">
|
||||
<div class="name">{{ localized(product.name, locale) }}</div>
|
||||
<div v-if="lowestSku(product)" class="price">
|
||||
<PriceText :amount-minor="lowestSku(product)!.price_minor" :currency="lowestSku(product)!.currency" />
|
||||
</div>
|
||||
<div v-else class="muted">{{ $t("mall.unavailable") }}</div>
|
||||
<NuxtLink class="btn sm mt" :to="`/products/${product.id}`">{{ $t("mall.viewDetails") }}</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="result && result.items.length > 0" class="row between pagination">
|
||||
<button class="btn" :disabled="page <= 1" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
|
||||
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn" :disabled="page >= totalPages" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters { display: flex; align-items: end; gap: 12px; margin-bottom: 20px; }
|
||||
.filters .field { margin: 0; }
|
||||
.search-field { flex: 1; }
|
||||
.category-field { min-width: 210px; }
|
||||
.filter-button { white-space: nowrap; }
|
||||
.image-placeholder { aspect-ratio: 1; display: grid; place-items: center; background: #eef1f5; color: var(--muted); }
|
||||
.pagination { margin-top: 20px; }
|
||||
.empty-state { text-align: center; }
|
||||
@media (max-width: 640px) {
|
||||
.filters { align-items: stretch; flex-direction: column; }
|
||||
.category-field { min-width: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { Invoice } from "@vmall/shared";
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale } = useI18n();
|
||||
const { formatAmount } = usePrice();
|
||||
const invoices = ref<Invoice[]>([]);
|
||||
const amounts = ref<Record<string, string>>({});
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
function errorMessage(value: unknown): string {
|
||||
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
||||
}
|
||||
|
||||
async function loadInvoices(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
invoices.value = await $api.listMyInvoices();
|
||||
const entries = await Promise.all(invoices.value.map(async (invoice) => [invoice.id, await formatAmount(invoice.amount_minor, invoice.currency)] as const));
|
||||
amounts.value = Object.fromEntries(entries);
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
invoices.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(locale, () => {
|
||||
if (invoices.value.length > 0) void loadInvoices();
|
||||
});
|
||||
onMounted(() => void loadInvoices());
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1 class="page-title">{{ $t("invoice.title") }}</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="!loading && invoices.length === 0" class="card empty-state">{{ $t("common.empty") }}</div>
|
||||
<table v-if="invoices.length > 0" class="table">
|
||||
<thead><tr><th>{{ $t("invoice.invoiceNo") }}</th><th>{{ $t("order.orderNo") }}</th><th>{{ $t("invoice.invoiceTitle") }}</th><th>{{ $t("invoice.kind") }}</th><th>{{ $t("invoice.amount") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("invoice.status.issued") }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in invoices" :key="item.id">
|
||||
<td>{{ item.invoice_no || $t("mall.notAvailable") }}</td>
|
||||
<td>{{ item.order_no || $t("mall.notAvailable") }}</td>
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ $t(`invoice.${item.kind}`) }}</td>
|
||||
<td>{{ amounts[item.id] || $t("common.loading") }}</td>
|
||||
<td><StatusBadge :status="item.status" kind="invoice" /></td>
|
||||
<td>{{ item.issued_at ? new Date(item.issued_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") : $t("mall.notAvailable") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state { text-align: center; }
|
||||
@media (max-width: 760px) { .table { display: block; overflow-x: auto; white-space: nowrap; } }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const auth = await $api.login(email.value.trim(), password.value);
|
||||
if (auth.user.role !== "customer") {
|
||||
error.value = $t("auth.wrongRole");
|
||||
return;
|
||||
}
|
||||
session.setAuth(auth);
|
||||
await navigateTo("/");
|
||||
} catch (value) {
|
||||
error.value = value instanceof Error ? value.message : $t("auth.badCredentials");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="form-narrow card">
|
||||
<h1 class="page-title">{{ $t("auth.welcome") }}</h1>
|
||||
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="field"><label for="login-email">{{ $t("common.email") }}</label><input id="login-email" v-model="email" type="email" autocomplete="email" required></div>
|
||||
<div class="field"><label for="login-password">{{ $t("common.password") }}</label><input id="login-password" v-model="password" type="password" autocomplete="current-password" required></div>
|
||||
<button class="btn primary" type="submit" :disabled="submitting">{{ submitting ? $t("common.loading") : $t("common.login") }}</button>
|
||||
</form>
|
||||
<p class="muted mt">{{ $t("auth.noAccount") }} <NuxtLink to="/register">{{ $t("common.register") }}</NuxtLink></p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import type { Invoice, InvoiceKind, Order, Shipment } from "@vmall/shared";
|
||||
import { t as localized } from "@vmall/shared";
|
||||
|
||||
const route = useRoute();
|
||||
const { locale } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const { formatAmount } = usePrice();
|
||||
|
||||
const order = ref<Order | null>(null);
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const invoice = ref<Invoice | null>(null);
|
||||
const totalDisplay = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const action = ref("");
|
||||
const invoiceKind = ref<InvoiceKind>("personal");
|
||||
const invoiceTitle = ref("");
|
||||
const taxNo = ref("");
|
||||
const invoiceSubmitting = ref(false);
|
||||
|
||||
function errorMessage(value: unknown): string {
|
||||
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
||||
}
|
||||
|
||||
async function loadOrder(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const orderId = String(route.params.id);
|
||||
const [loadedOrder, loadedShipments, loadedInvoices] = await Promise.all([
|
||||
$api.getOrder(orderId),
|
||||
$api.listMyShipments(),
|
||||
$api.listMyInvoices(),
|
||||
]);
|
||||
order.value = loadedOrder;
|
||||
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === loadedOrder.id);
|
||||
invoice.value = loadedInvoices.find((item) => item.order_id === loadedOrder.id) ?? null;
|
||||
totalDisplay.value = await formatAmount(loadedOrder.total_minor, loadedOrder.currency);
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
order.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(name: "pay" | "cancel"): Promise<void> {
|
||||
if (!order.value) return;
|
||||
action.value = name;
|
||||
error.value = "";
|
||||
try {
|
||||
if (name === "pay") await $api.payOrder(order.value.id);
|
||||
else await $api.cancelOrder(order.value.id);
|
||||
await loadOrder();
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
action.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelivery(shipment: Shipment): Promise<void> {
|
||||
action.value = shipment.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.confirmDelivered(shipment.id);
|
||||
await loadOrder();
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
action.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function requestInvoice(): Promise<void> {
|
||||
if (!order.value || !invoiceTitle.value.trim()) {
|
||||
error.value = $t("common.required");
|
||||
return;
|
||||
}
|
||||
if (invoiceKind.value === "company" && !taxNo.value.trim()) {
|
||||
error.value = $t("mall.companyTaxRequired");
|
||||
return;
|
||||
}
|
||||
invoiceSubmitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.requestInvoice(order.value.id, invoiceTitle.value.trim(), invoiceKind.value === "company" ? taxNo.value.trim() : null, invoiceKind.value);
|
||||
await loadOrder();
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
invoiceSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(locale, () => {
|
||||
if (order.value) void loadOrder();
|
||||
});
|
||||
onMounted(() => void loadOrder());
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<NuxtLink to="/orders" class="muted">← {{ $t("common.back") }}</NuxtLink>
|
||||
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
|
||||
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<template v-if="order">
|
||||
<div class="page-head mt">
|
||||
<h1 class="page-title">{{ order.order_no }}</h1>
|
||||
<StatusBadge :status="order.status" kind="order" />
|
||||
</div>
|
||||
<div class="card order-summary">
|
||||
<div><span class="muted">{{ $t("mall.created") }}</span><strong>{{ new Date(order.created_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") }}</strong></div>
|
||||
<div><span class="muted">{{ $t("common.total") }}</span><strong class="total">{{ totalDisplay }}</strong></div>
|
||||
<div v-if="order.status === 'pending_payment'" class="row">
|
||||
<button class="btn primary" :disabled="action !== ''" @click="runAction('pay')">{{ action === "pay" ? $t("common.loading") : $t("order.pay") }}</button>
|
||||
<button class="btn danger" :disabled="action !== ''" @click="runAction('cancel')">{{ action === "cancel" ? $t("common.loading") : $t("order.cancelOrder") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt">
|
||||
<h2>{{ $t("mall.cartItems") }}</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>{{ $t("product.detail") }}</th><th>{{ $t("common.price") }}</th><th>{{ $t("common.qty") }}</th><th>{{ $t("mall.lineTotal") }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in order.items" :key="item.id">
|
||||
<td><div>{{ localized(item.product_name, locale) }}</div><span class="muted">{{ item.sku_code }}</span></td>
|
||||
<td><PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" /></td>
|
||||
<td>{{ item.qty }}</td>
|
||||
<td><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card mt">
|
||||
<h2>{{ $t("order.shippingAddress") }}</h2>
|
||||
<div class="address-grid">
|
||||
<div><span class="muted">{{ $t("order.recipient") }}</span>{{ order.shipping_address.recipient }}</div>
|
||||
<div><span class="muted">{{ $t("order.phone") }}</span>{{ order.shipping_address.phone }}</div>
|
||||
<div><span class="muted">{{ $t("order.country") }}</span>{{ order.shipping_address.country }}</div>
|
||||
<div><span class="muted">{{ $t("order.region") }}</span>{{ order.shipping_address.region || $t("mall.notAvailable") }}</div>
|
||||
<div><span class="muted">{{ $t("order.city") }}</span>{{ order.shipping_address.city }}</div>
|
||||
<div><span class="muted">{{ $t("order.postalCode") }}</span>{{ order.shipping_address.postal_code }}</div>
|
||||
<div class="wide"><span class="muted">{{ $t("order.line1") }}</span>{{ order.shipping_address.line1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt">
|
||||
<h2>{{ $t("mall.shipments") }}</h2>
|
||||
<div v-if="shipments.length === 0" class="muted">{{ $t("common.empty") }}</div>
|
||||
<table v-else class="table">
|
||||
<thead><tr><th>{{ $t("shipment.shipmentNo") }}</th><th>{{ $t("shipment.carrier") }}</th><th>{{ $t("shipment.trackingNo") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("common.actions") }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="shipment in shipments" :key="shipment.id">
|
||||
<td>{{ shipment.shipment_no }}</td><td>{{ shipment.carrier }}</td><td>{{ shipment.tracking_no }}</td><td><StatusBadge :status="shipment.status" kind="shipment" /></td>
|
||||
<td><button v-if="shipment.status === 'shipped'" class="btn sm" :disabled="action !== ''" @click="confirmDelivery(shipment)">{{ $t("order.confirmDelivery") }}</button><span v-else class="muted">{{ $t("mall.notAvailable") }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card mt">
|
||||
<h2>{{ $t("mall.invoices") }}</h2>
|
||||
<div v-if="invoice" class="invoice-existing row between">
|
||||
<div><strong>{{ invoice.invoice_no || $t("mall.notAvailable") }}</strong><span class="muted">{{ invoice.title }} · {{ $t(`invoice.${invoice.kind}`) }}</span></div>
|
||||
<StatusBadge :status="invoice.status" kind="invoice" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="muted">{{ $t("mall.noInvoice") }}</p>
|
||||
<form class="invoice-form" @submit.prevent="requestInvoice">
|
||||
<div class="field"><label for="invoice-kind">{{ $t("invoice.kind") }}</label><select id="invoice-kind" v-model="invoiceKind"><option value="personal">{{ $t("invoice.personal") }}</option><option value="company">{{ $t("invoice.company") }}</option></select></div>
|
||||
<div class="field"><label for="invoice-title">{{ $t("invoice.invoiceTitle") }}</label><input id="invoice-title" v-model="invoiceTitle" required></div>
|
||||
<div v-if="invoiceKind === 'company'" class="field"><label for="tax-no">{{ $t("invoice.taxNo") }}</label><input id="tax-no" v-model="taxNo"></div>
|
||||
<button class="btn primary" type="submit" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? $t("common.loading") : $t("order.requestInvoice") }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-summary { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
|
||||
.order-summary > div:not(.row) { display: grid; gap: 3px; }
|
||||
.total { color: var(--primary); font-size: 18px; }
|
||||
.card h2 { font-size: 17px; margin: 0 0 14px; }
|
||||
.address-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 20px; }
|
||||
.address-grid > div { display: grid; gap: 2px; }
|
||||
.address-grid .wide { grid-column: 1 / -1; }
|
||||
.invoice-existing > div { display: grid; gap: 3px; }
|
||||
.invoice-form { max-width: 420px; }
|
||||
@media (max-width: 640px) { .address-grid { grid-template-columns: 1fr; } .address-grid .wide { grid-column: auto; } .table { display: block; overflow-x: auto; white-space: nowrap; } }
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { Order, Paged } from "@vmall/shared";
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale } = useI18n();
|
||||
const { formatAmount } = usePrice();
|
||||
const page = ref(1);
|
||||
const result = ref<Paged<Order> | null>(null);
|
||||
const totals = ref<Record<string, string>>({});
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
function errorMessage(value: unknown): string {
|
||||
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
||||
}
|
||||
|
||||
async function loadOrders(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
result.value = await $api.listMyOrders(page.value);
|
||||
const entries = await Promise.all(result.value.items.map(async (order) => [order.id, await formatAmount(order.total_minor, order.currency)] as const));
|
||||
totals.value = Object.fromEntries(entries);
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
result.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(next: number): Promise<void> {
|
||||
if (next < 1 || next > totalPages.value) return;
|
||||
page.value = next;
|
||||
await loadOrders();
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((result.value?.total ?? 0) / (result.value?.per_page ?? 20))));
|
||||
|
||||
watch(locale, () => {
|
||||
if (result.value) void loadOrders();
|
||||
});
|
||||
onMounted(() => void loadOrders());
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1 class="page-title">{{ $t("order.title") }}</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="!loading && result && result.items.length === 0" class="card empty-state">{{ $t("common.empty") }}</div>
|
||||
<table v-if="result && result.items.length > 0" class="table">
|
||||
<thead><tr><th>{{ $t("order.orderNo") }}</th><th>{{ $t("mall.created") }}</th><th>{{ $t("mall.items") }}</th><th>{{ $t("common.total") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("common.actions") }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="order in result.items" :key="order.id">
|
||||
<td>{{ order.order_no }}</td>
|
||||
<td>{{ new Date(order.created_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") }}</td>
|
||||
<td>{{ order.items.length }}</td>
|
||||
<td>{{ totals[order.id] || $t("common.loading") }}</td>
|
||||
<td><StatusBadge :status="order.status" kind="order" /></td>
|
||||
<td><NuxtLink :to="`/orders/${order.id}`">{{ $t("mall.details") }}</NuxtLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="result && result.items.length > 0" class="row between pagination">
|
||||
<button class="btn" :disabled="page <= 1" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
|
||||
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn" :disabled="page >= totalPages" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination { margin-top: 16px; }
|
||||
.empty-state { text-align: center; }
|
||||
@media (max-width: 700px) { .table { display: block; overflow-x: auto; white-space: nowrap; } }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import type { Product, Sku } from "@vmall/shared";
|
||||
import { t as localized } from "@vmall/shared";
|
||||
|
||||
const route = useRoute();
|
||||
const { locale } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const product = ref<Product | null>(null);
|
||||
const selectedSkuId = ref("");
|
||||
const quantity = ref(1);
|
||||
const mainImage = ref("");
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const skus = computed(() => (product.value?.skus ?? []).filter((sku) => sku.active));
|
||||
const selectedSku = computed<Sku | null>(() => skus.value.find((sku) => sku.id === selectedSkuId.value) ?? skus.value[0] ?? null);
|
||||
|
||||
function errorMessage(value: unknown): string {
|
||||
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
||||
}
|
||||
|
||||
async function loadProduct(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
product.value = await $api.getProduct(String(route.params.id));
|
||||
mainImage.value = product.value.images[0] ?? "";
|
||||
selectedSkuId.value = skus.value[0]?.id ?? "";
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addToCart(): Promise<void> {
|
||||
const sku = selectedSku.value;
|
||||
if (!sku || sku.stock < 1) return;
|
||||
if (!localStorage.getItem("vmall.token") || session.user?.role !== "customer") {
|
||||
await navigateTo("/login");
|
||||
return;
|
||||
}
|
||||
const qty = Math.max(1, Math.min(sku.stock, Math.trunc(quantity.value)));
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.addCartItem(sku.id, qty);
|
||||
await navigateTo("/cart");
|
||||
} catch (value) {
|
||||
error.value = errorMessage(value);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedSku, (sku) => {
|
||||
if (sku && quantity.value > sku.stock) quantity.value = Math.max(1, sku.stock);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
session.hydrate();
|
||||
await loadProduct();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<NuxtLink to="/" class="muted">← {{ $t("common.back") }}</NuxtLink>
|
||||
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<div v-else-if="product" class="detail-layout mt">
|
||||
<div>
|
||||
<div class="main-image">
|
||||
<img v-if="mainImage" :src="mainImage" :alt="localized(product.name, locale)">
|
||||
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
|
||||
</div>
|
||||
<div v-if="product.images.length > 1" class="thumbs">
|
||||
<button v-for="image in product.images" :key="image" class="thumb" :class="{ selected: image === mainImage }" type="button" @click="mainImage = image">
|
||||
<img :src="image" :alt="localized(product.name, locale)">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card product-info">
|
||||
<h1 class="page-title">{{ localized(product.name, locale) }}</h1>
|
||||
<p class="muted">{{ localized(product.description, locale) }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ $t("mall.chooseSku") }}</label>
|
||||
<div v-if="skus.length" class="sku-list">
|
||||
<label v-for="sku in skus" :key="sku.id" class="sku-option" :class="{ selected: sku.id === selectedSkuId }">
|
||||
<input v-model="selectedSkuId" type="radio" name="sku" :value="sku.id" :disabled="sku.stock < 1">
|
||||
<span>
|
||||
<strong>{{ sku.sku_code }}</strong>
|
||||
<span v-for="(value, key) in sku.attributes" :key="key" class="attribute">{{ key }}: {{ value }}</span>
|
||||
</span>
|
||||
<span class="sku-meta">
|
||||
<PriceText :amount-minor="sku.price_minor" :currency="sku.currency" />
|
||||
<span class="muted">{{ sku.stock }} {{ $t("mall.available") }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="muted">{{ $t("mall.unavailable") }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSku" class="purchase-row">
|
||||
<div class="field quantity-field">
|
||||
<label for="product-quantity">{{ $t("mall.quantity") }}</label>
|
||||
<input id="product-quantity" v-model.number="quantity" type="number" min="1" :max="selectedSku.stock">
|
||||
</div>
|
||||
<div class="selected-price"><PriceText :amount-minor="selectedSku.price_minor" :currency="selectedSku.currency" /></div>
|
||||
</div>
|
||||
<button class="btn primary" :disabled="submitting || !selectedSku || selectedSku.stock < 1" @click="addToCart">
|
||||
{{ submitting ? $t("common.loading") : $t("product.addToCart") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
|
||||
.main-image { overflow: hidden; border-radius: var(--radius); background: #eef1f5; }
|
||||
.main-image img, .image-placeholder { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
|
||||
.image-placeholder { display: grid; place-items: center; color: var(--muted); }
|
||||
.thumbs { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.thumb { padding: 0; border: 2px solid transparent; background: none; cursor: pointer; border-radius: var(--radius); overflow: hidden; }
|
||||
.thumb.selected { border-color: var(--primary); }
|
||||
.thumb img { width: 64px; height: 64px; display: block; object-fit: cover; }
|
||||
.product-info .page-title { margin-bottom: 8px; }
|
||||
.sku-list { display: grid; gap: 8px; }
|
||||
.sku-option { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; cursor: pointer; }
|
||||
.sku-option.selected { border-color: var(--primary); background: #f7f9ff; }
|
||||
.sku-option input { width: auto; margin-top: 3px; }
|
||||
.attribute { display: inline-block; margin-left: 10px; color: var(--muted); font-size: 12px; }
|
||||
.sku-meta { display: grid; justify-items: end; gap: 2px; color: var(--primary); font-weight: 600; }
|
||||
.sku-meta .muted { font-weight: 400; font-size: 12px; }
|
||||
.purchase-row { display: flex; align-items: end; gap: 16px; margin: 18px 0; }
|
||||
.quantity-field { width: 120px; margin: 0; }
|
||||
.selected-price { color: var(--primary); font-size: 20px; font-weight: 700; padding-bottom: 8px; }
|
||||
@media (max-width: 760px) { .detail-layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const displayName = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const auth = await $api.register(email.value.trim(), password.value, displayName.value.trim());
|
||||
if (auth.user.role !== "customer") {
|
||||
error.value = $t("auth.wrongRole");
|
||||
return;
|
||||
}
|
||||
session.setAuth(auth);
|
||||
await navigateTo("/");
|
||||
} catch (value) {
|
||||
error.value = value instanceof Error ? value.message : $t("common.error");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="form-narrow card">
|
||||
<h1 class="page-title">{{ $t("auth.createAccount") }}</h1>
|
||||
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="field"><label for="register-name">{{ $t("common.displayName") }}</label><input id="register-name" v-model="displayName" autocomplete="name" required></div>
|
||||
<div class="field"><label for="register-email">{{ $t("common.email") }}</label><input id="register-email" v-model="email" type="email" autocomplete="email" required></div>
|
||||
<div class="field"><label for="register-password">{{ $t("common.password") }}</label><input id="register-password" v-model="password" type="password" autocomplete="new-password" minlength="8" required></div>
|
||||
<button class="btn primary" type="submit" :disabled="submitting">{{ submitting ? $t("common.loading") : $t("common.register") }}</button>
|
||||
</form>
|
||||
<p class="muted mt">{{ $t("auth.haveAccount") }} <NuxtLink to="/login">{{ $t("common.login") }}</NuxtLink></p>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user