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
This commit is contained in:
2026-09-17 16:33:22 +00:00
parent 0ceb4a2b25
commit e1a0a5dbdb
19 changed files with 314 additions and 92 deletions
+17 -16
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { CartItem } from "@vmall/shared";
import { productById, storeById } from "~/mock/data";
import type { CartItem, LocalizedText } from "@vmall/shared";
type CartGroup = {
shopId: string;
store: ReturnType<typeof storeById>;
shopName: LocalizedText;
items: CartItem[];
};
@@ -28,19 +27,20 @@ const stepLabels = computed(() => [
t("cart.steps.complete"),
]);
// Grouped by the line's own shop, which the cart API returns; the fixed-data
// catalog is no longer consulted for shop or stock.
const groups = computed<CartGroup[]>(() => {
const grouped = new Map<string, CartItem[]>();
const grouped = new Map<string, CartGroup>();
for (const item of items.value) {
const shopId = productById(item.product_id)?.shop_id ?? "unknown";
const groupItems = grouped.get(shopId) ?? [];
groupItems.push(item);
grouped.set(shopId, groupItems);
const group = grouped.get(item.shop_id) ?? {
shopId: item.shop_id,
shopName: item.shop_name,
items: [],
};
group.items.push(item);
grouped.set(item.shop_id, group);
}
return Array.from(grouped, ([shopId, groupItems]) => ({
shopId,
store: shopId === "unknown" ? null : storeById(shopId),
items: groupItems,
}));
return [...grouped.values()];
});
const selectedItems = computed(() => items.value.filter((item) => selected[item.sku_id]));
@@ -53,11 +53,12 @@ const selectedTotalMinor = computed(() =>
);
function imageFor(item: CartItem): string {
return productById(item.product_id)?.images[0] ?? item.image ?? "/mock/product-1.svg";
return item.image ?? "/mock/product-1.svg";
}
function maxFor(item: CartItem): number {
return productById(item.product_id)?.skus?.find((sku) => sku.id === item.sku_id)?.stock ?? 999;
// Advisory stock from the cart line; checkout is what actually enforces it.
return Math.max(1, item.stock);
}
async function loadCart(): Promise<void> {
@@ -133,7 +134,7 @@ onMounted(() => void loadCart());
<section v-for="group in groups" :key="group.shopId" class="shop-group mpanel">
<header class="shop-heading">
<span class="shop-label">{{ t("cart.shop") }}</span>
<strong>{{ group.store ? pick(group.store.name, locale) : t("cart.unknownStore") }}</strong>
<strong>{{ pick(group.shopName, locale) || t("cart.unknownStore") }}</strong>
</header>
<table class="mtable cart-table">
<thead>