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
350 lines
9.2 KiB
Vue
350 lines
9.2 KiB
Vue
<script setup lang="ts">
|
||
import { t as pick } from "@vmall/shared";
|
||
import type { Address, CartItem, LocalizedText } from "@vmall/shared";
|
||
import { MOCK_ADDRESSES } from "~/mock/data";
|
||
import type { MockAddress } from "~/mock/data";
|
||
|
||
type CheckoutGroup = {
|
||
shopId: string;
|
||
shopName: LocalizedText;
|
||
items: CartItem[];
|
||
};
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { $api } = useNuxtApp();
|
||
const { locale, t } = useI18n();
|
||
const { currency } = usePrefs();
|
||
const cartStore = useCartStore();
|
||
const router = useRouter();
|
||
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
|
||
|
||
const items = ref<CartItem[]>([]);
|
||
const selectedAddressId = ref(MOCK_ADDRESSES.find((address) => address.isDefault)?.id ?? MOCK_ADDRESSES[0]?.id ?? "");
|
||
const remark = ref("");
|
||
const loading = ref(true);
|
||
const submitting = ref(false);
|
||
const error = ref("");
|
||
|
||
const stepLabels = computed(() => [
|
||
t("checkout.steps.cart"),
|
||
t("checkout.steps.order"),
|
||
t("checkout.steps.payment"),
|
||
t("checkout.steps.complete"),
|
||
]);
|
||
|
||
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, CheckoutGroup>();
|
||
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 sourceCurrency = computed(() => items.value[0]?.currency ?? currency.value);
|
||
const totalMinor = computed(() =>
|
||
items.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 toAddress(address: MockAddress): Address {
|
||
return {
|
||
recipient: address.recipient,
|
||
phone: address.phone,
|
||
country: "US",
|
||
region: address.region,
|
||
city: address.city,
|
||
line1: address.line1,
|
||
postal_code: address.postalCode,
|
||
};
|
||
}
|
||
|
||
async function loadCart(): Promise<void> {
|
||
loading.value = true;
|
||
error.value = "";
|
||
try {
|
||
const snapshot = await $api.getCart();
|
||
items.value = snapshot.items;
|
||
if (items.value.length === 0) {
|
||
await router.replace("/cart");
|
||
}
|
||
} catch {
|
||
error.value = t("checkout.loadError");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitOrder(): Promise<void> {
|
||
if (!selectedAddress.value || items.value.length === 0) return;
|
||
submitting.value = true;
|
||
error.value = "";
|
||
try {
|
||
const orders = await $api.checkout(toAddress(selectedAddress.value), currency.value);
|
||
pendingOrderIds.value = orders.map((order) => order.id);
|
||
const snapshot = await $api.getCart();
|
||
items.value = snapshot.items;
|
||
await cartStore.refresh();
|
||
await router.push("/checkout/pay");
|
||
} catch {
|
||
error.value = t("checkout.submitError");
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadCart());
|
||
</script>
|
||
|
||
<template>
|
||
<div class="transaction-page checkout-page">
|
||
<div class="w1200">
|
||
<UiStepBar :steps="stepLabels" :active="1" />
|
||
<header class="page-heading">
|
||
<h1>{{ t("checkout.title") }}</h1>
|
||
</header>
|
||
|
||
<p v-if="error" class="error-message">{{ error }}</p>
|
||
<template v-if="!loading && items.length > 0">
|
||
<section class="mpanel address-panel">
|
||
<h2 class="section-title">{{ t("checkout.address") }}</h2>
|
||
<div class="address-list">
|
||
<label
|
||
v-for="address in MOCK_ADDRESSES"
|
||
:key="address.id"
|
||
class="address-option"
|
||
:class="{ selected: address.id === selectedAddressId }"
|
||
>
|
||
<input v-model="selectedAddressId" type="radio" name="shipping-address" :value="address.id" />
|
||
<span class="address-main">
|
||
<strong>{{ address.recipient }}</strong>
|
||
<span>{{ address.phone }}</span>
|
||
<span>{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postalCode }}</span>
|
||
</span>
|
||
<span v-if="address.isDefault" class="default-tag">{{ t("checkout.defaultAddress") }}</span>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mpanel order-panel">
|
||
<h2 class="section-title">{{ t("checkout.orderPreview") }}</h2>
|
||
<div v-for="group in groups" :key="group.shopId" class="order-group">
|
||
<header class="shop-heading">
|
||
<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)" />
|
||
<div class="item-info">
|
||
<strong>{{ pick(item.product_name, locale) }}</strong>
|
||
<span class="muted">{{ item.sku_code }}</span>
|
||
</div>
|
||
<PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" />
|
||
<span class="qty">× {{ item.qty }}</span>
|
||
<strong class="line-total">
|
||
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" />
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mpanel remark-panel">
|
||
<label class="section-title" for="order-remark">{{ t("checkout.remark") }}</label>
|
||
<textarea id="order-remark" v-model="remark" class="remark-input" :placeholder="t('checkout.remarkPlaceholder')" rows="3" />
|
||
</section>
|
||
|
||
<footer class="submit-bar mpanel">
|
||
<span class="total-label">{{ t("checkout.total") }}:</span>
|
||
<strong class="total-price"><PriceText :amount-minor="totalMinor" :currency="sourceCurrency" /></strong>
|
||
<button class="mbtn red" type="button" :disabled="submitting" @click="submitOrder">
|
||
{{ t("checkout.submit") }}
|
||
</button>
|
||
</footer>
|
||
</template>
|
||
<div v-else-if="!loading" class="empty-wrap mpanel">
|
||
<UiEmptyState :text="t('checkout.noItems')" />
|
||
<NuxtLink class="mbtn gray" to="/cart">{{ t("checkout.backToCart") }}</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 {
|
||
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;
|
||
}
|
||
.mpanel {
|
||
margin-bottom: 16px;
|
||
}
|
||
.section-title {
|
||
display: block;
|
||
margin: 0;
|
||
padding: 16px 18px;
|
||
border-bottom: 1px solid var(--mall-line);
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
.address-list {
|
||
padding: 8px 18px 16px;
|
||
}
|
||
.address-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
padding: 13px 14px;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
line-height: 1.5;
|
||
}
|
||
.address-option:hover,
|
||
.address-option.selected {
|
||
border-color: var(--mall-red);
|
||
background: #fffafa;
|
||
}
|
||
.address-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.address-main span:last-child {
|
||
color: var(--mall-muted);
|
||
}
|
||
.default-tag {
|
||
color: var(--mall-red);
|
||
font-size: 12px;
|
||
}
|
||
.order-panel {
|
||
padding: 0;
|
||
overflow: hidden;
|
||
}
|
||
.order-group + .order-group {
|
||
border-top: 1px solid var(--mall-line);
|
||
}
|
||
.shop-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px 18px;
|
||
color: #444;
|
||
font-size: 13px;
|
||
}
|
||
.shop-heading img {
|
||
width: 28px;
|
||
height: 28px;
|
||
object-fit: cover;
|
||
border-radius: 50%;
|
||
}
|
||
.order-item {
|
||
display: grid;
|
||
grid-template-columns: 56px minmax(0, 1fr) 120px 64px 130px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 18px;
|
||
border-top: 1px solid #f5f5f5;
|
||
}
|
||
.order-item > img {
|
||
width: 56px;
|
||
height: 56px;
|
||
object-fit: cover;
|
||
border: 1px solid var(--mall-line);
|
||
}
|
||
.item-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
min-width: 0;
|
||
}
|
||
.item-info strong {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.muted,
|
||
.qty {
|
||
color: var(--mall-muted);
|
||
font-size: 12px;
|
||
}
|
||
.line-total {
|
||
color: var(--mall-red);
|
||
text-align: right;
|
||
}
|
||
.remark-panel {
|
||
padding: 0 0 16px;
|
||
}
|
||
.remark-input {
|
||
display: block;
|
||
box-sizing: border-box;
|
||
width: calc(100% - 36px);
|
||
margin: 14px 18px 0;
|
||
resize: vertical;
|
||
border: 1px solid var(--mall-line-dark);
|
||
padding: 10px 12px;
|
||
font: inherit;
|
||
outline: none;
|
||
}
|
||
.remark-input:focus {
|
||
border-color: var(--mall-red);
|
||
}
|
||
.submit-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 18px;
|
||
padding: 14px 18px;
|
||
}
|
||
.total-label {
|
||
color: var(--mall-muted);
|
||
font-size: 13px;
|
||
}
|
||
.total-price {
|
||
color: var(--mall-red);
|
||
font-size: 20px;
|
||
}
|
||
.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>
|