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>
+9
View File
@@ -9,6 +9,8 @@ const { locale, t } = useI18n();
const { currency } = usePrefs();
const router = useRouter();
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
/** Carried to the success page so it can show each paid order's money. */
const paidOrders = useState<Order[]>("checkout-paid-orders", () => []);
// Shared with the store directory; orders carry only a shop id.
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
@@ -68,6 +70,7 @@ async function confirmPayment(): Promise<void> {
error.value = "";
try {
for (const order of orders.value) await $api.payOrder(order.id);
paidOrders.value = orders.value;
pendingOrderIds.value = [];
await router.push("/checkout/success");
} catch {
@@ -125,6 +128,12 @@ onMounted(() => void loadOrders());
/></strong>
</div>
<footer class="flex flex-wrap items-center justify-end gap-3 px-4 py-3 text-sm">
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
<span v-if="order.shipping_fee_minor > 0"
><PriceText
:amount-minor="order.shipping_fee_minor"
:currency="order.currency" /></span
><span v-else>{{ t("checkout.freeShipping") }}</span>
<template v-if="order.discount_minor > 0"
><span>{{ t("checkout.couponDiscount") }}</span
><span class="text-danger"
+34
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import type { Order } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { t } = useI18n();
/** Paid orders handed over by the pay page; empty on a fresh load. */
const paidOrders = useState<Order[]>("checkout-paid-orders", () => []);
// One-shot display: revisiting the page must not show stale orders.
onBeforeUnmount(() => {
paidOrders.value = [];
});
const stepLabels = computed(() => [
t("checkout.steps.cart"),
@@ -24,6 +33,31 @@ const stepLabels = computed(() => [
</div>
<h1 class="mt-5 mb-2 text-2xl font-medium">{{ t("checkout.successTitle") }}</h1>
<p class="text-muted m-0">{{ t("checkout.successMessage") }}</p>
<div v-if="paidOrders.length > 0" class="mx-auto mt-6 max-w-[560px] space-y-2 text-left">
<div
v-for="order in paidOrders"
:key="order.id"
class="border-border flex flex-wrap items-center justify-between gap-3 border p-3 text-sm"
>
<span class="text-muted">{{ t("checkout.orderNo") }} {{ order.order_no }}</span>
<span class="flex items-center gap-4">
<span class="flex items-center gap-2">
<span class="text-muted">{{ t("checkout.shippingFee") }}</span>
<span v-if="order.shipping_fee_minor > 0"
><PriceText
:amount-minor="order.shipping_fee_minor"
:currency="order.currency" /></span
><span v-else>{{ t("checkout.freeShipping") }}</span>
</span>
<span class="flex items-center gap-2">
<span class="text-muted">{{ t("checkout.total") }}</span>
<strong class="text-primary"
><PriceText :amount-minor="order.total_minor" :currency="order.currency"
/></strong>
</span>
</span>
</div>
</div>
<div class="mt-7 flex justify-center gap-3">
<NuxtLink
class="border-primary bg-primary hover:bg-primary-hover inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-white"
+7
View File
@@ -180,6 +180,13 @@ onMounted(() => {
</tr>
</tbody>
</VTable>
<p class="text-muted mt-4 text-right text-[13px]">
{{ t("checkout.shippingFee") }}:
<span v-if="order.shipping_fee_minor > 0">
<PriceText :amount-minor="order.shipping_fee_minor" :currency="order.currency" />
</span>
<span v-else class="text-success">{{ t("checkout.freeShipping") }}</span>
</p>
<p v-if="order.discount_minor > 0" class="text-primary mt-4 text-right text-[13px]">
{{ t("checkout.couponDiscount") }}:
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />