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:
@@ -20,12 +20,14 @@ import {
|
||||
BASE_CURRENCY,
|
||||
MOCK_CATEGORIES,
|
||||
MOCK_CURRENCIES,
|
||||
MOCK_STORES,
|
||||
MOCK_USER,
|
||||
defaultAddress,
|
||||
mockConvertMinor,
|
||||
productById,
|
||||
searchMockProducts,
|
||||
seedOrders,
|
||||
storeById,
|
||||
} from "./data";
|
||||
|
||||
interface MockState {
|
||||
@@ -38,7 +40,9 @@ interface MockState {
|
||||
invoiceSeq: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "vmall.mock.state.v1";
|
||||
// v2: cart lines gained shop_id/shop_name/stock, so state saved by an older
|
||||
// build is no longer a valid CartItem[].
|
||||
const STORAGE_KEY = "vmall.mock.state.v2";
|
||||
|
||||
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "orderSeq" | "invoiceSeq">;
|
||||
|
||||
@@ -169,6 +173,10 @@ export function createMockApi(): ApiClient {
|
||||
unit_price_minor: sku.price_minor,
|
||||
currency: sku.currency,
|
||||
qty,
|
||||
// The live cart view carries these too; see replace-mock-api-wave-3.
|
||||
shop_id: entry.product.shop_id,
|
||||
shop_name: storeById(entry.product.shop_id)?.name ?? MOCK_STORES[0].name,
|
||||
stock: sku.stock,
|
||||
});
|
||||
}
|
||||
persist();
|
||||
|
||||
@@ -8,8 +8,8 @@ export default defineNuxtConfig({
|
||||
apiBase: "http://localhost:8080/api",
|
||||
// Domains served by the live backend; every other domain stays on the
|
||||
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
||||
// See openspec/changes/replace-mock-api-wave-1/design.md and wave 2.
|
||||
liveDomains: ["catalog", "currency", "auth"],
|
||||
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
|
||||
liveDomains: ["catalog", "currency", "auth", "cart", "orders", "shipments", "invoices"],
|
||||
appName: "mall",
|
||||
},
|
||||
},
|
||||
|
||||
+17
-16
@@ -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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { Address, CartItem } from "@vmall/shared";
|
||||
import { MOCK_ADDRESSES, productById, storeById } from "~/mock/data";
|
||||
import type { Address, CartItem, LocalizedText } from "@vmall/shared";
|
||||
import { MOCK_ADDRESSES } from "~/mock/data";
|
||||
import type { MockAddress } from "~/mock/data";
|
||||
|
||||
type CheckoutGroup = {
|
||||
shopId: string;
|
||||
store: ReturnType<typeof storeById>;
|
||||
shopName: LocalizedText;
|
||||
items: CartItem[];
|
||||
};
|
||||
|
||||
@@ -37,19 +37,19 @@ const selectedAddress = computed<MockAddress | null>(
|
||||
() => MOCK_ADDRESSES.find((address) => address.id === selectedAddressId.value) ?? null,
|
||||
);
|
||||
|
||||
// Grouped by the cart line's own shop, which the cart API returns.
|
||||
const groups = computed<CheckoutGroup[]>(() => {
|
||||
const grouped = new Map<string, CartItem[]>();
|
||||
const grouped = new Map<string, CheckoutGroup>();
|
||||
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 sourceCurrency = computed(() => items.value[0]?.currency ?? currency.value);
|
||||
@@ -58,7 +58,7 @@ const totalMinor = 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 toAddress(address: MockAddress): Address {
|
||||
@@ -144,8 +144,7 @@ onMounted(() => void loadCart());
|
||||
<h2 class="section-title">{{ t("checkout.orderPreview") }}</h2>
|
||||
<div v-for="group in groups" :key="group.shopId" class="order-group">
|
||||
<header class="shop-heading">
|
||||
<img v-if="group.store" :src="group.store.logo" :alt="pick(group.store.name, locale)" />
|
||||
<span>{{ group.store ? pick(group.store.name, locale) : t("checkout.shop") }}</span>
|
||||
<span>{{ pick(group.shopName, locale) || t("checkout.shop") }}</span>
|
||||
</header>
|
||||
<div v-for="item in group.items" :key="item.sku_id" class="order-item">
|
||||
<img :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { Order } from "@vmall/shared";
|
||||
import { storeById } from "~/mock/data";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -37,11 +36,6 @@ function imageFor(image: string | null): string {
|
||||
return image ?? "/mock/product-1.svg";
|
||||
}
|
||||
|
||||
function storeName(shopId: string): string {
|
||||
const store = storeById(shopId);
|
||||
return store ? pick(store.name, locale.value) : t("checkout.shop");
|
||||
}
|
||||
|
||||
async function loadOrders(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
@@ -91,7 +85,8 @@ onMounted(() => void loadOrders());
|
||||
<article v-for="order in orders" :key="order.id" class="mpanel order-card">
|
||||
<header class="order-heading">
|
||||
<span>{{ t("checkout.orderNo") }}: {{ order.order_no }}</span>
|
||||
<span class="shop-name">{{ storeName(order.shop_id) }}</span>
|
||||
<!-- Store names need the public store read; see docs/TBD-migrate-wave.md. -->
|
||||
<span class="shop-name">{{ t("checkout.shop") }}</span>
|
||||
</header>
|
||||
<div v-for="item in order.items" :key="item.id" class="order-item">
|
||||
<img :src="imageFor(item.image)" :alt="pick(item.product_name, locale)" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type { Category, Product, Sku } from "@vmall/shared";
|
||||
import {
|
||||
MOCK_COUPONS,
|
||||
@@ -149,7 +150,16 @@ const selectAttribute = (key: string, value: string): void => {
|
||||
|
||||
const addToCart = async (): Promise<boolean> => {
|
||||
if (!matchedSku.value || stock.value <= 0) return false;
|
||||
await $api.addCartItem(matchedSku.value.id, quantity.value);
|
||||
try {
|
||||
await $api.addCartItem(matchedSku.value.id, quantity.value);
|
||||
} catch (error) {
|
||||
// A live cart needs a token; send a signed-out shopper to sign in and back.
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
await router.push(`/login?redirect=${encodeURIComponent(route.fullPath)}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await cart.refresh();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -5,12 +5,23 @@ const { t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const errorKey = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
/**
|
||||
* Where to go after signing in. Only a same-origin absolute path is honoured:
|
||||
* "//host" and "https://host" would otherwise turn this into an open redirect.
|
||||
*/
|
||||
const redirectTarget = computed((): string => {
|
||||
const raw = route.query.redirect;
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : "/";
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
if (!email.value || !password.value) {
|
||||
errorKey.value = "auth.validationRequired";
|
||||
@@ -32,7 +43,7 @@ async function submit(): Promise<void> {
|
||||
try {
|
||||
const auth = await $api.login(email.value, password.value);
|
||||
session.setAuth(auth);
|
||||
await router.push("/");
|
||||
await router.push(redirectTarget.value);
|
||||
} catch (error) {
|
||||
errorKey.value = error instanceof ApiError && error.status === 401
|
||||
? "auth.invalidCredentials"
|
||||
|
||||
@@ -42,7 +42,7 @@ onMounted(() => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="invoice in rows" :key="invoice.id">
|
||||
<td>{{ invoice.invoice_no }}</td>
|
||||
<td>{{ invoice.invoice_no || t("mall.notAvailable") }}</td>
|
||||
<td>{{ invoice.order_no || t("mall.notAvailable") }}</td>
|
||||
<td>{{ invoice.title }}</td>
|
||||
<td>{{ invoice.kind === "personal" ? t("user.personal") : t("user.company") }}</td>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Order, OrderStatus } from "@vmall/shared";
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import { storeById } from "~/mock/data";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -89,11 +88,6 @@ function changeFilter(key: string): void {
|
||||
activeFilter.value = key;
|
||||
}
|
||||
|
||||
function shopName(shopId: string): string {
|
||||
const store = storeById(shopId);
|
||||
return store ? pick(store.name, locale.value) : t("user.shop");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setInitialFilter();
|
||||
void loadOrders();
|
||||
@@ -111,7 +105,8 @@ onMounted(() => {
|
||||
<article v-for="order in filteredOrders" :key="order.id" class="order-card">
|
||||
<header class="order-header">
|
||||
<div>
|
||||
<strong>{{ shopName(order.shop_id) }}</strong>
|
||||
<!-- 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>
|
||||
|
||||
Reference in New Issue
Block a user