357 lines
13 KiB
Vue
357 lines
13 KiB
Vue
<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="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
|
||
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"
|
||
>
|
||
<span class="text-sm">{{ t("checkout.total") }}:</span>
|
||
<strong class="text-primary text-lg"
|
||
><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="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>
|