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:
@@ -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 {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
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,
|
||||
};
|
||||
rows.value = [next, ...rows.value.map((row) => ({ ...row, isDefault: form.isDefault ? false : row.isDefault }))];
|
||||
const input: AddressInput = {
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
country: form.country,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postal_code: form.postal_code,
|
||||
is_default: form.is_default,
|
||||
};
|
||||
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;
|
||||
}
|
||||
open.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(() => {
|
||||
|
||||
Reference in New Issue
Block a user