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:
@@ -16,3 +16,4 @@ dist/
|
||||
.kimi-code/
|
||||
.omp/
|
||||
.opencode/
|
||||
.omc/
|
||||
|
||||
@@ -15,6 +15,8 @@ export default {
|
||||
backToCart: "Back to cart",
|
||||
loadError: "Unable to load the order.",
|
||||
submitError: "Unable to place the order.",
|
||||
addressRequired: "Please select or fill in a shipping address.",
|
||||
noSavedAddress: "No saved address. Please enter one below.",
|
||||
paymentTitle: "Choose payment",
|
||||
balance: "Balance payment",
|
||||
wechat: "WeChat Pay",
|
||||
@@ -48,6 +50,8 @@ export default {
|
||||
backToCart: "返回购物车",
|
||||
loadError: "订单信息加载失败,请稍后重试。",
|
||||
submitError: "订单提交失败,请稍后重试。",
|
||||
addressRequired: "请选择或填写收货地址。",
|
||||
noSavedAddress: "暂无收货地址,请在下方填写。",
|
||||
paymentTitle: "选择支付方式",
|
||||
balance: "余额支付",
|
||||
wechat: "微信支付",
|
||||
|
||||
@@ -68,6 +68,8 @@ export default {
|
||||
defaultAddress: "Default",
|
||||
setDefault: "Set as default",
|
||||
saveAddress: "Save address",
|
||||
country: "Country",
|
||||
saveFailed: "Save failed. Please try again.",
|
||||
noAddresses: "No shipping addresses yet.",
|
||||
favoritesTitle: "Favorites",
|
||||
favoriteProductsTab: "Products",
|
||||
@@ -159,6 +161,8 @@ export default {
|
||||
defaultAddress: "默认",
|
||||
setDefault: "设为默认",
|
||||
saveAddress: "保存地址",
|
||||
country: "国家",
|
||||
saveFailed: "保存失败,请重试。",
|
||||
noAddresses: "暂无收货地址。",
|
||||
favoritesTitle: "我的收藏",
|
||||
favoriteProductsTab: "商品",
|
||||
|
||||
+94
-4
@@ -5,6 +5,8 @@
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type {
|
||||
Address,
|
||||
AddressBookEntry,
|
||||
AddressInput,
|
||||
ApiClient,
|
||||
AuthTokens,
|
||||
Cart,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
MOCK_QUICK_LINKS,
|
||||
MOCK_STORES,
|
||||
MOCK_USER,
|
||||
MOCK_ADDRESSES,
|
||||
defaultAddress,
|
||||
mockConvertMinor,
|
||||
productById,
|
||||
@@ -42,15 +45,16 @@ interface MockState {
|
||||
orders: Order[];
|
||||
shipments: Shipment[];
|
||||
invoices: Invoice[];
|
||||
addresses: AddressBookEntry[];
|
||||
addressSeq: number;
|
||||
orderSeq: number;
|
||||
invoiceSeq: number;
|
||||
}
|
||||
|
||||
// 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";
|
||||
// v3: address book joined the persisted state.
|
||||
const STORAGE_KEY = "vmall.mock.state.v3";
|
||||
|
||||
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "orderSeq" | "invoiceSeq">;
|
||||
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "addresses" | "orderSeq" | "invoiceSeq" | "addressSeq">;
|
||||
|
||||
// Load cart/order session state persisted by a previous page load (client only).
|
||||
function loadPersisted(): PersistedState | null {
|
||||
@@ -64,6 +68,7 @@ function loadPersisted(): PersistedState | null {
|
||||
if (!Array.isArray(p.cart) || !Array.isArray(p.orders)) return null;
|
||||
if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null;
|
||||
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
|
||||
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
|
||||
return p as PersistedState;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -74,12 +79,27 @@ function initialState(): MockState {
|
||||
const persisted = loadPersisted();
|
||||
if (persisted) return { token: null, ...persisted };
|
||||
const seed = seedOrders(MOCK_USER.id);
|
||||
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
|
||||
id: a.id,
|
||||
user_id: MOCK_USER.id,
|
||||
recipient: a.recipient,
|
||||
phone: a.phone,
|
||||
country: "US",
|
||||
region: a.region,
|
||||
city: a.city,
|
||||
line1: a.line1,
|
||||
postal_code: a.postalCode,
|
||||
is_default: a.isDefault,
|
||||
created_at: `2026-08-0${i + 1}T09:00:00.000Z`,
|
||||
}));
|
||||
return {
|
||||
token: null,
|
||||
cart: [],
|
||||
orders: seed.orders,
|
||||
shipments: seed.shipments,
|
||||
invoices: seed.invoices,
|
||||
addresses: seededAddresses,
|
||||
addressSeq: 100,
|
||||
orderSeq: 100,
|
||||
invoiceSeq: 100,
|
||||
};
|
||||
@@ -132,6 +152,8 @@ export function createMockApi(): ApiClient {
|
||||
invoices: state.invoices,
|
||||
orderSeq: state.orderSeq,
|
||||
invoiceSeq: state.invoiceSeq,
|
||||
addresses: state.addresses,
|
||||
addressSeq: state.addressSeq,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
@@ -386,6 +408,74 @@ export function createMockApi(): ApiClient {
|
||||
return Promise.resolve(toShopProfile(store));
|
||||
},
|
||||
|
||||
listMyAddresses: () =>
|
||||
Promise.resolve(
|
||||
[...state.addresses].sort(
|
||||
(a, b) => Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
|
||||
),
|
||||
),
|
||||
|
||||
createAddress: (input: AddressInput) => {
|
||||
const makeDefault = input.is_default === true || state.addresses.length === 0;
|
||||
if (makeDefault) for (const a of state.addresses) a.is_default = false;
|
||||
state.addressSeq += 1;
|
||||
const entry: AddressBookEntry = {
|
||||
id: `a-${state.addressSeq}`,
|
||||
user_id: MOCK_USER.id,
|
||||
recipient: input.recipient,
|
||||
phone: input.phone,
|
||||
country: input.country,
|
||||
region: input.region,
|
||||
city: input.city,
|
||||
line1: input.line1,
|
||||
postal_code: input.postal_code,
|
||||
is_default: makeDefault,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
state.addresses.push(entry);
|
||||
persist();
|
||||
return Promise.resolve(entry);
|
||||
},
|
||||
|
||||
updateAddress: (id: string, input: AddressInput) => {
|
||||
const entry = state.addresses.find((a) => a.id === id);
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
|
||||
if (input.is_default === true && !entry.is_default) {
|
||||
for (const a of state.addresses) a.is_default = false;
|
||||
}
|
||||
entry.recipient = input.recipient;
|
||||
entry.phone = input.phone;
|
||||
entry.country = input.country;
|
||||
entry.region = input.region;
|
||||
entry.city = input.city;
|
||||
entry.line1 = input.line1;
|
||||
entry.postal_code = input.postal_code;
|
||||
entry.is_default = input.is_default === true || entry.is_default;
|
||||
persist();
|
||||
return Promise.resolve({ ...entry });
|
||||
},
|
||||
|
||||
deleteAddress: (id: string) => {
|
||||
const entry = state.addresses.find((a) => a.id === id);
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
|
||||
const wasDefault = entry.is_default;
|
||||
state.addresses = state.addresses.filter((a) => a.id !== id);
|
||||
if (wasDefault && state.addresses.length > 0) {
|
||||
const latest = state.addresses.reduce((a, b) => (a.created_at > b.created_at ? a : b));
|
||||
latest.is_default = true;
|
||||
}
|
||||
persist();
|
||||
return Promise.resolve([...state.addresses]);
|
||||
},
|
||||
|
||||
setDefaultAddress: (id: string) => {
|
||||
const entry = state.addresses.find((a) => a.id === id);
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
|
||||
for (const a of state.addresses) a.is_default = a.id === id;
|
||||
persist();
|
||||
return Promise.resolve({ ...entry });
|
||||
},
|
||||
|
||||
shop: {
|
||||
getMyShop: () => unsupported(),
|
||||
listMyProducts: () => unsupported(),
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineNuxtConfig({
|
||||
// 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 waves 2-3.
|
||||
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices"],
|
||||
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices", "addresses"],
|
||||
appName: "mall",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
function selectedAddress(): Address | null {
|
||||
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
|
||||
if (saved) {
|
||||
return {
|
||||
recipient: address.recipient,
|
||||
phone: address.phone,
|
||||
country: "US",
|
||||
region: address.region,
|
||||
city: address.city,
|
||||
line1: address.line1,
|
||||
postal_code: address.postalCode,
|
||||
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;
|
||||
|
||||
@@ -1,39 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { MockAddress } from "~/mock/data";
|
||||
import { MOCK_ADDRESSES } from "~/mock/data";
|
||||
import type { AddressBookEntry, AddressInput } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const rows = ref<MockAddress[]>(MOCK_ADDRESSES.map((address) => ({ ...address })));
|
||||
const rows = ref<AddressBookEntry[]>([]);
|
||||
const loading = ref(true);
|
||||
const open = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const form = reactive<MockAddress>({
|
||||
id: "",
|
||||
interface AddressForm {
|
||||
recipient: string;
|
||||
phone: string;
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
line1: string;
|
||||
postal_code: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const form = reactive<AddressForm>({
|
||||
recipient: "",
|
||||
phone: "",
|
||||
country: "US",
|
||||
region: "",
|
||||
city: "",
|
||||
line1: "",
|
||||
postalCode: "",
|
||||
isDefault: false,
|
||||
postal_code: "",
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
const errorKey = ref("");
|
||||
|
||||
const modalTitle = computed(() => (editingId.value ? t("user.editAddress") : t("user.addAddress")));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
rows.value = await $api.listMyAddresses();
|
||||
} catch {
|
||||
errorKey.value = "user.loadFailed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
|
||||
function resetForm(): void {
|
||||
form.id = "";
|
||||
form.recipient = "";
|
||||
form.phone = "";
|
||||
form.country = "US";
|
||||
form.region = "";
|
||||
form.city = "";
|
||||
form.line1 = "";
|
||||
form.postalCode = "";
|
||||
form.isDefault = false;
|
||||
form.postal_code = "";
|
||||
form.is_default = false;
|
||||
errorKey.value = "";
|
||||
}
|
||||
|
||||
@@ -43,61 +70,67 @@ function openAdd(): void {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: MockAddress): void {
|
||||
function openEdit(row: AddressBookEntry): void {
|
||||
editingId.value = row.id;
|
||||
form.id = row.id;
|
||||
form.recipient = row.recipient;
|
||||
form.phone = row.phone;
|
||||
form.country = row.country;
|
||||
form.region = row.region;
|
||||
form.city = row.city;
|
||||
form.line1 = row.line1;
|
||||
form.postalCode = row.postalCode;
|
||||
form.isDefault = row.isDefault;
|
||||
form.postal_code = row.postal_code;
|
||||
form.is_default = row.is_default;
|
||||
errorKey.value = "";
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function setDefault(id: string): void {
|
||||
rows.value = rows.value.map((row) => ({ ...row, isDefault: row.id === id }));
|
||||
async function setDefault(id: string): Promise<void> {
|
||||
try {
|
||||
await $api.setDefaultAddress(id);
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.saveFailed";
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!form.recipient || !form.phone || !form.region || !form.city || !form.line1 || !form.postalCode) {
|
||||
async function remove(row: AddressBookEntry): Promise<void> {
|
||||
try {
|
||||
rows.value = await $api.deleteAddress(row.id);
|
||||
} catch {
|
||||
errorKey.value = "user.saveFailed";
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!form.recipient || !form.phone || !form.country || !form.region || !form.city || !form.line1 || !form.postal_code) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
if (editingId.value) {
|
||||
rows.value = rows.value.map((row) =>
|
||||
row.id === editingId.value
|
||||
? {
|
||||
...row,
|
||||
const input: AddressInput = {
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
country: form.country,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postalCode: form.postalCode,
|
||||
isDefault: form.isDefault,
|
||||
}
|
||||
: form.isDefault
|
||||
? { ...row, isDefault: false }
|
||||
: row,
|
||||
);
|
||||
} else {
|
||||
const nextId = `a-local-${Date.now()}`;
|
||||
const next: MockAddress = {
|
||||
id: nextId,
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postalCode: form.postalCode,
|
||||
isDefault: form.isDefault,
|
||||
postal_code: form.postal_code,
|
||||
is_default: form.is_default,
|
||||
};
|
||||
rows.value = [next, ...rows.value.map((row) => ({ ...row, isDefault: form.isDefault ? false : row.isDefault }))];
|
||||
saving.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await $api.updateAddress(editingId.value, input);
|
||||
} else {
|
||||
await $api.createAddress(input);
|
||||
}
|
||||
open.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.saveFailed";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -107,12 +140,13 @@ function save(): void {
|
||||
{{ t("user.addresses") }}
|
||||
<button class="mbtn red" type="button" @click="openAdd">{{ t("user.addAddress") }}</button>
|
||||
</h1>
|
||||
<UiEmptyState v-if="rows.length === 0" :text="t('user.noAddresses')" />
|
||||
<UiEmptyState v-if="!loading && rows.length === 0" :text="t('user.noAddresses')" />
|
||||
<table v-else class="mtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("user.recipient") }}</th>
|
||||
<th>{{ t("user.phone") }}</th>
|
||||
<th>{{ t("user.country") }}</th>
|
||||
<th>{{ t("user.region") }}</th>
|
||||
<th>{{ t("user.city") }}</th>
|
||||
<th>{{ t("user.line1") }}</th>
|
||||
@@ -125,16 +159,18 @@ function save(): void {
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.recipient }}</td>
|
||||
<td>{{ row.phone }}</td>
|
||||
<td>{{ row.country }}</td>
|
||||
<td>{{ row.region }}</td>
|
||||
<td>{{ row.city }}</td>
|
||||
<td>{{ row.line1 }}</td>
|
||||
<td>{{ row.postalCode }}</td>
|
||||
<td>{{ row.postal_code }}</td>
|
||||
<td>
|
||||
<span v-if="row.isDefault" class="default-tag">{{ t("user.defaultAddress") }}</span>
|
||||
<span v-if="row.is_default" class="default-tag">{{ t("user.defaultAddress") }}</span>
|
||||
<button v-else class="mbtn" type="button" @click="setDefault(row.id)">{{ t("user.setDefault") }}</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="mbtn" type="button" @click="openEdit(row)">{{ t("common.edit") }}</button>
|
||||
<button class="mbtn" type="button" @click="remove(row)">{{ t("common.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -150,6 +186,10 @@ function save(): void {
|
||||
<span>{{ t("user.phone") }}</span>
|
||||
<input v-model.trim="form.phone" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.country") }}</span>
|
||||
<input v-model.trim="form.country" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.region") }}</span>
|
||||
<input v-model.trim="form.region" class="minput" type="text" />
|
||||
@@ -164,17 +204,17 @@ function save(): void {
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.postalCode") }}</span>
|
||||
<input v-model.trim="form.postalCode" class="minput" type="text" />
|
||||
<input v-model.trim="form.postal_code" class="minput" type="text" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input v-model="form.isDefault" type="checkbox" />
|
||||
<input v-model="form.is_default" type="checkbox" />
|
||||
<span>{{ t("user.defaultAddress") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="errorKey" class="error">{{ t(errorKey) }}</p>
|
||||
<template #footer>
|
||||
<button class="mbtn" type="button" @click="open = false">{{ t("common.cancel") }}</button>
|
||||
<button class="mbtn red" type="button" @click="save">{{ t("user.saveAddress") }}</button>
|
||||
<button class="mbtn red" type="button" :disabled="saving" @click="save">{{ t("user.saveAddress") }}</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
|
||||
@@ -17,7 +17,8 @@ type LiveDomain =
|
||||
| "cart"
|
||||
| "orders"
|
||||
| "shipments"
|
||||
| "invoices";
|
||||
| "invoices"
|
||||
| "addresses";
|
||||
|
||||
/**
|
||||
* Explicit per-domain method picks rather than a string allowlist: indexing
|
||||
@@ -56,6 +57,13 @@ const LIVE_PICKS = {
|
||||
requestInvoice: a.requestInvoice,
|
||||
listMyInvoices: a.listMyInvoices,
|
||||
}),
|
||||
addresses: (a: ApiClient) => ({
|
||||
listMyAddresses: a.listMyAddresses,
|
||||
createAddress: a.createAddress,
|
||||
updateAddress: a.updateAddress,
|
||||
deleteAddress: a.deleteAddress,
|
||||
setDefaultAddress: a.setDefaultAddress,
|
||||
}),
|
||||
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
|
||||
|
||||
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
|
||||
@@ -72,6 +80,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
|
||||
"orders",
|
||||
"shipments",
|
||||
"invoices",
|
||||
"addresses",
|
||||
];
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Proposal: replace-mock-api-wave-7
|
||||
|
||||
## Why
|
||||
|
||||
The address book is the last piece of the core purchase chain still on fixed mock data: `user/addresses.vue` renders `MOCK_ADDRESSES` with local-only edits, and checkout picks from the same static list. A real address entity closes the chain end-to-end against the live API, following the established wave pattern (per-domain live pick in the mall's api plugin, mock adapter stays as fallback).
|
||||
|
||||
## What Changes
|
||||
|
||||
- New `addresses` table: `user_id` FK (cascade), recipient/phone/country/region/city/line1/postal_code, `is_default`, timestamps; partial unique index enforces one default per user.
|
||||
- New route module `apps/api/src/routes/addresses.rs`: `GET/POST /api/addresses`, `PUT/DELETE /api/addresses/:id`, `POST /api/addresses/:id/default` — customer role only, ownership via `WHERE user_id =` (cross-user → 404). Default management is transactional (unset siblings in the same statement/transaction); the first address becomes default automatically; deleting the default promotes the most recent remaining address.
|
||||
- `packages/shared`: `AddressBookEntry` type + `listMyAddresses` / `createAddress` / `updateAddress` / `deleteAddress` / `setDefaultAddress` on `ApiClient`.
|
||||
- Mall mock adapter implements the same methods against its persisted state (seeded from `MOCK_ADDRESSES`), so mock mode keeps working and the rollback flag stays meaningful.
|
||||
- Mall pages: `user/addresses.vue` switches to real CRUD (existing `UiModal` form pattern, plus delete + set-default); `checkout/index.vue` loads the address list from the API (falls back to an inline manual form when the list is empty) and submits the selected one; `addresses` joins `LIVE_PICKS`/`DEFAULT_LIVE_DOMAINS`.
|
||||
- `scripts/seed-demo.mjs` seeds two demo addresses for `customer@vmall.local`, idempotently.
|
||||
- Integration tests in `apps/api/tests/addresses.rs` (fixtures from `tests/common/mod.rs`): CRUD happy path, cross-user 404, single-default invariant, delete-default promotion, role guard.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `address-book`: per-customer saved shipping addresses with a single default.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `frontend-mall`: the buyer center address page and checkout address selection use the live address API.
|
||||
|
||||
## Impact
|
||||
|
||||
`apps/api/migrations/0009_addresses.sql`, `apps/api/src/routes/{addresses.rs,mod.rs}`, router registration, `packages/shared/src/{types.ts,api.ts}`, `apps/mall/{mock/api.ts,plugins/api.ts,pages/user/addresses.vue,pages/checkout/index.vue,locales/user.ts,locales/checkout.ts}`, `scripts/seed-demo.mjs`, `apps/api/tests/addresses.rs`. The contract change rebuilds all three frontends.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No address auto-geocoding, no region cascader data (free-text region/city stays), no address selector inside the order-before flow beyond radio pick.
|
||||
- Coupons, favorites, wallet stats, marketing subsystems (seckill/collective/integral) and reviews remain mock/future work, unchanged.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Spec delta: address-book
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Saved shipping addresses
|
||||
A customer SHALL be able to list, create, update and delete their own shipping addresses through the API. Every address operation SHALL be scoped to the authenticated customer; operating on another user's address SHALL return 404. Non-customer roles SHALL be rejected with 403.
|
||||
|
||||
#### Scenario: manage own addresses
|
||||
- **WHEN** a customer creates, updates or deletes an address they own
|
||||
- **THEN** the change persists and appears in their address list
|
||||
|
||||
#### Scenario: cross-user access
|
||||
- **WHEN** a customer requests an address id owned by a different user
|
||||
- **THEN** the API returns 404 as if the address did not exist
|
||||
|
||||
### Requirement: Single default address
|
||||
Each customer SHALL have at most one default address, enforced by the database. The first saved address SHALL become the default. Setting a new default SHALL unset the previous one atomically. Deleting the default address SHALL promote the most recently created remaining address to default.
|
||||
|
||||
#### Scenario: switch default
|
||||
- **WHEN** a customer marks address B as default while address A was default
|
||||
- **THEN** B is default and A is not, after one request
|
||||
|
||||
#### Scenario: delete the default
|
||||
- **WHEN** a customer deletes their default address and other addresses remain
|
||||
- **THEN** the most recently created remaining address becomes the default
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Spec delta: frontend-mall
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Address book management
|
||||
The mall buyer center SHALL list, add, edit, delete and set-default the signed-in customer's saved addresses through the selected API adapter, replacing the previous fixed mock list.
|
||||
|
||||
#### Scenario: manage addresses in the buyer center
|
||||
- **WHEN** a signed-in shopper opens `/user/addresses` and adds or edits an address
|
||||
- **THEN** the change persists through the adapter and survives a full page reload
|
||||
|
||||
### Requirement: Checkout address selection
|
||||
Checkout SHALL offer the customer's saved addresses for selection, defaulting to their default address, and SHALL submit the selected address with the order. When the customer has no saved address, checkout SHALL provide an inline manual address form instead.
|
||||
|
||||
#### Scenario: checkout with a saved address
|
||||
- **WHEN** a signed-in shopper with saved addresses checks out
|
||||
- **THEN** the default address is preselected and the created order carries the chosen address
|
||||
@@ -0,0 +1,29 @@
|
||||
# Tasks: replace-mock-api-wave-7
|
||||
|
||||
## 1. Backend
|
||||
- [x] `apps/api/migrations/0009_addresses.sql`: table + `addresses_one_default` partial unique index
|
||||
- [x] `apps/api/src/routes/addresses.rs`: list/create/update/delete/set-default; customer role; `WHERE user_id` ownership (404 cross-user); transactional default switching; first address auto-default; delete-default promotes latest remaining
|
||||
- [x] Register module + router; `AddressBookEntry` row model
|
||||
|
||||
## 2. Shared contract
|
||||
- [x] `packages/shared/src/types.ts`: `AddressBookEntry`
|
||||
- [x] `packages/shared/src/api.ts`: `listMyAddresses`, `createAddress`, `updateAddress`, `deleteAddress`, `setDefaultAddress`
|
||||
|
||||
## 3. Mock adapter
|
||||
- [x] `apps/mall/mock/api.ts`: same five methods over persisted mock state (seed from `MOCK_ADDRESSES`)
|
||||
- [x] `apps/mall/plugins/api.ts`: `addresses` live pick + `DEFAULT_LIVE_DOMAINS` entry
|
||||
|
||||
## 4. Mall pages
|
||||
- [x] `user/addresses.vue`: real CRUD via `$api` (modal add/edit, delete, set-default)
|
||||
- [x] `checkout/index.vue`: load addresses from `$api`; radio select; inline manual form fallback when empty; submit selected address
|
||||
- [x] Locale keys (user/checkout domains, en+zh)
|
||||
|
||||
## 5. Seed + tests
|
||||
- [x] `scripts/seed-demo.mjs`: two idempotent demo customer addresses
|
||||
- [x] `apps/api/tests/addresses.rs`: CRUD, cross-user 404, single-default invariant, delete-default promotion, non-customer 403
|
||||
|
||||
## 6. Verify
|
||||
- [x] `cargo test -p vmall-api` green (docker vmall_test + Redis)
|
||||
- [x] `pnpm --filter @vmall/mall build` + shop-admin + admin builds green (contract change)
|
||||
- [x] `openspec validate replace-mock-api-wave-7 --strict` green
|
||||
- [x] Browser smoke: address CRUD in user center; checkout with saved address end-to-end
|
||||
@@ -0,0 +1,27 @@
|
||||
# address-book Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change replace-mock-api-wave-7. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Saved shipping addresses
|
||||
A customer SHALL be able to list, create, update and delete their own shipping addresses through the API. Every address operation SHALL be scoped to the authenticated customer; operating on another user's address SHALL return 404. Non-customer roles SHALL be rejected with 403.
|
||||
|
||||
#### Scenario: manage own addresses
|
||||
- **WHEN** a customer creates, updates or deletes an address they own
|
||||
- **THEN** the change persists and appears in their address list
|
||||
|
||||
#### Scenario: cross-user access
|
||||
- **WHEN** a customer requests an address id owned by a different user
|
||||
- **THEN** the API returns 404 as if the address did not exist
|
||||
|
||||
### Requirement: Single default address
|
||||
Each customer SHALL have at most one default address, enforced by the database. The first saved address SHALL become the default. Setting a new default SHALL unset the previous one atomically. Deleting the default address SHALL promote the most recently created remaining address to default.
|
||||
|
||||
#### Scenario: switch default
|
||||
- **WHEN** a customer marks address B as default while address A was default
|
||||
- **THEN** B is default and A is not, after one request
|
||||
|
||||
#### Scenario: delete the default
|
||||
- **WHEN** a customer deletes their default address and other addresses remain
|
||||
- **THEN** the most recently created remaining address becomes the default
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
The buyer-facing storefront: shell, home page, discovery, shopping and transaction flows, and the buyer center.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Localized storefront
|
||||
The mall SHALL render every UI string and all catalog/store/marketing mock content in en or zh from one switcher, defaulting to en. Switching locale SHALL update the desktop shell and current page without a full reload.
|
||||
|
||||
@@ -138,3 +136,18 @@ The mall SHALL provide a store directory and store home reading live shops from
|
||||
#### Scenario: a shop without a profile still renders
|
||||
- **WHEN** a shop has no profile row
|
||||
- **THEN** the directory and store home render it without inventing logo, scores or copy
|
||||
|
||||
### Requirement: Address book management
|
||||
The mall buyer center SHALL list, add, edit, delete and set-default the signed-in customer's saved addresses through the selected API adapter, replacing the previous fixed mock list.
|
||||
|
||||
#### Scenario: manage addresses in the buyer center
|
||||
- **WHEN** a signed-in shopper opens `/user/addresses` and adds or edits an address
|
||||
- **THEN** the change persists through the adapter and survives a full page reload
|
||||
|
||||
### Requirement: Checkout address selection
|
||||
Checkout SHALL offer the customer's saved addresses for selection, defaulting to their default address, and SHALL submit the selected address with the order. When the customer has no saved address, checkout SHALL provide an inline manual address form instead.
|
||||
|
||||
#### Scenario: checkout with a saved address
|
||||
- **WHEN** a signed-in shopper with saved addresses checks out
|
||||
- **THEN** the default address is preselected and the created order carries the chosen address
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Address,
|
||||
AddressBookEntry,
|
||||
AuthTokens,
|
||||
Brand,
|
||||
BrandInput,
|
||||
@@ -77,6 +78,10 @@ export interface ShipmentItemBody {
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface AddressInput extends Address {
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface CurrencyUpsertBody {
|
||||
code: string;
|
||||
name: LocalizedText;
|
||||
@@ -175,6 +180,11 @@ export interface ApiClient {
|
||||
/** Public store directory: active shops with whatever profile they have. */
|
||||
listShops(): Promise<ShopProfile[]>;
|
||||
getShop(slug: string): Promise<ShopProfile>;
|
||||
listMyAddresses(): Promise<AddressBookEntry[]>;
|
||||
createAddress(address: AddressInput): Promise<AddressBookEntry>;
|
||||
updateAddress(id: string, address: AddressInput): Promise<AddressBookEntry>;
|
||||
deleteAddress(id: string): Promise<AddressBookEntry[]>;
|
||||
setDefaultAddress(id: string): Promise<AddressBookEntry>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
@@ -250,6 +260,11 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
getHomeContent: () => r("GET", "/content/home"),
|
||||
listShops: () => r("GET", "/shops"),
|
||||
getShop: (slug) => r("GET", `/shops/${slug}`),
|
||||
listMyAddresses: () => r("GET", "/addresses"),
|
||||
createAddress: (address) => r("POST", "/addresses", address),
|
||||
updateAddress: (id, address) => r("PUT", `/addresses/${id}`, address),
|
||||
deleteAddress: (id) => r("DELETE", `/addresses/${id}`),
|
||||
setDefaultAddress: (id) => r("POST", `/addresses/${id}/default`),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
|
||||
@@ -176,6 +176,13 @@ export interface Address {
|
||||
postal_code: string;
|
||||
}
|
||||
|
||||
export interface AddressBookEntry extends Address {
|
||||
id: string;
|
||||
user_id: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ShipmentStatus = "pending" | "shipped" | "delivered";
|
||||
|
||||
export interface Shipment {
|
||||
|
||||
@@ -177,6 +177,24 @@ for (const def of SHOPS) {
|
||||
await call("POST", "/auth/register", {
|
||||
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
|
||||
});
|
||||
{
|
||||
r = await call("POST", "/auth/login", {
|
||||
body: { email: "customer@vmall.local", password: "customer123" },
|
||||
});
|
||||
if (r.status !== 200) fail("customer login", r);
|
||||
const customerToken = r.data.token;
|
||||
const existing = (await call("GET", "/addresses", { token: customerToken })).data;
|
||||
if (Array.isArray(existing) && existing.length === 0) {
|
||||
const demoAddresses = [
|
||||
{ recipient: "Demo Customer", phone: "+1 555 0100", country: "US", region: "California", city: "Cupertino", line1: "1 Infinite Loop", postal_code: "95014", is_default: true },
|
||||
{ recipient: "Demo Customer", phone: "+1 555 0100", country: "US", region: "New York", city: "New York", line1: "88 Fifth Ave", postal_code: "10011" },
|
||||
];
|
||||
for (const address of demoAddresses) {
|
||||
r = await call("POST", "/addresses", { token: customerToken, body: address });
|
||||
if (r.status !== 200 && r.status !== 201) fail("seed address", r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. categories (reference data from the migrations)
|
||||
const cats = (await call("GET", "/categories")).data;
|
||||
|
||||
Reference in New Issue
Block a user