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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user