import { useTranslation } from "react-i18next"; import { currencyOptions } from "./lib/currencies.js"; import { isTariffV2, type TariffBlock, type TariffCard, type TariffStep, type TariffStructure, type TariffState, } from "./api.js"; // The tariff EDITOR FORM — the rate-card composer's form machinery (state shape, // structure↔form converters, and the editing UI), extracted so two hosts can share // it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's // draft modal (edits an experimental card). The host owns the FormState and the // submit action; this module owns everything between. Amounts are entered in major // units (e.g. euros) and converted to integer minor units on submit. // See wiki/concepts/tariff.md. // Editable form mirror of TariffStructure, but money in major-unit strings. // Blocks are edited as a DURATION in hours ("this band lasts N hours") — the // owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes. // The LAST block is always open-ended ("thereafter"): its hours field is unused // and it has no bound. On submit, per-block hours accumulate into the engine's // cumulative `uptoMin` (minutes), and the last block emits uptoMin: null. export interface BlockForm { hours: string; // duration of THIS band, in hours (ignored for the last block) price: string; // major units, e.g. "2.00" } // One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the // matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md. export interface StepForm { hours: string; // inclusive upper bound of this tier, in hours (e.g. "3") total: string; // TOTAL major units for a stay within this tier (e.g. "5.00") } // A pricing body the form edits: a per-increment flat rate, a marginal block ladder, // a stepped (up-to) total-by-duration table, or a whole-window package (tiers only). export interface PricingForm { mode: "ladder" | "flat" | "stepped" | "package"; flat: string; // major units PER INCREMENT (used when mode==="flat") packageTotal: string; // major units for the WHOLE window occurrence (mode==="package") blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") steps: StepForm[]; // up-to tiers (used when mode==="stepped") dailyCap: string; // "" = no cap (ladder only) } // An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained. export interface TierForm { name: string; priority: string; category: string; // "" = applies to all categories dow: number[]; // selected days 0..6; empty = every day fromHour: string; // "" = all day toHour: string; dateFrom: string; // "" = unbounded dateTo: string; pricing: PricingForm; } export interface FormState { currency: string; gracePeriodEntryMin: string; incrementMin: string; lostTicket: string; gracePeriodExitMin: string; // The default (always-active) card — its own flat/ladder body + daily cap. base: PricingForm; // Optional time/category tiers. Empty ⇒ a bare V1 structure is published. tiers: TierForm[]; } const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); const toMajor = (minor: number): string => (minor / 100).toFixed(2); /** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded * "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as * 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are * ~100× the euro ones; USD rides with EUR. */ function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } { return currency.trim().toUpperCase() === "ALL" ? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" } : { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" }; } function emptySteps(currency: string): StepForm[] { const ex = examples(currency); return [ { hours: "1", total: ex.stepSmall }, { hours: "3", total: ex.stepBig }, ]; } function emptyLadder(currency: string): PricingForm { const ex = examples(currency); return { mode: "ladder", flat: "0.00", packageTotal: "0.00", dailyCap: "", blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }], steps: emptySteps(currency), }; } function emptyTier(currency: string): TierForm { return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] }, }; } export function emptyForm(): FormState { const currency = "ALL"; // the site's currency — examples scale with it return { currency, gracePeriodEntryMin: "15", incrementMin: "60", lostTicket: examples(currency).lost, gracePeriodExitMin: "15", base: emptyLadder(currency), tiers: [], }; } // Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band // hours the form edits. Open-ended last band has no hours. Legacy bounded tails still // load (shown as their own band). function blocksToForm(blocks: TariffBlock[]): BlockForm[] { let prev = 0; return blocks.map((b) => { if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) }; const hours = (b.uptoMin - prev) / 60; prev = b.uptoMin; return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) }; }); } // A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits. function stepsToForm(steps: TariffStep[]): StepForm[] { return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) })); } // A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped, // or window package). function pricingFromCard( c: { flatMinor?: number; blocks?: TariffBlock[]; steps?: TariffStep[]; packageMinor?: number; dailyCapMinor?: number | null; }, currency: string, ): PricingForm { if (c.steps != null && c.steps.length > 0) { return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) }; } if (c.packageMinor != null) { return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) }; } if (c.flatMinor != null) { return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) }; } return { ...emptyLadder(currency), mode: "ladder", dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), blocks: blocksToForm(c.blocks ?? []), }; } function tierFromCard(c: TariffCard, currency: string): TierForm { const w = c.window ?? {}; return { name: c.name, priority: String(c.priority), category: c.category ?? "", dow: w.dow ? [...w.dow] : [], fromHour: w.fromHour ?? "", toHour: w.toHour ?? "", dateFrom: w.dateFrom ?? "", dateTo: w.dateTo ?? "", pricing: pricingFromCard(c, currency), }; } /** A stored (currency, structure) pair → the editable form. Used to load the active * version into the composer page and a saved draft into the lab modal. */ export function formFromVersion(currency: string, st: TariffStructure): FormState { const common = { currency, gracePeriodEntryMin: String(st.gracePeriodEntryMin), incrementMin: String(st.incrementMin), lostTicket: toMajor(st.lostTicketMinor), gracePeriodExitMin: String(st.gracePeriodExitMin), }; if (isTariffV2(st)) { return { ...common, base: pricingFromCard(st.defaultCard, currency), tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)), }; } // V1: the bare ladder becomes the default card body; no tiers. return { ...common, base: pricingFromCard(st, currency), tiers: [] }; } export function formFromActive(s: TariffState): FormState { return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm(); } // Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm. function pricingToCardBody(p: PricingForm): Pick { if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) }; if (p.mode === "stepped") { // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours"). const steps: TariffStep[] = p.steps.map((s) => ({ uptoMin: Math.round(Number(s.hours || "0") * 60), totalMinor: toMinor(s.total), })); return { steps }; } // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended. const last = p.blocks.length - 1; let cum = 0; const blocks: TariffBlock[] = p.blocks.map((b, i) => { if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) }; cum += Math.round(Number(b.hours || "0") * 60); return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) }; }); return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) }; } function tierToCard(tr: TierForm): TariffCard { const window: TariffCard["window"] = {}; if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b); if (tr.fromHour && tr.toHour) { window.fromHour = tr.fromHour; window.toHour = tr.toHour; } if (tr.dateFrom) window.dateFrom = tr.dateFrom; if (tr.dateTo) window.dateTo = tr.dateTo; const card: TariffCard = { name: tr.name.trim() || "tier", priority: Math.round(Number(tr.priority || "0")), ...pricingToCardBody(tr.pricing), }; if (tr.category.trim()) card.category = tr.category.trim(); if (Object.keys(window).length > 0) card.window = window; return card; } export function toStructure(f: FormState): TariffStructure { const common = { gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), incrementMin: Math.round(Number(f.incrementMin)), lostTicketMinor: toMinor(f.lostTicket), gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), overstay: "reprice" as const, }; const baseBody = pricingToCardBody(f.base); // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants // tiers gets exactly today's shape; the server leaves it untouched). if (f.tiers.length === 0) { if (f.base.mode === "stepped") { // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap). return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null }; } if (f.base.mode === "flat") { // A flat V1: a single open-ended block at the flat rate (V1 has no flat field). return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null }; } return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null }; } // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here). return { ...common, version: 2, tz: "", defaultCard: { name: "default", priority: 0, ...baseBody }, windowedCards: f.tiers.map(tierToCard), }; } /** The full rate-card editing UI (shared settings + default card + tiers). The host * owns the FormState; every edit flows through `onChange` as a functional update. */ export function TariffEditorForm({ form, onChange, }: { form: FormState; onChange: (update: (f: FormState) => FormState) => void; }) { const { t } = useTranslation(); // The billing unit all flat/ladder prices are entered in (labels reflect it live). const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60); function set(key: K, value: FormState[K]) { onChange((f) => ({ ...f, [key]: value })); } // --- pricing-body editing (used by the default card AND each tier) --- // `update` maps the old PricingForm to a new one; `target` selects which body: // the base card, or tier index N. function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) { onChange((f) => { if (target === "base") return { ...f, base: update(f.base) }; return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) }; }); } function setBlock(target: "base" | number, i: number, patch: Partial) { updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); } // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended. function addBlock(target: "base" | number) { updatePricing(target, (p) => { const next = [...p.blocks]; next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" }); return { ...p, blocks: next }; }); } function removeBlock(target: "base" | number, i: number) { updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) })); } // --- stepped (up-to) editing (base card only) --- function setStep(i: number, patch: Partial) { updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); } function addStep() { updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] })); } function removeStep(i: number) { updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) })); } // --- tier editing --- function setTier(i: number, patch: Partial) { onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); } function addTier() { onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] })); } function removeTier(i: number) { onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); } function toggleDow(i: number, d: number) { onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr, ), })); } return (
set("gracePeriodEntryMin", e.target.value)} /> set("incrementMin", e.target.value)} /> set("lostTicket", e.target.value)} /> set("gracePeriodExitMin", e.target.value)} />
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the form reads naturally as per-hour; any other value silently redefines every price below, so shout it (the 60→10 "six charges per hour" trap). */} {inc !== 60 && (

{t("tariff.incrementWarning", { min: inc })}

)} {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never wants tiers just edits this and publishes a bare V1 structure. */}

{t("tariff.defaultCard")}

{t("tariff.defaultCardHint")}

updatePricing("base", (p) => ({ ...p, mode }))} onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} onBlock={(i, patch) => setBlock("base", i, patch)} onAddBlock={() => addBlock("base")} onRemoveBlock={(i) => removeBlock("base", i)} onStep={setStep} onAddStep={addStep} onRemoveStep={removeStep} />
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
0}> {t("tariff.tiersAdvanced")}

{t("tariff.tiersHint")}

{/* A stepped ("up-to") base rate cannot be combined with time tiers — the engine would ignore them. Warn up-front; publishing is also blocked server-side. */} {form.base.mode === "stepped" && form.tiers.length > 0 && (

{t("tariff.steppedTiersConflict")}

)} {form.tiers.map((tr, i) => (
setTier(i, { name: e.target.value })} placeholder={t("tariff.tierName")} />
setTier(i, { priority: e.target.value })} /> setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} /> {[1, 2, 3, 4, 5, 6, 0].map((d) => ( ))} setTier(i, { fromHour: e.target.value })} placeholder="22:00" /> – setTier(i, { toHour: e.target.value })} placeholder="06:00" /> {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( {t("tariff.tierOvernight")} )} setTier(i, { dateFrom: e.target.value })} /> – setTier(i, { dateTo: e.target.value })} />
updatePricing(i, (p) => ({ ...p, mode }))} onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))} onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))} onBlock={(bi, patch) => setBlock(i, bi, patch)} onAddBlock={() => addBlock(i)} onRemoveBlock={(bi) => removeBlock(i, bi)} />
))}
); } // A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped // (up-to) / window package. The stepped mode is offered only where `allowStepped` // (the default card); the package mode only where `allowPackage` (tier cards — the // engine needs a window to be an occurrence of). function PricingEditor(props: { t: (k: string, opts?: Record) => string; pricing: PricingForm; /** Current billing increment (minutes) — every flat/ladder price is PER this unit, * so the price labels state it explicitly instead of a vague "per increment". */ incrementMin: number; allowStepped?: boolean; allowPackage?: boolean; onMode: (m: "ladder" | "flat" | "stepped" | "package") => void; onFlat: (v: string) => void; onPackage?: (v: string) => void; onCap: (v: string) => void; onBlock: (i: number, patch: Partial) => void; onAddBlock: () => void; onRemoveBlock: (i: number) => void; onStep?: (i: number, patch: Partial) => void; onAddStep?: () => void; onRemoveStep?: (i: number) => void; }) { const { t, pricing: p } = props; /** "= N / orë" equivalence for a per-increment price (only shown when the tick * isn't an hour — at 60 the price already IS the hourly price). */ const perHour = (major: string): string | null => { if (props.incrementMin === 60) return null; const v = Number(major); if (!Number.isFinite(v) || v <= 0) return null; return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) }); }; const unitLabel = props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin }); const flatLabel = props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin }); return (
{props.allowStepped && ( )} {props.allowPackage && ( )}
{p.mode === "package" ? (

{t("tariff.packageHint")}

{t("tariff.packageTotal")} props.onPackage?.(e.target.value)} />
) : p.mode === "stepped" ? ( <>

{t("tariff.steppedHint")}

{p.steps.map((s, i) => ( ))}
{t("tariff.stepUpTo")} {t("tariff.stepTotal")}
props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> {t("tariff.hoursUnit")} props.onStep?.(i, { total: e.target.value })} /> {p.steps.length > 1 && ( )}
) : p.mode === "flat" ? (
{unitLabel} props.onFlat(e.target.value)} /> {perHour(p.flat) && {perHour(p.flat)}}
) : ( <> {p.blocks.map((b, i) => { const isTail = i === p.blocks.length - 1; return ( ); })}
{t("tariff.bandDuration")} {unitLabel}
{isTail ? ( {t("tariff.thereafter")} ) : ( props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> {t("tariff.hoursUnit")} )} props.onBlock(i, { price: e.target.value })} /> {perHour(b.price) && {perHour(b.price)}} {!isTail && ( )}
{t("tariff.dailyCap")} props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
)}
); }