feat(web): composer states the billing unit — the 60→10 price trap closed

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
This commit is contained in:
2026-07-06 15:41:40 +02:00
parent 5e1a885dcb
commit ab968eb25e
+84 -32
View File
@@ -70,23 +70,35 @@ export interface FormState {
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2); 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 [ return [
{ hours: "1", total: "2.00" }, { hours: "1", total: ex.stepSmall },
{ hours: "3", total: "5.00" }, { hours: "3", total: ex.stepBig },
]; ];
} }
function emptyLadder(): PricingForm { function emptyLadder(currency: string): PricingForm {
const ex = examples(currency);
return { return {
mode: "ladder", mode: "ladder",
flat: "0.00", flat: "0.00",
packageTotal: "0.00", packageTotal: "0.00",
dailyCap: "", dailyCap: "",
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
steps: emptySteps(), steps: emptySteps(currency),
}; };
} }
function emptyTier(): TierForm { function emptyTier(currency: string): TierForm {
return { return {
name: "", name: "",
priority: "10", priority: "10",
@@ -96,18 +108,19 @@ function emptyTier(): TierForm {
toHour: "", toHour: "",
dateFrom: "", dateFrom: "",
dateTo: "", dateTo: "",
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] }, pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
}; };
} }
export function emptyForm(): FormState { export function emptyForm(): FormState {
const currency = "ALL"; // the site's currency — examples scale with it
return { return {
currency: "ALL", currency,
gracePeriodEntryMin: "15", gracePeriodEntryMin: "15",
incrementMin: "60", incrementMin: "60",
lostTicket: "20.00", lostTicket: examples(currency).lost,
gracePeriodExitMin: "15", gracePeriodExitMin: "15",
base: emptyLadder(), base: emptyLadder(currency),
tiers: [], 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, // A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
// or window package). // or window package).
function pricingFromCard(c: { function pricingFromCard(
flatMinor?: number; c: {
blocks?: TariffBlock[]; flatMinor?: number;
steps?: TariffStep[]; blocks?: TariffBlock[];
packageMinor?: number; steps?: TariffStep[];
dailyCapMinor?: number | null; packageMinor?: number;
}): PricingForm { dailyCapMinor?: number | null;
},
currency: string,
): PricingForm {
if (c.steps != null && c.steps.length > 0) { 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) { 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) { if (c.flatMinor != null) {
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) }; return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
} }
return { return {
...emptyLadder(), ...emptyLadder(currency),
mode: "ladder", mode: "ladder",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []), blocks: blocksToForm(c.blocks ?? []),
}; };
} }
function tierFromCard(c: TariffCard): TierForm { function tierFromCard(c: TariffCard, currency: string): TierForm {
const w = c.window ?? {}; const w = c.window ?? {};
return { return {
name: c.name, name: c.name,
@@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm {
toHour: w.toHour ?? "", toHour: w.toHour ?? "",
dateFrom: w.dateFrom ?? "", dateFrom: w.dateFrom ?? "",
dateTo: w.dateTo ?? "", 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), gracePeriodExitMin: String(st.gracePeriodExitMin),
}; };
if (isTariffV2(st)) { 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. // 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 { export function formFromActive(s: TariffState): FormState {
@@ -278,6 +298,8 @@ export function TariffEditorForm({
onChange: (update: (f: FormState) => FormState) => void; onChange: (update: (f: FormState) => FormState) => void;
}) { }) {
const { t } = useTranslation(); 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<K extends keyof FormState>(key: K, value: FormState[K]) { function set<K extends keyof FormState>(key: K, value: FormState[K]) {
onChange((f) => ({ ...f, [key]: value })); 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)) })); onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
} }
function addTier() { function addTier() {
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
} }
function removeTier(i: number) { function removeTier(i: number) {
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
@@ -355,6 +377,15 @@ export function TariffEditorForm({
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} /> <input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div> </div>
{/* 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 && (
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.incrementWarning", { min: inc })}
</p>
)}
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
wants tiers just edits this and publishes a bare V1 structure. */} wants tiers just edits this and publishes a bare V1 structure. */}
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3> <h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
@@ -363,6 +394,7 @@ export function TariffEditorForm({
<PricingEditor <PricingEditor
t={t} t={t}
pricing={form.base} pricing={form.base}
incrementMin={inc}
allowStepped allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))} onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
@@ -434,6 +466,7 @@ export function TariffEditorForm({
<PricingEditor <PricingEditor
t={t} t={t}
pricing={tr.pricing} pricing={tr.pricing}
incrementMin={inc}
allowPackage allowPackage
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))} onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} 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 // (the default card); the package mode only where `allowPackage` (tier cards — the
// engine needs a window to be an occurrence of). // engine needs a window to be an occurrence of).
function PricingEditor(props: { function PricingEditor(props: {
t: (k: string) => string; t: (k: string, opts?: Record<string, unknown>) => string;
pricing: PricingForm; 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; allowStepped?: boolean;
allowPackage?: boolean; allowPackage?: boolean;
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void; onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
@@ -475,6 +511,18 @@ function PricingEditor(props: {
onRemoveStep?: (i: number) => void; onRemoveStep?: (i: number) => void;
}) { }) {
const { t, pricing: p } = props; 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 ( return (
<div> <div>
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]"> <div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
@@ -484,7 +532,7 @@ function PricingEditor(props: {
</label> </label>
<label className="inline-flex items-center gap-1.5 text-term-text"> <label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} /> <input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")} {flatLabel}
</label> </label>
{props.allowStepped && ( {props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text"> <label className="inline-flex items-center gap-1.5 text-term-text">
@@ -550,8 +598,9 @@ function PricingEditor(props: {
</> </>
) : p.mode === "flat" ? ( ) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span> <span className="label">{unitLabel}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} /> <input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
</div> </div>
) : ( ) : (
<> <>
@@ -559,7 +608,7 @@ function PricingEditor(props: {
<thead> <thead>
<tr className="text-left"> <tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th> <th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th> <th className="label px-2 pb-1 font-normal">{unitLabel}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -579,7 +628,10 @@ function PricingEditor(props: {
)} )}
</td> </td>
<td className="px-2 py-1"> <td className="px-2 py-1">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} /> <span className="inline-flex items-center gap-2">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
</span>
</td> </td>
<td className="px-2"> <td className="px-2">
{!isTail && ( {!isTail && (