feat(web): currency becomes a closed select (ALL / EUR / USD)
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 41s

Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-05 15:23:29 +02:00
parent 1de209be48
commit d5ff2097bd
3 changed files with 24 additions and 3 deletions
+6 -1
View File
@@ -14,6 +14,7 @@ import {
type SubscriptionPlan, type SubscriptionPlan,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { currencyOptions } from "./lib/currencies.js";
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the // Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A // operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
@@ -348,7 +349,11 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.pricePer")}</label> <label className="label">{t("plans.pricePer")}</label>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> <input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} /> <select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span> <span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span> </span>
</div> </div>
+7 -2
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { currencyOptions } from "./lib/currencies.js";
import { import {
isTariffV2, isTariffV2,
type TariffBlock, type TariffBlock,
@@ -101,7 +102,7 @@ function emptyTier(): TierForm {
export function emptyForm(): FormState { export function emptyForm(): FormState {
return { return {
currency: "EUR", currency: "ALL",
gracePeriodEntryMin: "15", gracePeriodEntryMin: "15",
incrementMin: "60", incrementMin: "60",
lostTicket: "20.00", lostTicket: "20.00",
@@ -339,7 +340,11 @@ export function TariffEditorForm({
<div> <div>
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2"> <div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.currency")}</label> <label className="label">{t("tariff.currency")}</label>
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} /> <select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="label">{t("tariff.freeEntryGrace")}</label> <label className="label">{t("tariff.freeEntryGrace")}</label>
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} /> <input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label className="label">{t("tariff.billingIncrement")}</label> <label className="label">{t("tariff.billingIncrement")}</label>
+11
View File
@@ -0,0 +1,11 @@
// The currencies the booth can price in (ISO 4217). Money is always stored as
// integer minor units + one of these codes; the UI offers a closed select rather
// than free text so a typo can never publish an unknown currency.
export const CURRENCIES = ["ALL", "EUR", "USD"] as const;
/** The select options: the known set, plus the current value when it's some
* historical code outside it (so an old record still displays + round-trips). */
export function currencyOptions(current: string): string[] {
const cur = current.trim().toUpperCase();
return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES];
}