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