Opening or joining a group now sets the activity SKU to quantity 1 instead of accumulating, so a retry after a failed checkout - or a second click - can no longer push the line to quantity 2 and then fail the quantity-1 intent with an opaque error. The stashed group intent is cleared when the checkout page unmounts without submitting, and when its SKU is no longer in the cart, so an abandoned intent cannot silently reprice a later order or keep the coupon picker hidden.
472 lines
14 KiB
Vue
472 lines
14 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="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 v-if="!groupIntent && couponsForShop(group.shopId).length > 0" class="coupon-picker">
|
||
<span class="coupon-label">{{ t("checkout.coupon") }}</span>
|
||
<select v-model="selectedCoupon[group.shopId]" class="minput coupon-select">
|
||
<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="coupon-value">
|
||
−
|
||
<PriceText
|
||
:amount-minor="selectedCouponFor(group.shopId)?.amount_minor ?? 0"
|
||
:currency="selectedCouponFor(group.shopId)?.currency ?? sourceCurrency"
|
||
/>
|
||
</span>
|
||
</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;
|
||
}
|
||
.coupon-picker {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 18px 14px;
|
||
border-top: 1px solid #f5f5f5;
|
||
}
|
||
.coupon-label {
|
||
color: var(--mall-muted);
|
||
font-size: 13px;
|
||
}
|
||
.coupon-select {
|
||
max-width: 320px;
|
||
}
|
||
.coupon-value {
|
||
color: var(--mall-red);
|
||
font-size: 13px;
|
||
}
|
||
.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>
|