feat(mall): address book wave 7 frontend, contract, and archive

Live addresses domain for the mall: shared contract (AddressBookEntry +
five client methods), mock adapter state v3, live domain pick, addresses
page CRUD, checkout saved-address picker with manual fallback, demo seed,
and the archived change plus address-book capability spec.
This commit is contained in:
Chengdong Zhang
2026-09-18 16:00:05 +08:00
parent 5e866d08f4
commit c51e96ae41
17 changed files with 467 additions and 88 deletions
+71 -25
View File
@@ -1,8 +1,6 @@
<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";
import type { Address, AddressBookEntry, CartItem, LocalizedText } from "@vmall/shared";
type CheckoutGroup = {
shopId: string;
@@ -20,12 +18,24 @@ 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 addresses = ref<AddressBookEntry[]>([]);
const selectedAddressId = ref("");
const remark = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
// Inline manual form, used only when the customer has no saved address.
const manual = reactive<Address>({
recipient: "",
phone: "",
country: "US",
region: "",
city: "",
line1: "",
postal_code: "",
});
const stepLabels = computed(() => [
t("checkout.steps.cart"),
t("checkout.steps.order"),
@@ -33,10 +43,6 @@ const stepLabels = computed(() => [
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>();
@@ -61,24 +67,36 @@ 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,
};
function selectedAddress(): Address | null {
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
if (saved) {
return {
recipient: saved.recipient,
phone: saved.phone,
country: saved.country,
region: saved.region,
city: saved.city,
line1: saved.line1,
postal_code: saved.postal_code,
};
}
if (addresses.value.length > 0) return null;
// Manual fallback: every required field must be filled.
if (!manual.recipient || !manual.phone || !manual.country || !manual.city || !manual.line1) {
return null;
}
return { ...manual };
}
async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
try {
const snapshot = await $api.getCart();
const [snapshot, list] = await Promise.all([$api.getCart(), $api.listMyAddresses()]);
items.value = snapshot.items;
addresses.value = list;
selectedAddressId.value =
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
if (items.value.length === 0) {
await router.replace("/cart");
}
@@ -90,11 +108,15 @@ async function loadCart(): Promise<void> {
}
async function submitOrder(): Promise<void> {
if (!selectedAddress.value || items.value.length === 0) return;
const address = selectedAddress();
if (!address || items.value.length === 0) {
error.value = t("checkout.addressRequired");
return;
}
submitting.value = true;
error.value = "";
try {
const orders = await $api.checkout(toAddress(selectedAddress.value), currency.value);
const orders = await $api.checkout(address, currency.value);
pendingOrderIds.value = orders.map((order) => order.id);
const snapshot = await $api.getCart();
items.value = snapshot.items;
@@ -122,9 +144,9 @@ onMounted(() => void loadCart());
<template v-if="!loading && items.length > 0">
<section class="mpanel address-panel">
<h2 class="section-title">{{ t("checkout.address") }}</h2>
<div class="address-list">
<div v-if="addresses.length > 0" class="address-list">
<label
v-for="address in MOCK_ADDRESSES"
v-for="address in addresses"
:key="address.id"
class="address-option"
:class="{ selected: address.id === selectedAddressId }"
@@ -133,11 +155,23 @@ onMounted(() => void loadCart());
<span class="address-main">
<strong>{{ address.recipient }}</strong>
<span>{{ address.phone }}</span>
<span>{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postalCode }}</span>
<span>{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postal_code }}</span>
</span>
<span v-if="address.isDefault" class="default-tag">{{ t("checkout.defaultAddress") }}</span>
<span v-if="address.is_default" class="default-tag">{{ t("checkout.defaultAddress") }}</span>
</label>
</div>
<div v-else class="manual-form">
<p class="muted">{{ t("checkout.noSavedAddress") }}</p>
<div class="manual-grid">
<input v-model.trim="manual.recipient" class="minput" type="text" :placeholder="t('user.recipient')" />
<input v-model.trim="manual.phone" class="minput" type="text" :placeholder="t('user.phone')" />
<input v-model.trim="manual.country" class="minput" type="text" :placeholder="t('user.country')" />
<input v-model.trim="manual.region" class="minput" type="text" :placeholder="t('user.region')" />
<input v-model.trim="manual.city" class="minput" type="text" :placeholder="t('user.city')" />
<input v-model.trim="manual.postal_code" class="minput" type="text" :placeholder="t('user.postalCode')" />
<input v-model.trim="manual.line1" class="minput span-2" type="text" :placeholder="t('user.line1')" />
</div>
</div>
</section>
<section class="mpanel order-panel">
@@ -243,6 +277,18 @@ onMounted(() => void loadCart());
.address-main span:last-child {
color: var(--mall-muted);
}
.manual-form {
padding: 8px 18px 16px;
}
.manual-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.manual-grid .span-2 {
grid-column: span 2;
}
.default-tag {
color: var(--mall-red);
font-size: 12px;