// Offer texts, status and display prices (brief §3, §5). Shared by the admin UI, the config compiler and // the app proxy pages. Keep wording identical to the Odoo module. export type BundleType = "fixed" | "mix" | "bxgy"; export type DiscountType = "percent" | "amount" | "fixed_price"; export type OfferStatus = "draft" | "scheduled" | "running" | "expired"; export interface OfferLike { type: BundleType | string; discountType: DiscountType | string; discountValue: number | string | { toString(): string }; mixQty: number; buyQty: number; getQty: number; getPercent: number | string | { toString(): string }; active: boolean; published: boolean; startsAt: Date | string | null; endsAt: Date | string | null; } export const num = (v: unknown) => Number(typeof v === "object" && v !== null ? v.toString() : v) || 0; /** %g: no trailing zeros */ export function pct(v: unknown) { const n = num(v); return String(Number(n.toFixed(4))); } export type MoneyFormatter = (amount: number) => string; export function moneyFormatter(currency: string, locale = "en"): MoneyFormatter { try { const f = new Intl.NumberFormat(locale, { style: "currency", currency }); return (a) => f.format(a); } catch { return (a) => `${a.toFixed(2)} ${currency}`; } } /** Summary text (§5). */ export function offerSummary(o: OfferLike, money: MoneyFormatter): string { const v = num(o.discountValue); if (o.type === "bxgy") { const gp = num(o.getPercent); return gp >= 100 ? `Buy ${o.buyQty}, get ${o.getQty} free` : `Buy ${o.buyQty}, get ${o.getQty} at ${pct(gp)}% off`; } if (o.type === "mix") { if (o.discountType === "fixed_price") return `Any ${o.mixQty} for ${money(v)}`; if (o.discountType === "amount") return `${money(v)} off any ${o.mixQty}`; return `${pct(v)}% off any ${o.mixQty}`; } if (o.discountType === "fixed_price") return `Bundle price ${money(v)}`; if (o.discountType === "amount") return `Save ${money(v)} on the bundle`; return `Save ${pct(v)}% on the bundle`; } const toDate = (d: Date | string | null) => (d ? new Date(d) : null); /** draft if inactive or unpublished; expired if end < now; scheduled if start > now; else running. */ export function offerStatus(o: Pick, now = new Date()): OfferStatus { if (!o.active || !o.published) return "draft"; const end = toDate(o.endsAt); const start = toDate(o.startsAt); if (end && end < now) return "expired"; if (start && start > now) return "scheduled"; return "running"; } export const STATUS_LABEL: Record = { draft: "Draft", scheduled: "Scheduled", running: "Running", expired: "Ended", }; export const STATUS_TONE: Record = { draft: "neutral", scheduled: "info", running: "success", expired: "warning", }; export const TYPE_LABEL: Record = { fixed: "Fixed bundle", mix: "Mix & match", bxgy: "Buy X get Y", }; /** Fixed bundle display prices (§5): saving and bundle price from the regular price. */ export function fixedBundlePrices(regular: number, discountType: string, value: number) { let saving = 0; if (discountType === "percent") saving = (regular * Math.min(value, 100)) / 100; else if (discountType === "amount") saving = Math.min(value, regular); else if (discountType === "fixed_price") saving = Math.max(regular - value, 0); saving = Math.max(saving, 0); return { regular, saving, bundle: regular - saving }; } export function slugify(name: string) { return ( name .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60) || "bundle" ); }