feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user