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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user