Files
pricing.tootaio.com/app/composables/eventOrder.ts
xiaomai a5ac00baa1 feat(pricing): introduce real-time budget estimator
This commit introduces a real-time budget estimator for event services. A new summary sidebar now displays a live,
itemized breakdown of costs as the user selects options in the order form.

Key changes include:
- A new `OrderSummary` component to display the price breakdown and total.
- Comprehensive pricing logic implemented in the `useEventOrder` composable.
- A responsive two-column layout on the main page to accommodate the summary.
- UI/UX improvements across the form, including clearer labels and subtle transition animations for conditional fields.
2025-10-16 21:14:44 +08:00

268 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createSharedComposable } from "@vueuse/core";
import * as z from "zod";
export const _useEventOrder = () => {
const sectionIndex = ref(0);
function formatLocalDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
const EVENT_LOCATIONS = [
"永平区",
"周边城市Batu Pahat / Kluang",
"柔佛州境内",
"柔佛州境外",
] as const;
const eventLocationItems = ref<string[]>([...EVENT_LOCATIONS]);
const orderSchema = z.object({
contactName: z.string().min(1, "姓名不能为空"),
// 用户可以选择是否使用同一联系方式
isSameContact: z.boolean().default(true),
contactNumber: z.string().optional(),
eventName: z.string().min(1, "活动名称不能为空"),
eventDate: z
.string()
.refine((date) => {
return !isNaN(Date.parse(date));
}, "无效的日期")
.refine((date) => {
const eventDate = new Date(date);
const today = new Date();
// 仅按日期比较(忽略时间)
eventDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
// 活动日期必须是今天或未来的日期
return eventDate.getTime() >= today.getTime();
}, "活动日期必须是今天或未来的日期"),
eventLocation: z.enum(EVENT_LOCATIONS),
// 服务:竞标系统
biddingSystem: z.boolean().default(false),
biddingSystemProvideImage: z.boolean().default(false).optional(),
estimatedBidItemCount: z.string().optional(),
// 服务:背景设计
backgroundDesign: z.boolean().default(false),
backgroundType: z.enum(["static", "dynamic"]).optional(),
backgroundWidthOverride: z.number().optional(),
backgroundHeightOverride: z.number().optional(),
// 服务:流程 PPT 设计
flowBackgroundDesign: z.boolean().default(false),
backgroundSourceProvided: z.boolean().default(false), // 如果自己设计的,那么就没有这个额外收费
pptDesignQty: z
.number()
.min(1, "最少都要一张")
.max(20, "太多了我来不及做设计"),
// 服务:赞助商征信录设计
sponsorListDesign: z.boolean().default(false),
});
type OrderForm = z.infer<typeof orderSchema>;
const orderState = reactive<OrderForm>({
contactName: "",
isSameContact: true,
contactNumber: "",
eventName: "",
eventDate: formatLocalDate(
new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
), // 默认一周后
eventLocation: EVENT_LOCATIONS[0],
biddingSystem: false,
biddingSystemProvideImage: false,
estimatedBidItemCount: "30-40",
backgroundDesign: false,
backgroundType: "static",
backgroundWidthOverride: 1920,
backgroundHeightOverride: 1080,
flowBackgroundDesign: false,
backgroundSourceProvided: false,
pptDesignQty: 1,
sponsorListDesign: false,
});
// --- Pricing logic (editable) ---
const PRICE = {
bidding: 550, // base for bidding system
biddingProvideImageDiscount: 50, // discount if client provides all images
biddingLocationTierAdjustment: {
[EVENT_LOCATIONS[0]]: { adj: -100, reason: "同乡折扣" },
[EVENT_LOCATIONS[1]]: { adj: 0, reason: "" },
[EVENT_LOCATIONS[2]]: { adj: 200, reason: "含差旅费" },
[EVENT_LOCATIONS[3]]: { adj: 400, reason: "含差旅费" },
},
// biddingTierAdditions: [
// { range: [10, 20], add: 0 },
// { range: [20, 30], add: 200 },
// { range: [30, 40], add: 400 },
// { range: [40, 50], add: 600 },
// ],
background: {
static: 100,
dynamic: 250,
// highResSurcharge: 200, // if dimensions exceed 1920x1080
},
// 背景设计加急
// 静态背景:倒数 2 天 + 20倒数 1 天 + 50
// 动态背景:倒数 5 天 + 50倒数 1 天 + 100中间 Lerp 计算
backgroundRush: (daysLeft: number, isDynamic: boolean) => {
if (isDynamic) {
if (daysLeft >= 5) return 0;
if (daysLeft <= 1) return 100;
return Math.round(((5 - daysLeft) / 4) * 50 + 50);
} else {
if (daysLeft >= 2) return 0;
if (daysLeft <= 1) return 50;
return Math.round(((2 - daysLeft) / 1) * 30 + 20);
}
},
flowPptDesign: {
base: 90,
perSlide: 10,
slideAfterTen: 5,
providedSourceDiscount: 0.5, // 20% off if background sources provided
},
sponsorList: 100,
} as const;
// function parseRange(range?: string | null): [number, number] | null {
// if (!range) return null;
// const m = String(range).match(/(\d+)\s*[-~]\s*(\d+)/);
// if (!m) return null;
// return [Number(m[1]), Number(m[2])];
// }
const priceBreakdown = computed(() => {
const items: { label: string; amount: number; note?: string }[] = [];
// Bidding system
if (orderState.biddingSystem) {
let amount = PRICE.bidding;
amount -= orderState.biddingSystemProvideImage
? PRICE.biddingProvideImageDiscount
: 0;
// const r = parseRange(orderState.estimatedBidItemCount);
// if (r) {
// const tier = PRICE.biddingTierAdditions.find(
// (t) => r[0] >= t.range[0] && r[1] <= t.range[1]
// );
// if (tier) amount += tier.add;
// }
items.push({
label: "竞价系统",
amount,
note:
`${orderState.estimatedBidItemCount} 件区间` +
(orderState.biddingSystemProvideImage
? ",素材由客户提供"
: ",包含素材拍摄") +
",图片处理,现场技术支持",
});
const locationAdj =
PRICE.biddingLocationTierAdjustment[orderState.eventLocation];
if (locationAdj.adj) {
items.push({
label: locationAdj.reason,
amount: locationAdj.adj,
note: `活动地点:${orderState.eventLocation}`,
});
}
}
// Stage background design
if (orderState.backgroundDesign) {
let amount =
orderState.backgroundType === "dynamic"
? PRICE.background.dynamic
: PRICE.background.static;
const w = Number(orderState.backgroundWidthOverride || 1920);
const h = Number(orderState.backgroundHeightOverride || 1080);
// if (w > 1920 || h > 1080) amount += PRICE.background.highResSurcharge;
items.push({
label: `舞台背景设计(${
orderState.backgroundType === "dynamic" ? "动效" : "静态"
}`,
amount,
note: `${w}×${h}`,
});
}
// Background design rush fee
if (orderState.backgroundDesign) {
const eventDate = new Date(orderState.eventDate);
const today = new Date();
// 计算剩余天数(向上取整)
const diffTime = eventDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const rushFee = PRICE.backgroundRush(
diffDays,
orderState.backgroundType === "dynamic"
);
if (rushFee > 0) {
items.push({
label: "背景设计加急费",
amount: rushFee,
note: `距离活动仅剩 ${diffDays}`,
});
}
}
// Flow/PPT design
if (orderState.flowBackgroundDesign) {
const qty = Number(orderState.pptDesignQty || 0);
// 如果客户有要求背景设计Base Price 不算
let amount = orderState.backgroundDesign ? 0 : PRICE.flowPptDesign.base;
if (qty > 0) {
amount += Math.min(qty, 10) * PRICE.flowPptDesign.perSlide;
if (qty > 10) {
amount += (qty - 10) * PRICE.flowPptDesign.slideAfterTen;
}
}
if (orderState.backgroundSourceProvided) {
amount = Math.round(
amount * (1 - PRICE.flowPptDesign.providedSourceDiscount)
);
}
items.push({
label: "流程/PPT设计",
amount,
note:
`${qty} 张幻灯片设计` +
(orderState.backgroundSourceProvided ? ",客户提供背景素材" : ""),
});
}
// Sponsor list
if (orderState.sponsorListDesign) {
items.push({ label: "赞助商位设计", amount: PRICE.sponsorList });
}
return items;
});
const estimatedTotal = computed(() =>
priceBreakdown.value.reduce((sum, i) => sum + (i.amount || 0), 0)
);
const money = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "MYR",
maximumFractionDigits: 0,
});
return {
sectionIndex,
eventLocationItems,
orderSchema,
orderState,
priceBreakdown,
estimatedTotal,
money,
};
};
export const useEventOrder = createSharedComposable(_useEventOrder);