Files
vmall/apps/mall/pages/checkout/index.vue
T
Chengdong Zhang c51e96ae41 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.
2026-09-18 16:00:05 +08:00

396 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Address, AddressBookEntry, CartItem, LocalizedText } from "@vmall/shared";
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 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"),
t("checkout.steps.payment"),
t("checkout.steps.complete"),
]);
// 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 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, 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");
}
} catch {
error.value = t("checkout.loadError");
} finally {
loading.value = false;
}
}
async function submitOrder(): Promise<void> {
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(address, 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 v-if="addresses.length > 0" class="address-list">
<label
v-for="address in 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.postal_code }}</span>
</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">
<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);
}
.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;
}
.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>