From ab968eb25e3e1f5b3f530186d957a0f9282f12e7 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 6 Jul 2026 15:41:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20composer=20states=20the=20billing?= =?UTF-8?q?=20unit=20=E2=80=94=20the=2060=E2=86=9210=20price=20trap=20clos?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ladder/flat prices are PER BILLING INCREMENT, but the form said only "Çmimi / interval" — so changing the increment 60→10 silently multiplied every price ×6 (operator walked into it). Now: - Price headers name the real unit live: "Çmimi / orë" at 60, "Çmimi / N min" otherwise (flat-mode radio label likewise). - Amber warning whenever the increment ≠ 60: every price below is charged per started N minutes, NOT per hour. - Per-row "= X / orë" equivalence next to each ladder/flat price when the tick isn't an hour — the multiplication nobody should do mentally. - Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500 up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms, new tier rows, and mode-switch templates alike. Band DURATIONS stay in hours — real wall time, increment-independent. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/web/src/TariffEditorForm.tsx | 116 +++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/apps/web/src/TariffEditorForm.tsx b/apps/web/src/TariffEditorForm.tsx index 50cd04d..4703c6a 100644 --- a/apps/web/src/TariffEditorForm.tsx +++ b/apps/web/src/TariffEditorForm.tsx @@ -70,23 +70,35 @@ export interface FormState { const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); const toMajor = (minor: number): string => (minor / 100).toFixed(2); -function emptySteps(): StepForm[] { +/** 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: "2.00" }, - { hours: "3", total: "5.00" }, + { hours: "1", total: ex.stepSmall }, + { hours: "3", total: ex.stepBig }, ]; } -function emptyLadder(): PricingForm { +function emptyLadder(currency: string): PricingForm { + const ex = examples(currency); return { mode: "ladder", flat: "0.00", packageTotal: "0.00", dailyCap: "", - blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], - steps: emptySteps(), + blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }], + steps: emptySteps(currency), }; } -function emptyTier(): TierForm { +function emptyTier(currency: string): TierForm { return { name: "", priority: "10", @@ -96,18 +108,19 @@ function emptyTier(): TierForm { toHour: "", dateFrom: "", dateTo: "", - pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] }, + 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: "ALL", + currency, gracePeriodEntryMin: "15", incrementMin: "60", - lostTicket: "20.00", + lostTicket: examples(currency).lost, gracePeriodExitMin: "15", - base: emptyLadder(), + base: emptyLadder(currency), tiers: [], }; } @@ -132,31 +145,34 @@ function stepsToForm(steps: TariffStep[]): StepForm[] { // 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; -}): PricingForm { +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(), mode: "stepped", steps: stepsToForm(c.steps) }; + return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) }; } if (c.packageMinor != null) { - return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) }; + return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) }; } if (c.flatMinor != null) { - return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) }; + return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) }; } return { - ...emptyLadder(), + ...emptyLadder(currency), mode: "ladder", dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), blocks: blocksToForm(c.blocks ?? []), }; } -function tierFromCard(c: TariffCard): TierForm { +function tierFromCard(c: TariffCard, currency: string): TierForm { const w = c.window ?? {}; return { name: c.name, @@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm { toHour: w.toHour ?? "", dateFrom: w.dateFrom ?? "", dateTo: w.dateTo ?? "", - pricing: pricingFromCard(c), + pricing: pricingFromCard(c, currency), }; } @@ -182,10 +198,14 @@ export function formFromVersion(currency: string, st: TariffStructure): FormStat gracePeriodExitMin: String(st.gracePeriodExitMin), }; if (isTariffV2(st)) { - return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) }; + 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), tiers: [] }; + return { ...common, base: pricingFromCard(st, currency), tiers: [] }; } export function formFromActive(s: TariffState): FormState { @@ -278,6 +298,8 @@ export function TariffEditorForm({ 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 })); @@ -322,7 +344,7 @@ export function TariffEditorForm({ onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); } function addTier() { - onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); + onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] })); } function removeTier(i: number) { onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); @@ -355,6 +377,15 @@ export function TariffEditorForm({ 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")}

@@ -363,6 +394,7 @@ export function TariffEditorForm({ updatePricing("base", (p) => ({ ...p, mode }))} onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} @@ -434,6 +466,7 @@ export function TariffEditorForm({ updatePricing(i, (p) => ({ ...p, mode }))} onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} @@ -459,8 +492,11 @@ export function TariffEditorForm({ // (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) => string; + 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; @@ -475,6 +511,18 @@ function PricingEditor(props: { 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 (
@@ -484,7 +532,7 @@ function PricingEditor(props: { {props.allowStepped && (