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
+78 -26
View File
@@ -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: {
function pricingFromCard(
c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
packageMinor?: number;
dailyCapMinor?: number | null;
}): PricingForm {
},
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<K extends keyof FormState>(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({
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</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
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>
@@ -363,6 +394,7 @@ export function TariffEditorForm({
<PricingEditor
t={t}
pricing={form.base}
incrementMin={inc}
allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
@@ -434,6 +466,7 @@ export function TariffEditorForm({
<PricingEditor
t={t}
pricing={tr.pricing}
incrementMin={inc}
allowPackage
onMode={(mode) => 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, unknown>) => 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 (
<div>
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
@@ -484,7 +532,7 @@ function PricingEditor(props: {
</label>
<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")} />
{t("tariff.modeFlat")}
{flatLabel}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
@@ -550,8 +598,9 @@ function PricingEditor(props: {
</>
) : p.mode === "flat" ? (
<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)} />
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
</div>
) : (
<>
@@ -559,7 +608,7 @@ function PricingEditor(props: {
<thead>
<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.pricePerIncrement")}</th>
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
<th />
</tr>
</thead>
@@ -579,7 +628,10 @@ function PricingEditor(props: {
)}
</td>
<td className="px-2 py-1">
<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 className="px-2">
{!isTail && (