Files
vmall/apps/mall/pages/checkout/index.vue
T

457 lines
17 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,
ShippingQuote,
} 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("");
/** Server-computed shipping fees for the current cart and destination. */
const shippingQuote = ref<ShippingQuote | null>(null);
const quoteFailed = ref(false);
let quoteTimer: ReturnType<typeof setTimeout> | undefined;
let quoteSeq = 0;
// 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),
);
/** Per-shop shipping fee from the latest server quote, keyed by shop id. */
const shopFeeMinor = computed<Record<string, number>>(() => {
const fees: Record<string, number> = {};
for (const shop of shippingQuote.value?.shops ?? []) fees[shop.shop_id] = shop.fee_minor;
return fees;
});
const shippingTotalMinor = computed(() => shippingQuote.value?.total_minor ?? 0);
/** Coupon preview only; the server decides the discount actually granted. */
const couponDiscountMinor = computed(() =>
groups.value.reduce((sum, group) => sum + (selectedCouponFor(group.shopId)?.amount_minor ?? 0), 0),
);
const grandTotalMinor = computed(
() => totalMinor.value + shippingTotalMinor.value - couponDiscountMinor.value,
);
const shippingHint = computed(() => {
if (quoteFailed.value) return t("checkout.shippingQuoteError");
if (!selectedAddress()) return t("checkout.selectAddressForShipping");
return t("common.loading");
});
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 };
}
/** Requote when the destination changes; without an address no fee is quoted. */
async function refreshQuote(): Promise<void> {
const address = selectedAddress();
if (!address) {
shippingQuote.value = null;
quoteFailed.value = false;
return;
}
const seq = ++quoteSeq;
try {
const quote = await $api.quoteShipping(address, currency.value);
if (seq === quoteSeq) {
shippingQuote.value = quote;
quoteFailed.value = false;
}
} catch {
if (seq === quoteSeq) {
shippingQuote.value = null;
quoteFailed.value = true;
}
}
}
// Saved-address switches and manual-form edits both reprice; the debounce
// keeps typing from firing a quote per keystroke.
const addressKey = computed(() => JSON.stringify(selectedAddress()));
watch(addressKey, () => {
if (quoteTimer) clearTimeout(quoteTimer);
quoteTimer = setTimeout(() => void refreshQuote(), 300);
});
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;
if (quoteTimer) clearTimeout(quoteTimer);
});
</script>
<template>
<div class="bg-bg text-text min-h-screen font-sans">
<div class="max-w-mall mx-auto 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="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5">
{{ error }}
</p>
<template v-if="!loading && items.length > 0">
<VCard class="mb-4 p-5">
<h2 class="mt-0 mb-4 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="border-border has-[:checked]:border-primary has-[:checked]:bg-primary-soft flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm"
>
<input
v-model="selectedAddressId"
class="accent-primary mt-1 h-4 w-4"
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="text-muted mb-3 text-sm">{{ 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="mt-0 mb-4 text-base font-semibold">{{ t("checkout.orderPreview") }}</h2>
<div
v-for="group in groups"
:key="group.shopId"
class="border-border border-b pb-3 last:border-b-0 last:pb-0"
>
<header class="border-border border-b 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="border-bg grid grid-cols-[56px_minmax(0,1fr)_auto_auto_auto] items-center gap-3 border-b 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-muted text-xs">{{ item.sku_code }}</span>
</div>
<PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" />
<span class="text-muted text-xs">× {{ item.qty }}</span>
<strong class="text-primary text-right"
><PriceText
:amount-minor="item.unit_price_minor * item.qty"
:currency="item.currency"
/></strong>
</div>
<div class="flex flex-wrap items-center gap-3 pt-3 text-sm">
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
<template v-if="group.shopId in shopFeeMinor">
<span v-if="shopFeeMinor[group.shopId] === 0" class="text-success">{{
t("checkout.freeShipping")
}}</span>
<PriceText
v-else
:amount-minor="shopFeeMinor[group.shopId]"
:currency="shippingQuote?.currency ?? sourceCurrency"
/>
</template>
<span v-else class="text-muted">{{ shippingHint }}</span>
</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="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 text-sm focus:outline-2"
>
<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="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
:placeholder="t('checkout.remarkPlaceholder')"
rows="3"
/>
</VCard>
<footer
class="border-border bg-surface mb-4 flex flex-wrap items-center justify-end gap-4 rounded-lg border p-4 shadow-sm"
>
<div class="ml-auto flex flex-col items-end gap-1.5 text-sm">
<div class="flex items-center gap-3">
<span class="text-muted">{{ t("checkout.subtotal") }}</span>
<PriceText :amount-minor="totalMinor" :currency="sourceCurrency" />
</div>
<div class="flex items-center gap-3">
<span class="text-muted">{{ t("checkout.shippingTotal") }}</span>
<template v-if="shippingQuote">
<span v-if="shippingTotalMinor === 0" class="text-success">{{
t("checkout.freeShipping")
}}</span>
<PriceText
v-else
:amount-minor="shippingTotalMinor"
:currency="shippingQuote?.currency ?? sourceCurrency"
/>
</template>
<span v-else class="text-muted">{{ shippingHint }}</span>
</div>
<div v-if="couponDiscountMinor > 0" class="flex items-center gap-3">
<span class="text-muted">{{ t("checkout.couponDiscount") }}</span>
<span class="text-danger"
>−<PriceText :amount-minor="couponDiscountMinor" :currency="sourceCurrency"
/></span>
</div>
<div class="flex items-center gap-3">
<span>{{ t("checkout.total") }}</span>
<strong class="text-primary text-lg"
><PriceText :amount-minor="grandTotalMinor" :currency="sourceCurrency"
/></strong>
</div>
</div>
<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="border-border bg-surface hover:bg-bg inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium"
to="/cart"
>{{ t("checkout.backToCart") }}</NuxtLink
>
</VCard>
<div v-else class="text-muted py-8 text-center">{{ $t("common.loading") }}</div>
</div>
</div>
</template>