Files
vmall/apps/mall/pages/user/orders/[id].vue
T
james 2125ae437f feat(mall): live flash-sale page and activity price snapshots
The flash-sale page loads active sessions and their items through the shared
client with live sale price, remaining activity stock, sell-through, and a
countdown, and adds the SKU to the existing cart so checkout resolves the
authoritative price. Payment and order detail tag and render the snapshotted
activity unit price.

The flash-sale fixtures leave the page; the fixed-data adapter still serves the
domain as the rollback path.
2026-09-18 12:53:26 +00:00

313 lines
8.8 KiB
Vue

<script setup lang="ts">
import type { InvoiceKind, Order, Shipment } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const order = ref<Order | null>(null);
const shipments = ref<Shipment[]>([]);
const loading = ref(true);
const confirmingShipmentId = ref("");
const invoiceWorking = ref(false);
const invoiceSuccess = ref(false);
const invoiceErrorKey = ref("");
const invoiceTitle = ref("");
const invoiceTaxNo = ref("");
const invoiceKind = ref<InvoiceKind>("personal");
const orderId = computed(() => String(route.params.id ?? ""));
const orderShipments = computed(() => shipments.value.filter((shipment) => shipment.order_id === order.value?.id));
async function load(): Promise<void> {
loading.value = true;
try {
const [o, allShipments] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments()]);
order.value = o;
shipments.value = allShipments;
} finally {
loading.value = false;
}
}
async function confirmDelivery(shipment: Shipment): Promise<void> {
confirmingShipmentId.value = shipment.id;
try {
await $api.confirmDelivered(shipment.id);
await load();
} finally {
confirmingShipmentId.value = "";
}
}
async function submitInvoice(): Promise<void> {
invoiceErrorKey.value = "";
if (!order.value || !invoiceTitle.value.trim()) {
invoiceErrorKey.value = "user.validationRequired";
return;
}
invoiceWorking.value = true;
try {
await $api.requestInvoice(
order.value.id,
invoiceTitle.value.trim(),
invoiceTaxNo.value.trim() || null,
invoiceKind.value,
);
invoiceSuccess.value = true;
} catch {
invoiceErrorKey.value = "user.loadFailed";
} finally {
invoiceWorking.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="mpanel order-detail-panel">
<h1 class="mpanel-title">{{ t("user.orderDetail") }}</h1>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="!order" :text="t('user.noOrders')" />
<div v-else>
<header class="summary">
<div>
<strong>{{ t("user.orderNo") }} {{ order.order_no }}</strong>
<p>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</p>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.shippingAddress") }}</h2>
<p>{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</p>
<p>
{{ order.shipping_address.country }} {{ order.shipping_address.region }} {{ order.shipping_address.city }}
{{ order.shipping_address.line1 }} {{ order.shipping_address.postal_code }}
</p>
</article>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.orderItems") }}</h2>
<table class="mtable">
<thead>
<tr>
<th>{{ t("user.product") }}</th>
<th>{{ t("common.price") }}</th>
<th>{{ t("common.qty") }}</th>
<th>{{ t("common.total") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td class="prod-col">
<img :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
<span>{{ pick(item.product_name, locale) }}</span>
<span v-if="item.flash_sale_item_id" class="activity-tag">{{ t("marketing.flashTag") }}</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>
<p v-if="order.discount_minor > 0" class="order-discount">
{{ t("checkout.couponDiscount") }}:
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
</p>
<p class="order-total">{{ t("user.total") }}: <PriceText :amount-minor="order.total_minor" :currency="order.currency" /></p>
</article>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.shipments") }}</h2>
<UiEmptyState v-if="orderShipments.length === 0" :text="t('user.noShipments')" />
<div v-else>
<div v-for="shipment in orderShipments" :key="shipment.id" class="shipment-row">
<div>
<strong>{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong>
<p>{{ t("user.carrier") }}: {{ shipment.carrier }}</p>
<p>{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}</p>
</div>
<div class="shipment-right">
<StatusBadge :status="shipment.status" kind="shipment" />
<button
v-if="shipment.status === 'shipped'"
class="mbtn red"
type="button"
:disabled="confirmingShipmentId === shipment.id"
@click="confirmDelivery(shipment)"
>
{{ t("user.confirmDelivery") }}
</button>
</div>
</div>
</div>
</article>
<article class="mpanel block">
<h2 class="block-title">{{ t("user.invoiceRequest") }}</h2>
<div v-if="invoiceSuccess" class="invoice-success">{{ t("user.invoiceSuccess") }}</div>
<form v-else class="invoice-form" @submit.prevent="submitInvoice">
<label>
<span>{{ t("user.invoiceTitle") }}</span>
<input v-model.trim="invoiceTitle" class="minput" type="text" />
</label>
<label>
<span>{{ t("user.taxNo") }}</span>
<input v-model.trim="invoiceTaxNo" class="minput" type="text" />
</label>
<fieldset>
<legend>{{ t("user.invoiceKind") }}</legend>
<label><input v-model="invoiceKind" type="radio" value="personal" /> {{ t("user.personal") }}</label>
<label><input v-model="invoiceKind" type="radio" value="company" /> {{ t("user.company") }}</label>
</fieldset>
<p v-if="invoiceErrorKey" class="error">{{ t(invoiceErrorKey) }}</p>
<button class="mbtn red" type="submit" :disabled="invoiceWorking">{{ t("user.submitInvoice") }}</button>
</form>
</article>
</div>
</section>
</template>
<style scoped>
.order-detail-panel {
min-height: 560px;
}
.muted {
color: var(--mall-faint);
padding: 20px 0;
}
.summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px;
border: 1px solid var(--mall-line);
background: #fafafa;
}
.summary strong {
color: var(--mall-ink);
font-size: 15px;
}
.summary p {
margin: 4px 0 0;
color: var(--mall-faint);
font-size: 12px;
}
.block {
margin-top: 16px;
margin-bottom: 0;
border: 1px solid var(--mall-line);
}
.block-title {
margin: 0 0 14px;
color: var(--mall-ink);
font-size: 15px;
}
.prod-col {
display: flex;
align-items: center;
gap: 10px;
}
.prod-col img {
width: 44px;
height: 44px;
border: 1px solid var(--mall-line);
object-fit: contain;
}
.order-discount {
margin: 16px 0 0;
text-align: right;
font-size: 13px;
color: var(--mall-red);
}
.activity-tag {
margin-left: 6px;
padding: 1px 6px;
border: 1px solid var(--mall-red);
color: var(--mall-red);
font-size: 11px;
}
.order-total {
margin: 16px 0 0;
text-align: right;
font-size: 13px;
color: var(--mall-muted);
}
.order-total :deep(.price) {
color: var(--mall-red);
font-size: 18px;
font-weight: 600;
}
.shipment-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid var(--mall-line);
margin-bottom: 10px;
}
.shipment-row:last-child {
margin-bottom: 0;
}
.shipment-row p {
margin: 5px 0 0;
font-size: 12px;
color: var(--mall-muted);
}
.shipment-right {
display: flex;
align-items: center;
gap: 8px;
}
.invoice-form {
display: grid;
gap: 12px;
max-width: 520px;
}
.invoice-form label > span,
.invoice-form legend {
display: block;
margin-bottom: 6px;
color: var(--mall-muted);
font-size: 13px;
}
.invoice-form fieldset {
margin: 0;
padding: 0;
border: 0;
display: flex;
align-items: center;
gap: 16px;
}
.invoice-form fieldset label {
font-size: 13px;
color: var(--mall-ink);
}
.error {
margin: 0;
color: var(--mall-red);
font-size: 12px;
}
.invoice-success {
color: var(--mall-green);
font-size: 14px;
}
@media (max-width: 760px) {
.summary,
.shipment-row {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}
</style>