feat(web): currency becomes a closed select (ALL / EUR / USD)
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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user