feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)

This commit is contained in:
Chengdong Zhang
2026-09-24 15:03:51 +08:00
parent 5b426486ac
commit 473d19d089
44 changed files with 2409 additions and 67 deletions
+104 -4
View File
@@ -7,6 +7,7 @@ import type {
Coupon,
GroupBuyIntent,
LocalizedText,
ShippingQuote,
} from "@vmall/shared";
type CheckoutGroup = {
@@ -36,6 +37,11 @@ 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>({
@@ -75,6 +81,26 @@ 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";
}
@@ -111,6 +137,37 @@ function selectedAddress(): Address | 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 = "";
@@ -178,6 +235,7 @@ onMounted(() => void loadCart());
// consumed by a successful submit, which clears it before navigating to pay.
onBeforeUnmount(() => {
groupIntent.value = null;
if (quoteTimer) clearTimeout(quoteTimer);
});
</script>
@@ -289,6 +347,20 @@ onBeforeUnmount(() => {
: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"
@@ -333,10 +405,38 @@ onBeforeUnmount(() => {
<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>
<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>