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:
+13
-1
@@ -50,6 +50,11 @@ pub struct CartItemView {
|
|||||||
pub unit_price_minor: i64,
|
pub unit_price_minor: i64,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub qty: i32,
|
pub qty: i32,
|
||||||
|
/// Owning shop, so the storefront can group lines without the mock catalog.
|
||||||
|
pub shop_id: Uuid,
|
||||||
|
pub shop_name: serde_json::Value,
|
||||||
|
/// Current SKU stock; advisory for the quantity stepper. Checkout is authoritative.
|
||||||
|
pub stock: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -67,7 +72,8 @@ pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult<CartView> {
|
|||||||
let sku_ids: Vec<Uuid> = entries.iter().map(|(id, _)| *id).collect();
|
let sku_ids: Vec<Uuid> = entries.iter().map(|(id, _)| *id).collect();
|
||||||
let rows = sqlx::query_as::<_, CartRow>(
|
let rows = sqlx::query_as::<_, CartRow>(
|
||||||
"SELECT s.id AS sku_id, p.id AS product_id, p.name AS product_name, s.sku_code,
|
"SELECT s.id AS sku_id, p.id AS product_id, p.name AS product_name, s.sku_code,
|
||||||
(p.images ->> 0) AS image, s.price_minor, s.currency
|
(p.images ->> 0) AS image, s.price_minor, s.currency, s.stock,
|
||||||
|
p.shop_id, sh.name AS shop_name
|
||||||
FROM skus s
|
FROM skus s
|
||||||
JOIN products p ON p.id = s.product_id
|
JOIN products p ON p.id = s.product_id
|
||||||
JOIN shops sh ON sh.id = p.shop_id
|
JOIN shops sh ON sh.id = p.shop_id
|
||||||
@@ -92,6 +98,9 @@ pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult<CartView> {
|
|||||||
unit_price_minor: r.price_minor,
|
unit_price_minor: r.price_minor,
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
qty: *qty,
|
qty: *qty,
|
||||||
|
shop_id: r.shop_id,
|
||||||
|
shop_name: r.shop_name,
|
||||||
|
stock: r.stock,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -107,4 +116,7 @@ struct CartRow {
|
|||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
price_minor: i64,
|
price_minor: i64,
|
||||||
currency: String,
|
currency: String,
|
||||||
|
stock: i32,
|
||||||
|
shop_id: Uuid,
|
||||||
|
shop_name: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,39 @@ async fn stock_of(app: &common::TestApp, sku_id: &str) -> i32 {
|
|||||||
async fn checkout_splits_orders_per_shop_and_clears_cart() {
|
async fn checkout_splits_orders_per_shop_and_clears_cart() {
|
||||||
let app = spawn_app().await;
|
let app = spawn_app().await;
|
||||||
let admin = login_admin(&app).await;
|
let admin = login_admin(&app).await;
|
||||||
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "split-a", 1000, 5).await;
|
let (_, shop_a, _, sku_a) = setup_sellable(&app, &admin, "split-a", 1000, 5).await;
|
||||||
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "split-b", 2000, 5).await;
|
let (_, shop_b, _, sku_b) = setup_sellable(&app, &admin, "split-b", 2000, 5).await;
|
||||||
let (buyer, _) = register_customer(&app, "buyer").await;
|
let (buyer, _) = register_customer(&app, "buyer").await;
|
||||||
|
|
||||||
add_to_cart(&app, &buyer, &sku_a, 2).await;
|
add_to_cart(&app, &buyer, &sku_a, 2).await;
|
||||||
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
||||||
|
|
||||||
|
// The cart view must carry the owning shop and current stock, so the
|
||||||
|
// storefront can group lines and cap quantity without the mock catalog.
|
||||||
|
let res = client()
|
||||||
|
.get(app.url("/api/cart"))
|
||||||
|
.bearer_auth(&buyer)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), 200);
|
||||||
|
let view: serde_json::Value = res.json().await.unwrap();
|
||||||
|
let items = view["items"].as_array().unwrap();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
let shop_ids: std::collections::HashSet<String> = items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i["shop_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(shop_ids.len(), 2, "each line reports its own shop");
|
||||||
|
assert!(shop_ids.contains(&shop_a) && shop_ids.contains(&shop_b));
|
||||||
|
for item in items {
|
||||||
|
assert!(
|
||||||
|
item["shop_name"]["en"].as_str().is_some_and(|s| !s.is_empty()),
|
||||||
|
"line must carry a bilingual shop name"
|
||||||
|
);
|
||||||
|
assert_eq!(item["stock"], 5, "stock is the SKU's, before checkout decrements it");
|
||||||
|
}
|
||||||
|
|
||||||
let orders = checkout(&app, &buyer).await;
|
let orders = checkout(&app, &buyer).await;
|
||||||
assert_eq!(orders.len(), 2, "one order per shop");
|
assert_eq!(orders.len(), 2, "one order per shop");
|
||||||
let totals: Vec<i64> = orders
|
let totals: Vec<i64> = orders
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import {
|
|||||||
BASE_CURRENCY,
|
BASE_CURRENCY,
|
||||||
MOCK_CATEGORIES,
|
MOCK_CATEGORIES,
|
||||||
MOCK_CURRENCIES,
|
MOCK_CURRENCIES,
|
||||||
|
MOCK_STORES,
|
||||||
MOCK_USER,
|
MOCK_USER,
|
||||||
defaultAddress,
|
defaultAddress,
|
||||||
mockConvertMinor,
|
mockConvertMinor,
|
||||||
productById,
|
productById,
|
||||||
searchMockProducts,
|
searchMockProducts,
|
||||||
seedOrders,
|
seedOrders,
|
||||||
|
storeById,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
|
||||||
interface MockState {
|
interface MockState {
|
||||||
@@ -38,7 +40,9 @@ interface MockState {
|
|||||||
invoiceSeq: number;
|
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">;
|
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "orderSeq" | "invoiceSeq">;
|
||||||
|
|
||||||
@@ -169,6 +173,10 @@ export function createMockApi(): ApiClient {
|
|||||||
unit_price_minor: sku.price_minor,
|
unit_price_minor: sku.price_minor,
|
||||||
currency: sku.currency,
|
currency: sku.currency,
|
||||||
qty,
|
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();
|
persist();
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export default defineNuxtConfig({
|
|||||||
apiBase: "http://localhost:8080/api",
|
apiBase: "http://localhost:8080/api",
|
||||||
// Domains served by the live backend; every other domain stays on the
|
// Domains served by the live backend; every other domain stays on the
|
||||||
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
||||||
// See openspec/changes/replace-mock-api-wave-1/design.md and wave 2.
|
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
|
||||||
liveDomains: ["catalog", "currency", "auth"],
|
liveDomains: ["catalog", "currency", "auth", "cart", "orders", "shipments", "invoices"],
|
||||||
appName: "mall",
|
appName: "mall",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-16
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import type { CartItem } from "@vmall/shared";
|
import type { CartItem, LocalizedText } from "@vmall/shared";
|
||||||
import { productById, storeById } from "~/mock/data";
|
|
||||||
|
|
||||||
type CartGroup = {
|
type CartGroup = {
|
||||||
shopId: string;
|
shopId: string;
|
||||||
store: ReturnType<typeof storeById>;
|
shopName: LocalizedText;
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,19 +27,20 @@ const stepLabels = computed(() => [
|
|||||||
t("cart.steps.complete"),
|
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 groups = computed<CartGroup[]>(() => {
|
||||||
const grouped = new Map<string, CartItem[]>();
|
const grouped = new Map<string, CartGroup>();
|
||||||
for (const item of items.value) {
|
for (const item of items.value) {
|
||||||
const shopId = productById(item.product_id)?.shop_id ?? "unknown";
|
const group = grouped.get(item.shop_id) ?? {
|
||||||
const groupItems = grouped.get(shopId) ?? [];
|
shopId: item.shop_id,
|
||||||
groupItems.push(item);
|
shopName: item.shop_name,
|
||||||
grouped.set(shopId, groupItems);
|
items: [],
|
||||||
|
};
|
||||||
|
group.items.push(item);
|
||||||
|
grouped.set(item.shop_id, group);
|
||||||
}
|
}
|
||||||
return Array.from(grouped, ([shopId, groupItems]) => ({
|
return [...grouped.values()];
|
||||||
shopId,
|
|
||||||
store: shopId === "unknown" ? null : storeById(shopId),
|
|
||||||
items: groupItems,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedItems = computed(() => items.value.filter((item) => selected[item.sku_id]));
|
const selectedItems = computed(() => items.value.filter((item) => selected[item.sku_id]));
|
||||||
@@ -53,11 +53,12 @@ const selectedTotalMinor = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
function imageFor(item: CartItem): string {
|
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 {
|
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> {
|
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">
|
<section v-for="group in groups" :key="group.shopId" class="shop-group mpanel">
|
||||||
<header class="shop-heading">
|
<header class="shop-heading">
|
||||||
<span class="shop-label">{{ t("cart.shop") }}</span>
|
<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>
|
</header>
|
||||||
<table class="mtable cart-table">
|
<table class="mtable cart-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import type { Address, CartItem } from "@vmall/shared";
|
import type { Address, CartItem, LocalizedText } from "@vmall/shared";
|
||||||
import { MOCK_ADDRESSES, productById, storeById } from "~/mock/data";
|
import { MOCK_ADDRESSES } from "~/mock/data";
|
||||||
import type { MockAddress } from "~/mock/data";
|
import type { MockAddress } from "~/mock/data";
|
||||||
|
|
||||||
type CheckoutGroup = {
|
type CheckoutGroup = {
|
||||||
shopId: string;
|
shopId: string;
|
||||||
store: ReturnType<typeof storeById>;
|
shopName: LocalizedText;
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,19 +37,19 @@ const selectedAddress = computed<MockAddress | null>(
|
|||||||
() => MOCK_ADDRESSES.find((address) => address.id === selectedAddressId.value) ?? 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 groups = computed<CheckoutGroup[]>(() => {
|
||||||
const grouped = new Map<string, CartItem[]>();
|
const grouped = new Map<string, CheckoutGroup>();
|
||||||
for (const item of items.value) {
|
for (const item of items.value) {
|
||||||
const shopId = productById(item.product_id)?.shop_id ?? "unknown";
|
const group = grouped.get(item.shop_id) ?? {
|
||||||
const groupItems = grouped.get(shopId) ?? [];
|
shopId: item.shop_id,
|
||||||
groupItems.push(item);
|
shopName: item.shop_name,
|
||||||
grouped.set(shopId, groupItems);
|
items: [],
|
||||||
|
};
|
||||||
|
group.items.push(item);
|
||||||
|
grouped.set(item.shop_id, group);
|
||||||
}
|
}
|
||||||
return Array.from(grouped, ([shopId, groupItems]) => ({
|
return [...grouped.values()];
|
||||||
shopId,
|
|
||||||
store: shopId === "unknown" ? null : storeById(shopId),
|
|
||||||
items: groupItems,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourceCurrency = computed(() => items.value[0]?.currency ?? currency.value);
|
const sourceCurrency = computed(() => items.value[0]?.currency ?? currency.value);
|
||||||
@@ -58,7 +58,7 @@ const totalMinor = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
function imageFor(item: CartItem): string {
|
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 {
|
function toAddress(address: MockAddress): Address {
|
||||||
@@ -144,8 +144,7 @@ onMounted(() => void loadCart());
|
|||||||
<h2 class="section-title">{{ t("checkout.orderPreview") }}</h2>
|
<h2 class="section-title">{{ t("checkout.orderPreview") }}</h2>
|
||||||
<div v-for="group in groups" :key="group.shopId" class="order-group">
|
<div v-for="group in groups" :key="group.shopId" class="order-group">
|
||||||
<header class="shop-heading">
|
<header class="shop-heading">
|
||||||
<img v-if="group.store" :src="group.store.logo" :alt="pick(group.store.name, locale)" />
|
<span>{{ pick(group.shopName, locale) || t("checkout.shop") }}</span>
|
||||||
<span>{{ group.store ? pick(group.store.name, locale) : t("checkout.shop") }}</span>
|
|
||||||
</header>
|
</header>
|
||||||
<div v-for="item in group.items" :key="item.sku_id" class="order-item">
|
<div v-for="item in group.items" :key="item.sku_id" class="order-item">
|
||||||
<img :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
|
<img :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import type { Order } from "@vmall/shared";
|
import type { Order } from "@vmall/shared";
|
||||||
import { storeById } from "~/mock/data";
|
|
||||||
|
|
||||||
definePageMeta({ middleware: "auth" });
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
@@ -37,11 +36,6 @@ function imageFor(image: string | null): string {
|
|||||||
return image ?? "/mock/product-1.svg";
|
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> {
|
async function loadOrders(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -91,7 +85,8 @@ onMounted(() => void loadOrders());
|
|||||||
<article v-for="order in orders" :key="order.id" class="mpanel order-card">
|
<article v-for="order in orders" :key="order.id" class="mpanel order-card">
|
||||||
<header class="order-heading">
|
<header class="order-heading">
|
||||||
<span>{{ t("checkout.orderNo") }}: {{ order.order_no }}</span>
|
<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>
|
</header>
|
||||||
<div v-for="item in order.items" :key="item.id" class="order-item">
|
<div v-for="item in order.items" :key="item.id" class="order-item">
|
||||||
<img :src="imageFor(item.image)" :alt="pick(item.product_name, locale)" />
|
<img :src="imageFor(item.image)" :alt="pick(item.product_name, locale)" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
|
import { ApiError } from "@vmall/shared";
|
||||||
import type { Category, Product, Sku } from "@vmall/shared";
|
import type { Category, Product, Sku } from "@vmall/shared";
|
||||||
import {
|
import {
|
||||||
MOCK_COUPONS,
|
MOCK_COUPONS,
|
||||||
@@ -149,7 +150,16 @@ const selectAttribute = (key: string, value: string): void => {
|
|||||||
|
|
||||||
const addToCart = async (): Promise<boolean> => {
|
const addToCart = async (): Promise<boolean> => {
|
||||||
if (!matchedSku.value || stock.value <= 0) return false;
|
if (!matchedSku.value || stock.value <= 0) return false;
|
||||||
|
try {
|
||||||
await $api.addCartItem(matchedSku.value.id, quantity.value);
|
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();
|
await cart.refresh();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ const { t } = useI18n();
|
|||||||
const { $api } = useNuxtApp();
|
const { $api } = useNuxtApp();
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const errorKey = ref("");
|
const errorKey = ref("");
|
||||||
const submitting = ref(false);
|
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 {
|
function validate(): boolean {
|
||||||
if (!email.value || !password.value) {
|
if (!email.value || !password.value) {
|
||||||
errorKey.value = "auth.validationRequired";
|
errorKey.value = "auth.validationRequired";
|
||||||
@@ -32,7 +43,7 @@ async function submit(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const auth = await $api.login(email.value, password.value);
|
const auth = await $api.login(email.value, password.value);
|
||||||
session.setAuth(auth);
|
session.setAuth(auth);
|
||||||
await router.push("/");
|
await router.push(redirectTarget.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorKey.value = error instanceof ApiError && error.status === 401
|
errorKey.value = error instanceof ApiError && error.status === 401
|
||||||
? "auth.invalidCredentials"
|
? "auth.invalidCredentials"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ onMounted(() => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="invoice in rows" :key="invoice.id">
|
<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.order_no || t("mall.notAvailable") }}</td>
|
||||||
<td>{{ invoice.title }}</td>
|
<td>{{ invoice.title }}</td>
|
||||||
<td>{{ invoice.kind === "personal" ? t("user.personal") : t("user.company") }}</td>
|
<td>{{ invoice.kind === "personal" ? t("user.personal") : t("user.company") }}</td>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Order, OrderStatus } from "@vmall/shared";
|
import type { Order, OrderStatus } from "@vmall/shared";
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import { storeById } from "~/mock/data";
|
|
||||||
|
|
||||||
definePageMeta({ middleware: "auth" });
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
@@ -89,11 +88,6 @@ function changeFilter(key: string): void {
|
|||||||
activeFilter.value = key;
|
activeFilter.value = key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shopName(shopId: string): string {
|
|
||||||
const store = storeById(shopId);
|
|
||||||
return store ? pick(store.name, locale.value) : t("user.shop");
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setInitialFilter();
|
setInitialFilter();
|
||||||
void loadOrders();
|
void loadOrders();
|
||||||
@@ -111,7 +105,8 @@ onMounted(() => {
|
|||||||
<article v-for="order in filteredOrders" :key="order.id" class="order-card">
|
<article v-for="order in filteredOrders" :key="order.id" class="order-card">
|
||||||
<header class="order-header">
|
<header class="order-header">
|
||||||
<div>
|
<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.orderNo") }} {{ order.order_no }}</span>
|
||||||
<span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
|
<span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-20
@@ -1,15 +1,15 @@
|
|||||||
# TBD — migrate the mall off the mock API (waves 3+)
|
# TBD — migrate the mall off the mock API (waves 4+)
|
||||||
|
|
||||||
Waves 1 and 2 are captured in `openspec/changes/replace-mock-api-wave-1/` (catalog +
|
Waves 1–3 are captured in `openspec/changes/replace-mock-api-wave-{1,2,3}/`: catalog +
|
||||||
currency) and `replace-mock-api-wave-2/` (auth). This file tracks what remains.
|
currency, auth, and the transaction chain (cart, orders, shipments, invoices). Every domain
|
||||||
|
the mall had an API for is now live; what remains in Wave 4 is the mock content that never
|
||||||
|
had a backend behind it.
|
||||||
|
|
||||||
**How to use:** check a box only once the behaviour is implemented *and* verified against
|
**How to use:** check a box only once the behaviour is implemented *and* verified against
|
||||||
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`). The per-domain
|
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`).
|
||||||
switch in `apps/mall/plugins/api.ts` means most of these are adding a domain name to the
|
|
||||||
live list — each still needs its own verification below.
|
|
||||||
|
|
||||||
**Delete this file** once every box in Wave 3 is checked. Wave 4 is optional: if you decide
|
**Delete this file** once every Wave 4 box is checked, or consciously dropped and recorded.
|
||||||
against it, delete this file anyway and record that decision wherever you like.
|
The "deliberately out of scope" list at the bottom does not block deleting it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,20 +33,20 @@ leaves the mock half reading state the live half never populates:
|
|||||||
- mock `listMyShipments()` returns shipments whose `order_id` matches no live order, so the
|
- mock `listMyShipments()` returns shipments whose `order_id` matches no live order, so the
|
||||||
shipment block is silently empty (`mock/api.ts:282`, `pages/user/orders/[id].vue:25`).
|
shipment block is silently empty (`mock/api.ts:282`, `pages/user/orders/[id].vue:25`).
|
||||||
|
|
||||||
- [ ] Flip `cart`, `orders`, `shipments` and `invoices` in the same change, then verify one purchase end to end: add to cart, check out into per-shop orders, pay, ship, confirm delivery, request an invoice.
|
- [x] Flip `cart`, `orders`, `shipments` and `invoices` in the same change, then verify one purchase end to end: add to cart, check out into per-shop orders, pay, ship, confirm delivery, request an invoice. Done in `replace-mock-api-wave-3`; the merchant half was driven through the API because the mall has no merchant UI.
|
||||||
- [ ] Verify cancel restores stock and `payOrder` only accepts `pending_payment` (both enforced live; the mock only mimicked them).
|
- [x] Verify cancel restores stock and `payOrder` only accepts `pending_payment`. Cancel and stock restore are asserted by `cancel_rules_and_stock_restore`; `pay_order` is a status-guarded `UPDATE ... AND status = 'pending_payment'` that answers 409 otherwise (`apps/api/src/routes/orders.rs:279-287`).
|
||||||
- [ ] Verify a company invoice requires a tax number, and that one order can hold only one active invoice.
|
- [x] Verify a company invoice requires a tax number, and that one order can hold only one active invoice. Asserted by `invoice_lifecycle` (400 without a tax number, 409 on the second invoice).
|
||||||
- [ ] Remove cart's mock display coupling: `apps/mall/pages/cart.vue:4,34,41,56,60` uses `productById` / `storeById` for shop name, image and stock. The live `CartView` already returns `product_name`, `image` and `unit_price_minor` (`apps/api/src/cart.rs:44-53`) — but **not** the shop, so decide whether to add a shop field to `CartItem` in the shared contract or accept ungrouped lines until the public store read exists.
|
- [x] Remove cart's mock display coupling. `CartItemView` now carries `shop_id`, `shop_name` and `stock` (`apps/api/src/cart.rs`), and `pages/cart.vue` groups by them.
|
||||||
- [ ] Decide what caps cart quantity: `CartItem` carries no `stock`, so `maxFor` falls back to 999 (`pages/cart.vue:60`). The live cart does not enforce stock either — only checkout does (409). Either add `stock` to `CartItem` or keep the cap at checkout and say so.
|
- [x] Decide what caps cart quantity. `CartItem.stock` is exposed and the stepper caps at it, but the API deliberately does not check stock on add — checkout stays authoritative with its 409.
|
||||||
- [ ] Gate add-to-cart for anonymous shoppers: a live `addCartItem` on the public product page returns 401, and `pages/goods/[id].vue` is not behind the auth middleware.
|
- [x] Gate add-to-cart for anonymous shoppers: `pages/goods/[id].vue` sends a 401 to `/login?redirect=…`, and `pages/login.vue` honours only same-origin paths.
|
||||||
- [ ] Checkout keeps sourcing `shipping_address` from `MOCK_ADDRESSES` (`pages/checkout/index.vue:4,23,37,127`) — intentional; see the out-of-scope note below.
|
- [x] Checkout keeps sourcing `shipping_address` from `MOCK_ADDRESSES` — intentional; see the out-of-scope note below.
|
||||||
- [ ] Fix contract debt so the TS types stop lying: `Shipment.items` is required in `packages/shared/src/types.ts` but the live struct has no `items` field (`apps/api/src/models.rs:172-182`), and `Invoice.invoice_no` is nullable live (`models.rs:194`) but non-null `string` in TS.
|
- [x] Fix contract debt: `Shipment.items` is optional and `Invoice.invoice_no` is nullable, matching what the API returns. `Invoice` was declared twice in `packages/shared/src/types.ts` and TypeScript merges duplicate interfaces, so the duplicate had to go for the change to take effect.
|
||||||
- [ ] Remove the remaining `storeById` mock usage on the order pages (`apps/mall/pages/user/orders/index.vue:4`).
|
- [x] Remove the remaining `storeById` mock usage on the order pages. They animate the generic store label instead; the real names need the public store read below.
|
||||||
- [ ] Confirm the mall still renders when the live API is down, with every domain configured to fixed data.
|
- [x] Confirm the mall still renders when the live API is down, with every domain configured to fixed data.
|
||||||
|
|
||||||
## Wave 4 — optional, mostly new backend capabilities
|
## Wave 4 — the mock content that never had an API
|
||||||
|
|
||||||
Only if you want more of the storefront backed by real data. Each is a new capability, not a flip.
|
Each is a new backend capability rather than a domain flip.
|
||||||
|
|
||||||
- [ ] **Storefront content** — banners, promos, quick links and floor advert art. Needs real tables, admin CRUD and i18n JSONB. Do this first if you want the home page fully live; it is the most visible remaining mock surface.
|
- [ ] **Storefront content** — banners, promos, quick links and floor advert art. Needs real tables, admin CRUD and i18n JSONB. Do this first if you want the home page fully live; it is the most visible remaining mock surface.
|
||||||
- [ ] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Small: products already carry `shop_id`, and the public catalog already joins shops for the active check.
|
- [ ] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Small: products already carry `shop_id`, and the public catalog already joins shops for the active check.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-17
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See `proposal.md` — Why. After waves 1 and 2 the mall serves catalog, currency and auth live through the per-domain `liveDomains` switch (`apps/mall/plugins/api.ts`); cart, orders, shipments and invoices remain on fixed data. Facts that shape this design:
|
||||||
|
|
||||||
|
- The fixed-data adapter holds `state.cart -> state.orders -> state.shipments` in one localStorage blob (`apps/mall/mock/api.ts:31-43`): `checkout()` reads `state.cart` (`:194`), `requestInvoice()` looks up `state.orders` (`:285`), `listMyShipments()` returns `state.shipments` (`:282`).
|
||||||
|
- The live cart is Redis-backed per user; `cart_view` already joins products and shops (`apps/api/src/cart.rs:68-74`) but selects only `price_minor` and `currency`.
|
||||||
|
- The live cart checks purchasability, not stock (`apps/api/src/routes/cart.rs:31-47`); checkout is where stock is enforced, answering 409.
|
||||||
|
- `packages/shared/src/types.ts` requires `Shipment.items` and a non-null `Invoice.invoice_no`; the API returns neither (`apps/api/src/models.rs:172-182`, `:192-205`).
|
||||||
|
- Order and payment pages already fall back to a generic store label when `storeById` misses (`pages/user/orders/index.vue:94`, `pages/checkout/pay.vue:42`), so live orders degrade rather than break.
|
||||||
|
- `pages/goods/[id].vue` is public and is not behind `middleware/auth.ts`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- One purchase runs end to end against the backend: cart → per-shop orders → payment → shipment → invoice.
|
||||||
|
- The cart keeps grouping per shop and capping quantity at real stock.
|
||||||
|
- The shared contract stops describing fields the API never sends.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- No stock check on add-to-cart; checkout stays the authority.
|
||||||
|
- No store names on order or shipment surfaces — the Wave 4 public store read owns that.
|
||||||
|
- No addresses model, and no deletion of the mock cart/order code, which the rollback path needs.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. The four domains flip together, in dependency order.**
|
||||||
|
They share entities, so any subset leaves the mock half reading state the live half never writes. Cart must precede orders, orders precede shipments and invoices, and the flip lands in a single commit so no shopper can reach a live cart in front of a mock checkout.
|
||||||
|
*Alternative:* bridge a live cart into the mock's order state — rejected as throwaway code that would still be wrong for invoice lookups.
|
||||||
|
|
||||||
|
**2. Add `shop_id`, `shop_name` and `stock` to `CartItemView` rather than reading the mock catalog.**
|
||||||
|
The join already exists (`cart.rs:68-74`), so this is three columns. Without it every live cart line collapses into one `"unknown"` group (`pages/cart.vue:31-42`) and the quantity stepper falls back to a hard-coded 999 (`pages/cart.vue:60`) — both visible regressions of things the UI currently does correctly.
|
||||||
|
*Alternative:* derive the shop client-side — impossible, a live cart line carries no shop.
|
||||||
|
|
||||||
|
**3. Stock stays advisory in the cart.**
|
||||||
|
Exposing `stock` lets the stepper cap, matching the mock's behaviour, but the API deliberately does not re-check it when adding: two shoppers can race regardless, and checkout's 409 is the real gate. Recorded explicitly so nobody mistakes the cart for a stock reservation.
|
||||||
|
|
||||||
|
**4. An anonymous add-to-cart redirects to `/login?redirect=…`.**
|
||||||
|
The alternative — an inline "sign in to buy" panel — still leaves the shopper to find sign-in themselves, and a return path is needed either way. The `redirect` value is accepted only as a same-origin path, so it cannot become an open redirect.
|
||||||
|
|
||||||
|
**5. Fix the contract by relaxing the types, not by inventing API fields.**
|
||||||
|
`Shipment.items` becomes optional and `Invoice.invoice_no` becomes nullable. Nothing consumes shipment items, and the API genuinely does not send them, so adding fields nobody reads would be speculative. The invoices table renders a placeholder for a null number, mirroring how it already handles a missing `order_no` (`pages/user/invoices.vue:46`).
|
||||||
|
|
||||||
|
**6. Keep the fixed-data cart and order code.**
|
||||||
|
The `Mock API adapter` requirement promises the adapter can still serve every domain, so deleting it would break the documented rollback. This wave adds nothing to it, and touches no requirement that waves 1 or 2 modify — so archive order cannot clobber their text.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Shoppers lose their existing mock cart and orders] → intended and marked BREAKING; the localStorage blob is left untouched, so rolling `liveDomains` back restores it.
|
||||||
|
- [Exposed stock can go stale between read and checkout] → advisory by design (decision 3); checkout remains authoritative.
|
||||||
|
- [Order surfaces lose real store names] → pre-existing fallback to a generic label, unchanged here, owned by Wave 4.
|
||||||
|
- [A live cart needs a token while the product page is public] → decision 4 is the gate, verified explicitly for a signed-out shopper.
|
||||||
|
- [Four domains moving at once is a large diff] → the milestone order in `tasks.md` keeps the backend additive and each verification step independent.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Backend and contract first: extend `cart_view`'s selected columns and the shared types. Both are additive and still serve the fixed-data path.
|
||||||
|
2. Flip the four domains in `liveDomains` in the same commit as the page changes.
|
||||||
|
3. Verify one purchase end to end, then verify the all-fixed-data rollback with the backend stopped.
|
||||||
|
4. Rollback: remove the four names from `liveDomains`; no data migration to reverse.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Cart, orders, shipments and invoices are the last mocked domains, and they cannot move one at a time: the fixed-data adapter keeps `cart -> orders -> shipments` in one shared state, so a live cart with a mock checkout dies on `EMPTY_CART` and mock invoices 404 on live order ids.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Flip `cart`, `orders`, `shipments` and `invoices` to live **together**, so one purchase runs end to end.
|
||||||
|
- **BREAKING** (data): cart and order state moves from `localStorage` to Redis/Postgres; existing mock carts and orders do not carry over.
|
||||||
|
- Extend the cart line with its shop and stock so the cart keeps grouping per shop and the stepper caps at real stock; `CartItemView` already joins both (`apps/api/src/cart.rs:68-74`).
|
||||||
|
- Ask an anonymous shopper to sign in rather than fail: a live `addCartItem` answers 401 on a public page, which redirects to `/login?redirect=…`.
|
||||||
|
- Stop the shared contract lying: `Shipment.items` is required in TS but never returned, and `Invoice.invoice_no` is nullable live but non-null in TS.
|
||||||
|
- Keep `MOCK_ADDRESSES` behind checkout: live checkout takes the address in the request body.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `cart`: the cart view carries each line's shop and current stock.
|
||||||
|
- `frontend-mall`: the shopping and transaction flows run against the live API, and an anonymous add-to-cart prompts sign-in.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
`apps/api/src/cart.rs`; `packages/shared/src/{api,types}.ts`; the mall's `plugins/api.ts`, `nuxt.config.ts`, cart/checkout/order/invoice pages and `middleware/auth.ts`. The contract change means all three frontends rebuild.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
No addresses, favourites, coupons or storefront content. No stock check on add-to-cart; checkout stays the authority. Store names on order surfaces keep their generic fallback until Wave 4.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Server-side cart
|
||||||
|
Authenticated shoppers SHALL have a Redis-backed cart keyed by user id, containing sku_id + qty entries. Reading the cart SHALL return, for every line, the SKU's current price, currency and stock together with the product's name, image and owning shop, so the storefront can group lines by shop and cap quantity without reading the fixed-data catalog. Lines whose SKU has become inactive or unpurchasable SHALL be omitted from the view.
|
||||||
|
|
||||||
|
#### Scenario: add and update
|
||||||
|
- **WHEN** a shopper POSTs sku + qty, then PUTs a new qty
|
||||||
|
- **THEN** GET /api/cart reflects the latest qty with current price/name snapshot
|
||||||
|
|
||||||
|
#### Scenario: unpurchasable SKU rejected
|
||||||
|
- **WHEN** adding a SKU that is inactive or whose product is not published
|
||||||
|
- **THEN** the API returns 400
|
||||||
|
|
||||||
|
#### Scenario: cart view carries shop and stock
|
||||||
|
- **WHEN** a shopper reads a cart holding SKUs from more than one shop
|
||||||
|
- **THEN** each line reports its shop and the SKU's current stock, so the storefront can group the lines per shop and cap quantity from the response alone
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Shopping flow
|
||||||
|
A shopper SHALL be able to browse, view detail, add to cart, checkout with a shipping address, pay, track orders/shipments, confirm delivery, and request an invoice against the live API. Cart, order, shipment and invoice state SHALL be the backend's rather than the browser's, and the fixed-data adapter SHALL remain available as a configured fallback rather than the default. Adding to the cart SHALL require an authenticated shopper: an anonymous add SHALL send the shopper to sign in and return them to where they left off.
|
||||||
|
|
||||||
|
#### Scenario: end-to-end purchase
|
||||||
|
- **WHEN** a shopper completes checkout on a non-empty cart
|
||||||
|
- **THEN** the resulting order appears in the buyer center and the cart is empty
|
||||||
|
|
||||||
|
#### Scenario: anonymous add prompts sign-in
|
||||||
|
- **WHEN** a signed-out shopper adds an in-stock SKU from a product page
|
||||||
|
- **THEN** they are sent to sign in and, once signed in, returned to that product page
|
||||||
|
|
||||||
|
### Requirement: Mock transaction flow
|
||||||
|
The mall SHALL provide a store-grouped cart, address-selecting checkout preview, payment selection and payment-success result, all reading and writing the live cart and order APIs. Quantity changes, removals, selection totals, checkout and payment SHALL be persisted by the backend for the signed-in shopper, so they survive a page reload.
|
||||||
|
|
||||||
|
#### Scenario: complete mock purchase
|
||||||
|
- **WHEN** a shopper adds an in-stock SKU, checks out with a mock address and confirms a payment
|
||||||
|
- **THEN** the cart is cleared, the success page is shown and the new order appears in the user order list
|
||||||
|
|
||||||
|
#### Scenario: cart survives a reload
|
||||||
|
- **WHEN** a signed-in shopper adds an item and then reloads the page
|
||||||
|
- **THEN** the cart still holds that item, priced and stocked from the catalog
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Contract and backend cart view
|
||||||
|
|
||||||
|
- [x] 1.1 Add `shop_id`, `shop_name` and `stock` to `CartItemView` and to the `cart_view` SELECT in `apps/api/src/cart.rs` (the query already joins `products`, `shops` and `skus`); verified `GET /api/cart` returns a distinct shop and the SKU's stock per line for a two-shop cart
|
||||||
|
- [x] 1.2 Add `shop_id`, `shop_name` and `stock` to `CartItem` in `packages/shared/src/types.ts`. Also had to teach the fixed-data adapter to construct the new fields (`apps/mall/mock/api.ts`) and bump its persisted-state key to `v2`, because a cart saved by an older build is no longer a valid `CartItem[]`; verified the mall, shop-admin and admin builds all pass
|
||||||
|
- [x] 1.3 Make `Shipment.items` optional and `Invoice.invoice_no` nullable in `packages/shared/src/types.ts`. `Invoice` was declared twice in that file and TypeScript merges duplicate interfaces, so the duplicate was removed for the change to take effect at all; verified against a live shipment (no `items` key) and a requested invoice (`invoice_no: null`)
|
||||||
|
- [x] 1.4 Extend the two-shop cart test in `apps/api/tests/orders.rs` to assert each line reports its own shop, a bilingual shop name and the SKU stock; verified `cargo test -p vmall-api --test orders` is green
|
||||||
|
|
||||||
|
## 2. Anonymous add-to-cart gate
|
||||||
|
|
||||||
|
- [x] 2.1 Send a signed-out shopper to `/login?redirect=<current path>` when `addCartItem` answers 401, accepting only same-origin paths; verified the parameter is dropped for `//host` and absolute URLs
|
||||||
|
- [x] 2.2 Have `pages/login.vue` return the shopper to a valid `redirect` target after a successful sign-in; verified the round trip from a product page
|
||||||
|
|
||||||
|
## 3. Cart surface
|
||||||
|
|
||||||
|
- [x] 3.1 Group `pages/cart.vue` by the line's own `shop_id` and label each group with `shop_name`, dropping the `productById`/`storeById` lookups; verified a two-shop cart renders two groups labelled with the live names (`Aurora Digital`, `Demo Store`)
|
||||||
|
- [x] 3.2 Cap the quantity stepper with the line's `stock` instead of the 999 fallback, and use the line's `image` for the thumbnail; verified the caps read `12` and `25` from the live cart rather than 999
|
||||||
|
|
||||||
|
## 4. Transaction surfaces
|
||||||
|
|
||||||
|
- [x] 4.1 `pages/checkout/index.vue`: group by the cart line's shop and image rather than the mock catalog, keeping `MOCK_ADDRESSES` as the address source; verified the checkout preview groups correctly and the order submits
|
||||||
|
- [x] 4.2 `pages/checkout/pay.vue` and `pages/user/orders/index.vue`: drop the `storeById` lookups and keep the existing generic store label until Wave 4; verified order cards render with the placeholder and without errors
|
||||||
|
- [x] 4.3 `pages/user/invoices.vue`: render a placeholder when `invoice_no` is null, mirroring the existing `order_no` handling; verified the invoices table shows `—` for a requested-but-unissued invoice alongside real `INV…` numbers
|
||||||
|
|
||||||
|
## 5. Flip the domains
|
||||||
|
|
||||||
|
- [x] 5.1 Add `cart`, `orders`, `shipments` and `invoices` to the default `liveDomains` in `apps/mall/nuxt.config.ts` in one commit with the page changes above; verified every one of those pages now reads from `:8080`
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
- [x] 6.1 Run `pnpm --filter @vmall/mall build` plus the shop-admin and admin builds, since the shared contract changed; all three pass
|
||||||
|
- [x] 6.2 With the backend seeded, ran one purchase in a browser: signed in, added from a product page, saw a two-shop cart group correctly, checked out, paid, then confirmed the order. The merchant half of the chain (ship → issue invoice) has no mall UI and was driven through the API, after which the order page showed the live shipment, confirming delivery moved the order to `completed`, and the invoices page listed the issued numbers. The only console entries were the header's signed-out `GET /api/cart` 401 (caught, count 0) and no hydration warnings
|
||||||
|
- [x] 6.3 Verified the rollback: with every domain set to fixed data and the backend stopped, sign-in, add-to-cart, cart grouping, the stock cap and checkout all still work from localStorage
|
||||||
@@ -89,6 +89,11 @@ export interface CartItem {
|
|||||||
unit_price_minor: number;
|
unit_price_minor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
qty: number;
|
qty: number;
|
||||||
|
/** Owning shop, so the storefront can group lines without the mock catalog. */
|
||||||
|
shop_id: string;
|
||||||
|
shop_name: LocalizedText;
|
||||||
|
/** Current SKU stock; advisory, since checkout is what enforces it. */
|
||||||
|
stock: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Cart {
|
export interface Cart {
|
||||||
@@ -130,7 +135,8 @@ export type InvoiceKind = "personal" | "company";
|
|||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
invoice_no: string;
|
/** Null until the shop issues the invoice. */
|
||||||
|
invoice_no: string | null;
|
||||||
order_id: string;
|
order_id: string;
|
||||||
order_no?: string;
|
order_no?: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -163,7 +169,8 @@ export interface Shipment {
|
|||||||
carrier: string;
|
carrier: string;
|
||||||
tracking_no: string;
|
tracking_no: string;
|
||||||
status: ShipmentStatus;
|
status: ShipmentStatus;
|
||||||
items: { order_item_id: string; qty: number }[];
|
/** Not returned by the API; only shipment creation sends line items. */
|
||||||
|
items?: { order_item_id: string; qty: number }[];
|
||||||
shipped_at: string | null;
|
shipped_at: string | null;
|
||||||
delivered_at: string | null;
|
delivered_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -171,21 +178,6 @@ export interface Shipment {
|
|||||||
|
|
||||||
export type InvoiceStatus = "requested" | "issued" | "cancelled";
|
export type InvoiceStatus = "requested" | "issued" | "cancelled";
|
||||||
|
|
||||||
export interface Invoice {
|
|
||||||
id: string;
|
|
||||||
invoice_no: string;
|
|
||||||
order_id: string;
|
|
||||||
order_no?: string;
|
|
||||||
title: string;
|
|
||||||
tax_no: string | null;
|
|
||||||
kind: "personal" | "company";
|
|
||||||
amount_minor: number;
|
|
||||||
currency: string;
|
|
||||||
status: InvoiceStatus;
|
|
||||||
issued_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Paged<T> {
|
export interface Paged<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user