Files
vmall/apps/mall/pages/user/orders/index.vue
T
james e1a0a5dbdb feat(mall): run the transaction chain against the live API
Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and
invoices flip together, so one purchase runs end to end against the backend.

- cart: CartItemView carries the line's shop and the SKU's stock, so the cart
  keeps grouping per shop and the quantity stepper caps at real stock instead
  of a hard-coded 999
- contract: Shipment.items is optional and Invoice.invoice_no nullable, both
  matching what the API actually returns. Invoice was declared twice in
  types.ts and TypeScript merges duplicate interfaces, so the duplicate had to
  go for the change to take effect at all
- an anonymous add-to-cart redirects to /login?redirect=..., and sign-in
  honours only same-origin paths
- the fixed-data adapter learns the new cart fields, and its persisted state
  key moves to v2 because a cart saved by an older build is no longer valid
- order surfaces drop their storeById lookups and keep the generic store label
  until the public store read arrives

Verified end to end: two-shop cart grouping with live shop names, stock caps
read from the API, checkout, payment, shipment, delivery confirmation and an
issued invoice. Rollback re-verified with every domain on fixed data and the
backend stopped.

Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at
the mock content that remains.

OpenSpec change: openspec/changes/replace-mock-api-wave-3
2026-09-17 16:33:22 +00:00

250 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Order, OrderStatus } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const activeFilter = ref("all");
const orders = ref<Order[]>([]);
const page = ref(1);
const total = ref(0);
const perPage = ref(10);
const loading = ref(true);
const workingId = ref("");
const filterStatuses: Record<string, OrderStatus[]> = {
all: [],
pending_payment: ["pending_payment"],
pending_shipment: ["paid", "fulfilling"],
pending_receipt: ["shipped"],
completed: ["completed"],
cancelled: ["cancelled"],
};
const tabs = computed(() => [
{ key: "all", label: t("user.filterAll") },
{ key: "pending_payment", label: t("user.filterPendingPayment") },
{ key: "pending_shipment", label: t("user.filterPendingShipment") },
{ key: "pending_receipt", label: t("user.filterPendingReceipt") },
{ key: "completed", label: t("user.filterCompleted") },
{ key: "cancelled", label: t("user.filterCancelled") },
]);
const filteredOrders = computed(() => {
const statuses = filterStatuses[activeFilter.value] ?? [];
return statuses.length === 0 ? orders.value : orders.value.filter((order) => statuses.includes(order.status));
});
function setInitialFilter(): void {
const queryStatus = String(route.query.status ?? "");
const mapped = queryStatus === "paid" || queryStatus === "fulfilling"
? "pending_shipment"
: queryStatus === "shipped"
? "pending_receipt"
: queryStatus === "pending_payment" || queryStatus === "completed" || queryStatus === "cancelled"
? queryStatus
: "all";
activeFilter.value = mapped;
}
async function loadOrders(nextPage = page.value): Promise<void> {
loading.value = true;
try {
const result = await $api.listMyOrders(nextPage);
orders.value = result.items;
page.value = result.page;
total.value = result.total;
perPage.value = result.per_page;
} finally {
loading.value = false;
}
}
async function pay(order: Order): Promise<void> {
workingId.value = order.id;
try {
await $api.payOrder(order.id);
await loadOrders();
} finally {
workingId.value = "";
}
}
async function cancel(order: Order): Promise<void> {
workingId.value = order.id;
try {
await $api.cancelOrder(order.id);
await loadOrders();
} finally {
workingId.value = "";
}
}
function changeFilter(key: string): void {
activeFilter.value = key;
}
onMounted(() => {
setInitialFilter();
void loadOrders();
});
</script>
<template>
<section class="mpanel orders-panel">
<h1 class="mpanel-title">{{ t("user.ordersTitle") }}</h1>
<UiTabs :model-value="activeFilter" :tabs="tabs" @update:model-value="changeFilter">
<template #default>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="filteredOrders.length === 0" :text="t('user.noOrders')" />
<div v-else class="order-list">
<article v-for="order in filteredOrders" :key="order.id" class="order-card">
<header class="order-header">
<div>
<!-- Store names need the public store read; see docs/TBD-migrate-wave.md. -->
<strong>{{ t("user.shop") }}</strong>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span>
<span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
</div>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="order-items">
<div v-for="item in order.items" :key="item.id" class="order-item">
<img :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
<div class="item-name">
<span>{{ pick(item.product_name, locale) }}</span>
<small>{{ item.sku_code }} × {{ item.qty }}</small>
</div>
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
</div>
</div>
<footer class="order-footer">
<span>{{ t("user.orderTotal") }} <strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong></span>
<div class="actions">
<button v-if="order.status === 'pending_payment'" class="mbtn red" type="button" :disabled="workingId === order.id" @click="pay(order)">{{ t("user.payNow") }}</button>
<button v-if="order.status === 'pending_payment'" class="mbtn" type="button" :disabled="workingId === order.id" @click="cancel(order)">{{ t("user.cancelOrder") }}</button>
<NuxtLink class="mbtn" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</footer>
</article>
</div>
<UiPagination :page="page" :total="total" :per-page="perPage" @change="loadOrders" />
</template>
</UiTabs>
</section>
</template>
<style scoped>
.orders-panel {
min-height: 560px;
}
.muted {
padding: 30px 0;
color: var(--mall-faint);
}
.order-card {
margin-bottom: 14px;
border: 1px solid var(--mall-line);
}
.order-card:last-child {
margin-bottom: 0;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: #fafafa;
border-bottom: 1px solid var(--mall-line);
}
.order-header > div {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
color: var(--mall-faint);
font-size: 12px;
}
.order-header strong {
color: var(--mall-ink);
font-size: 13px;
font-weight: 600;
}
.order-items {
padding: 4px 14px;
}
.order-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
}
.order-item + .order-item {
border-top: 1px solid var(--mall-line);
}
.order-item img {
width: 54px;
height: 54px;
border: 1px solid var(--mall-line);
object-fit: contain;
}
.item-name {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
font-size: 13px;
}
.item-name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-name small {
color: var(--mall-faint);
font-size: 11px;
}
.order-item :deep(.price) {
color: var(--mall-muted);
font-size: 13px;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid var(--mall-line);
color: var(--mall-muted);
font-size: 12px;
}
.order-footer strong :deep(.price) {
color: var(--mall-red);
font-size: 16px;
}
.actions {
display: flex;
gap: 8px;
}
.actions .mbtn {
padding: 5px 11px;
}
@media (max-width: 760px) {
.order-header > div {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.order-footer {
align-items: flex-start;
flex-direction: column;
}
}
</style>