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
352 lines
9.3 KiB
Vue
352 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import { t as pick } from "@vmall/shared";
|
|
import type { CartItem, LocalizedText } from "@vmall/shared";
|
|
|
|
type CartGroup = {
|
|
shopId: string;
|
|
shopName: LocalizedText;
|
|
items: CartItem[];
|
|
};
|
|
|
|
const { $api } = useNuxtApp();
|
|
const { locale, t } = useI18n();
|
|
const { currency } = usePrefs();
|
|
const cartStore = useCartStore();
|
|
const router = useRouter();
|
|
|
|
const items = ref<CartItem[]>([]);
|
|
const selected = reactive<Record<string, boolean>>({});
|
|
const loading = ref(true);
|
|
const mutating = ref(false);
|
|
const error = ref("");
|
|
|
|
const stepLabels = computed(() => [
|
|
t("cart.steps.cart"),
|
|
t("cart.steps.order"),
|
|
t("cart.steps.payment"),
|
|
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, CartGroup>();
|
|
for (const item of items.value) {
|
|
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 [...grouped.values()];
|
|
});
|
|
|
|
const selectedItems = computed(() => items.value.filter((item) => selected[item.sku_id]));
|
|
const allSelected = computed(
|
|
() => items.value.length > 0 && items.value.every((item) => selected[item.sku_id]),
|
|
);
|
|
const selectedCurrency = computed(() => selectedItems.value[0]?.currency ?? currency.value);
|
|
const selectedTotalMinor = computed(() =>
|
|
selectedItems.value.reduce((total, item) => total + item.unit_price_minor * item.qty, 0),
|
|
);
|
|
|
|
function imageFor(item: CartItem): string {
|
|
return item.image ?? "/mock/product-1.svg";
|
|
}
|
|
|
|
function maxFor(item: CartItem): number {
|
|
// Advisory stock from the cart line; checkout is what actually enforces it.
|
|
return Math.max(1, item.stock);
|
|
}
|
|
|
|
async function loadCart(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const snapshot = await $api.getCart();
|
|
const previous = { ...selected };
|
|
for (const skuId of Object.keys(selected)) delete selected[skuId];
|
|
items.value = snapshot.items;
|
|
for (const item of items.value) selected[item.sku_id] = previous[item.sku_id] ?? true;
|
|
} catch {
|
|
error.value = t("cart.loadError");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function setAllSelected(checked: boolean): void {
|
|
for (const item of items.value) selected[item.sku_id] = checked;
|
|
}
|
|
|
|
function onSelectionChange(skuId: string, event: Event): void {
|
|
selected[skuId] = (event.target as HTMLInputElement).checked;
|
|
}
|
|
|
|
async function updateQuantity(item: CartItem, qty: number): Promise<void> {
|
|
mutating.value = true;
|
|
error.value = "";
|
|
try {
|
|
await $api.updateCartItem(item.sku_id, qty);
|
|
await loadCart();
|
|
await cartStore.refresh();
|
|
} catch {
|
|
error.value = t("cart.updateError");
|
|
} finally {
|
|
mutating.value = false;
|
|
}
|
|
}
|
|
|
|
async function removeItem(item: CartItem): Promise<void> {
|
|
mutating.value = true;
|
|
error.value = "";
|
|
try {
|
|
await $api.removeCartItem(item.sku_id);
|
|
await loadCart();
|
|
await cartStore.refresh();
|
|
} catch {
|
|
error.value = t("cart.updateError");
|
|
} finally {
|
|
mutating.value = false;
|
|
}
|
|
}
|
|
|
|
async function goToCheckout(): Promise<void> {
|
|
if (selectedItems.value.length === 0) return;
|
|
await router.push("/checkout");
|
|
}
|
|
|
|
onMounted(() => void loadCart());
|
|
</script>
|
|
|
|
<template>
|
|
<div class="transaction-page cart-page">
|
|
<div class="w1200">
|
|
<UiStepBar :steps="stepLabels" :active="0" />
|
|
<header class="page-heading">
|
|
<h1>{{ t("cart.title") }}</h1>
|
|
</header>
|
|
|
|
<p v-if="error" class="error-message">{{ error }}</p>
|
|
<template v-if="!loading && items.length > 0">
|
|
<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>{{ pick(group.shopName, locale) || t("cart.unknownStore") }}</strong>
|
|
</header>
|
|
<table class="mtable cart-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="check-col" />
|
|
<th class="product-col">{{ t("cart.product") }}</th>
|
|
<th>{{ t("cart.spec") }}</th>
|
|
<th>{{ t("cart.unitPrice") }}</th>
|
|
<th>{{ t("cart.quantity") }}</th>
|
|
<th>{{ t("cart.subtotal") }}</th>
|
|
<th>{{ t("cart.actions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in group.items" :key="item.sku_id">
|
|
<td class="check-col">
|
|
<input
|
|
:checked="Boolean(selected[item.sku_id])"
|
|
type="checkbox"
|
|
:aria-label="pick(item.product_name, locale)"
|
|
@change="onSelectionChange(item.sku_id, $event)"
|
|
/>
|
|
</td>
|
|
<td class="product-cell">
|
|
<img :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
|
|
<span>{{ pick(item.product_name, locale) }}</span>
|
|
</td>
|
|
<td class="muted">{{ item.sku_code }}</td>
|
|
<td><PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" /></td>
|
|
<td>
|
|
<UiQtyStepper
|
|
:model-value="item.qty"
|
|
:max="maxFor(item)"
|
|
@update:model-value="updateQuantity(item, $event)"
|
|
/>
|
|
</td>
|
|
<td class="price-strong">
|
|
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" />
|
|
</td>
|
|
<td>
|
|
<button type="button" class="text-button" :disabled="mutating" @click="removeItem(item)">
|
|
{{ t("cart.remove") }}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<footer class="cart-footer mpanel">
|
|
<label class="select-all">
|
|
<input :checked="allSelected" type="checkbox" @change="setAllSelected(($event.target as HTMLInputElement).checked)" />
|
|
<span>{{ t("cart.selectAll") }}</span>
|
|
</label>
|
|
<span class="selected-count">{{ t("cart.selectedCount", { n: selectedItems.length }) }}</span>
|
|
<span class="total-label">{{ t("cart.subtotal") }}:</span>
|
|
<strong class="total-price"><PriceText :amount-minor="selectedTotalMinor" :currency="selectedCurrency" /></strong>
|
|
<button class="mbtn red checkout-button" type="button" :disabled="selectedItems.length === 0 || mutating" @click="goToCheckout">
|
|
{{ t("cart.checkout") }}
|
|
</button>
|
|
</footer>
|
|
</template>
|
|
|
|
<div v-else-if="!loading" class="empty-wrap mpanel">
|
|
<UiEmptyState :text="t('cart.empty')" />
|
|
<NuxtLink class="mbtn red" to="/">{{ t("cart.continueShopping") }}</NuxtLink>
|
|
</div>
|
|
<div v-else class="loading-state">{{ $t("common.loading") }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.transaction-page {
|
|
padding: 24px 0 56px;
|
|
background: #f5f5f5;
|
|
min-height: 60vh;
|
|
}
|
|
.page-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 0 0 16px;
|
|
}
|
|
.page-heading h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
font-weight: 500;
|
|
}
|
|
.error-message {
|
|
margin: 0 0 16px;
|
|
padding: 10px 14px;
|
|
color: #b42318;
|
|
background: #fff1f0;
|
|
border: 1px solid #ffd6d2;
|
|
}
|
|
.shop-group {
|
|
margin-bottom: 16px;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
.shop-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid var(--mall-line);
|
|
font-size: 14px;
|
|
}
|
|
.shop-label {
|
|
color: var(--mall-muted);
|
|
font-size: 12px;
|
|
}
|
|
.cart-table {
|
|
margin: 0;
|
|
width: 100%;
|
|
}
|
|
.cart-table th,
|
|
.cart-table td {
|
|
padding: 14px 10px;
|
|
vertical-align: middle;
|
|
}
|
|
.cart-table th:first-child,
|
|
.cart-table td:first-child {
|
|
padding-left: 18px;
|
|
}
|
|
.cart-table th:last-child,
|
|
.cart-table td:last-child {
|
|
padding-right: 18px;
|
|
}
|
|
.check-col {
|
|
width: 42px;
|
|
text-align: center;
|
|
}
|
|
.product-col {
|
|
width: 34%;
|
|
}
|
|
.product-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-height: 60px;
|
|
line-height: 1.5;
|
|
}
|
|
.product-cell img {
|
|
width: 60px;
|
|
height: 60px;
|
|
flex: 0 0 60px;
|
|
object-fit: cover;
|
|
border: 1px solid var(--mall-line);
|
|
background: #fff;
|
|
}
|
|
.muted {
|
|
color: var(--mall-muted);
|
|
font-size: 12px;
|
|
}
|
|
.price-strong,
|
|
.total-price {
|
|
color: var(--mall-red);
|
|
font-weight: 600;
|
|
}
|
|
.text-button {
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--mall-muted);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
.text-button:hover {
|
|
color: var(--mall-red);
|
|
}
|
|
.text-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
.cart-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 18px;
|
|
padding: 14px 18px;
|
|
}
|
|
.select-all {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
cursor: pointer;
|
|
}
|
|
.selected-count {
|
|
color: var(--mall-muted);
|
|
font-size: 13px;
|
|
}
|
|
.total-label {
|
|
margin-left: auto;
|
|
color: var(--mall-muted);
|
|
font-size: 13px;
|
|
}
|
|
.total-price {
|
|
font-size: 18px;
|
|
}
|
|
.checkout-button {
|
|
min-width: 112px;
|
|
}
|
|
.empty-wrap {
|
|
padding-bottom: 28px;
|
|
text-align: center;
|
|
}
|
|
.empty-wrap :deep(.empty) {
|
|
padding-bottom: 20px;
|
|
}
|
|
.loading-state {
|
|
padding: 70px 0;
|
|
text-align: center;
|
|
color: var(--mall-muted);
|
|
}
|
|
</style>
|