The product page loads a shop's claimable coupons through the shared client and claims them in place; the buyer coupon list reads owned snapshots with their claimed/redeemed/expired state. Checkout offers each shop order at most one owned coupon and submits only the choice; the pay page and order detail render the server-persisted discount and reduced total, never a client figure. Coupon fixtures leave the pages; the fixed-data adapter still serves the coupon domain as the rollback path. The demo seed creates two deterministic templates and claims them for the demo customer.
305 lines
8.5 KiB
Vue
305 lines
8.5 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>
|
|
</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);
|
|
}
|
|
.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>
|