Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
// 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<OfferLike, "active" | "published" | "startsAt" | "endsAt">, 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<OfferStatus, string> = {
|
|
draft: "Draft",
|
|
scheduled: "Scheduled",
|
|
running: "Running",
|
|
expired: "Ended",
|
|
};
|
|
|
|
export const STATUS_TONE: Record<OfferStatus, "neutral" | "info" | "success" | "warning"> = {
|
|
draft: "neutral",
|
|
scheduled: "info",
|
|
running: "success",
|
|
expired: "warning",
|
|
};
|
|
|
|
export const TYPE_LABEL: Record<string, string> = {
|
|
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"
|
|
);
|
|
}
|