Files
vmall/apps/mall/pages/checkout/index.vue
T
Chengdong Zhang 0d0e10b97b feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin
- Add @vmall/shared/theme.css tokens with html[data-accent] presets
- Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage,
  VAccentSwatch, useAccent) as a Nuxt module
- Convert all three apps to kit + utilities; delete ui.css/mall.css and
  every <style scoped>; consoles get accent presets, mall locked to red
- Fix VCard boolean prop default (padding) and PDP/store stale
  useAsyncData keys on param navigation
- Archive adopt-tailwind-design-system; new frontend-ui capability spec
2026-09-22 18:22:50 +08:00

259 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, Coupon, GroupBuyIntent, 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", () => []);
/** Group-buying intent chosen on the group-buying page, if any. */
const groupIntent = useState<GroupBuyIntent | null>("checkout-group-intent", () => null);
const items = ref<CartItem[]>([]);
const addresses = ref<AddressBookEntry[]>([]);
/** Owned, still-claimable coupons, by shop. */
const coupons = ref<Coupon[]>([]);
const selectedCoupon = reactive<Record<string, string>>({});
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";
}
/** One coupon per shop order; the server decides eligibility and the amount. */
function couponsForShop(shopId: string): Coupon[] {
return coupons.value.filter((c) => c.shop_id === shopId && c.status === "claimed");
}
function selectedCouponFor(shopId: string): Coupon | null {
const id = selectedCoupon[shopId];
if (!id) return null;
return coupons.value.find((c) => c.id === id) ?? null;
}
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, couponList] = await Promise.all([
$api.getCart(),
$api.listMyAddresses(),
// Coupons are optional: a failure must not block checkout.
$api.listMyCoupons().catch(() => [] as Coupon[]),
]);
items.value = snapshot.items;
addresses.value = list;
coupons.value = couponList;
// A stashed group intent only applies while its SKU is still in the cart.
const intent = groupIntent.value;
if (intent && !items.value.some((item) => item.sku_id === intent.sku_id)) {
groupIntent.value = null;
}
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 couponByShop: Record<string, string> = {};
for (const [shopId, couponId] of Object.entries(selectedCoupon)) {
if (couponId) couponByShop[shopId] = couponId;
}
const orders = await $api.checkout(
address,
currency.value,
couponByShop,
groupIntent.value ?? undefined,
);
groupIntent.value = null;
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());
// Leaving without submitting must not reprice a later order: the intent is
// consumed by a successful submit, which clears it before navigating to pay.
onBeforeUnmount(() => {
groupIntent.value = null;
});
</script>
<template>
<div class="min-h-screen bg-bg font-sans text-text">
<div class="mx-auto max-w-mall px-4 py-6">
<UiStepBar :steps="stepLabels" :active="1" />
<header class="mb-4"><h1 class="m-0 text-xl font-medium">{{ t("checkout.title") }}</h1></header>
<p v-if="error" class="mb-4 border border-danger/30 bg-danger/10 px-3.5 py-2.5 text-danger">{{ error }}</p>
<template v-if="!loading && items.length > 0">
<VCard class="mb-4 p-5">
<h2 class="mb-4 mt-0 text-base font-semibold">{{ t("checkout.address") }}</h2>
<div v-if="addresses.length > 0" class="grid gap-2">
<label
v-for="address in addresses"
:key="address.id"
class="flex cursor-pointer items-start gap-3 rounded-md border border-border p-3 text-sm has-[:checked]:border-primary has-[:checked]:bg-primary-soft"
>
<input v-model="selectedAddressId" class="mt-1 h-4 w-4 accent-primary" type="radio" name="shipping-address" :value="address.id" />
<span class="flex min-w-0 flex-1 flex-col gap-1">
<span class="flex flex-wrap gap-2"><strong>{{ address.recipient }}</strong><span>{{ address.phone }}</span></span>
<span class="text-muted">{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postal_code }}</span>
</span>
<VBadge v-if="address.is_default" tone="blue">{{ t("checkout.defaultAddress") }}</VBadge>
</label>
</div>
<div v-else>
<p class="mb-3 text-sm text-muted">{{ t("checkout.noSavedAddress") }}</p>
<div class="grid gap-3 sm:grid-cols-2">
<VField :label="t('user.recipient')"><VInput v-model.trim="manual.recipient" type="text" :placeholder="t('user.recipient')" /></VField>
<VField :label="t('user.phone')"><VInput v-model.trim="manual.phone" type="text" :placeholder="t('user.phone')" /></VField>
<VField :label="t('user.country')"><VInput v-model.trim="manual.country" type="text" :placeholder="t('user.country')" /></VField>
<VField :label="t('user.region')"><VInput v-model.trim="manual.region" type="text" :placeholder="t('user.region')" /></VField>
<VField :label="t('user.city')"><VInput v-model.trim="manual.city" type="text" :placeholder="t('user.city')" /></VField>
<VField :label="t('user.postalCode')"><VInput v-model.trim="manual.postal_code" type="text" :placeholder="t('user.postalCode')" /></VField>
<VField class="sm:col-span-2" :label="t('user.line1')"><VInput v-model.trim="manual.line1" type="text" :placeholder="t('user.line1')" /></VField>
</div>
</div>
</VCard>
<VCard class="mb-4 p-5">
<h2 class="mb-4 mt-0 text-base font-semibold">{{ t("checkout.orderPreview") }}</h2>
<div v-for="group in groups" :key="group.shopId" class="border-b border-border pb-3 last:border-b-0 last:pb-0">
<header class="border-b border-border py-2 text-sm font-medium">{{ pick(group.shopName, locale) || t("checkout.shop") }}</header>
<div v-for="item in group.items" :key="item.sku_id" class="grid grid-cols-[56px_minmax(0,1fr)_auto_auto_auto] items-center gap-3 border-b border-bg py-3 last:border-b-0">
<img class="h-14 w-14 object-cover" :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
<div class="flex min-w-0 flex-col gap-1"><strong class="truncate">{{ pick(item.product_name, locale) }}</strong><span class="text-xs text-muted">{{ item.sku_code }}</span></div>
<PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" />
<span class="text-xs text-muted">× {{ item.qty }}</span>
<strong class="text-right text-primary"><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" /></strong>
</div>
<div v-if="!groupIntent && couponsForShop(group.shopId).length > 0" class="flex flex-wrap items-center gap-3 pt-3 text-sm">
<span class="text-muted">{{ t("checkout.coupon") }}</span>
<select v-model="selectedCoupon[group.shopId]" class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30">
<option value="">{{ t("checkout.noCoupon") }}</option>
<option v-for="coupon in couponsForShop(group.shopId)" :key="coupon.id" :value="coupon.id">{{ pick(coupon.title, locale) }}</option>
</select>
<span v-if="selectedCouponFor(group.shopId)" class="text-danger"> <PriceText :amount-minor="selectedCouponFor(group.shopId)?.amount_minor ?? 0" :currency="selectedCouponFor(group.shopId)?.currency ?? sourceCurrency" /></span>
</div>
</div>
</VCard>
<VCard class="mb-4 p-5">
<label class="mb-1 block text-sm font-medium" for="order-remark">{{ t("checkout.remark") }}</label>
<textarea id="order-remark" v-model="remark" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30" :placeholder="t('checkout.remarkPlaceholder')" rows="3" />
</VCard>
<footer class="mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border border-border bg-surface p-4 shadow-sm">
<span class="text-sm">{{ t("checkout.total") }}:</span>
<strong class="text-lg text-primary"><PriceText :amount-minor="totalMinor" :currency="sourceCurrency" /></strong>
<VBtn variant="primary" type="button" :disabled="submitting" @click="submitOrder">{{ t("checkout.submit") }}</VBtn>
</footer>
</template>
<VCard v-else-if="!loading" class="flex flex-col items-center gap-4 p-8 text-center">
<UiEmptyState :text="t('checkout.noItems')" />
<NuxtLink class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium hover:bg-bg" to="/cart">{{ t("checkout.backToCart") }}</NuxtLink>
</VCard>
<div v-else class="py-8 text-center text-muted">{{ $t("common.loading") }}</div>
</div>
</div>
</template>