feat(web): UI component layer + dark-theme reskin

The TRM tokens were good but every screen hand-rolled inputs and buttons as
bare outlines on near-black panels, so fields, cards and buttons were
visually indistinguishable. Add a component layer (.input/.select/.textarea
as recessed slots, .btn family with a FILLED primary, .card scaffolding) and
adopt it across the booth/shift/login/tariff/site screens — several of which
were still light-theme inline styles dropped on a dark background.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 10:07:39 +02:00
parent ef0ecadff9
commit 808fb26ab6
8 changed files with 371 additions and 203 deletions
+96 -86
View File
@@ -284,12 +284,14 @@ export function TariffComposer() {
}
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("tariff.title")}</h2>
<section className="mx-auto max-w-3xl px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
{!state?.active ? (
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
{t("tariff.noRateCard")}
</p>
) : (
<p style={{ color: "#555" }}>
<p className="mb-4 text-[12px] text-term-muted">
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
@@ -297,82 +299,84 @@ export function TariffComposer() {
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
<label>{t("tariff.currency")}</label>
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
<label>{t("tariff.freeEntryGrace")}</label>
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>{t("tariff.billingIncrement")}</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>{t("tariff.lostTicketFee")}</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>{t("tariff.exitGrace")}</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
<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>
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
<label className="label">{t("tariff.freeEntryGrace")}</label>
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label className="label">{t("tariff.billingIncrement")}</label>
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label className="label">{t("tariff.lostTicketFee")}</label>
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label className="label">{t("tariff.exitGrace")}</label>
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
{/* 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 style={{ marginBottom: "0.25rem" }}>{t("tariff.defaultCard")}</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.defaultCardHint")}</p>
<PricingEditor
t={t}
pricing={form.base}
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)}
/>
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
<div className="card card-body">
<PricingEditor
t={t}
pricing={form.base}
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)}
/>
</div>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details style={{ marginTop: "1.25rem" }} open={form.tiers.length > 0}>
<summary style={{ cursor: "pointer", fontWeight: 600 }}>{t("tariff.tiersAdvanced")}</summary>
<p style={{ color: "#777", margin: "0.4rem 0", fontSize: "0.9em" }}>{t("tariff.tiersHint")}</p>
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{form.tiers.map((tr, i) => (
<fieldset key={i} style={{ border: "1px solid #ddd", borderRadius: 6, padding: "0.6rem 0.8rem", marginBottom: "0.75rem" }}>
<legend style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
<input
className="input w-40"
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
style={{ width: 140 }}
/>
<button type="button" onClick={() => removeTier(i)}>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.35rem 0.75rem", alignItems: "center", maxWidth: 520 }}>
<label>{t("tariff.tierPriority")}</label>
<input value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} style={{ width: 70 }} />
<label>{t("tariff.tierCategory")}</label>
<input value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} />
<label>{t("tariff.tierDays")}</label>
<span style={{ display: "flex", gap: "0.3rem", flexWrap: "wrap" }}>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.tierPriority")}</label>
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
<label className="label">{t("tariff.tierCategory")}</label>
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} style={{ display: "inline-flex", alignItems: "center", gap: "0.15rem", fontSize: "0.85em" }}>
<input type="checkbox" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label>{t("tariff.tierHours")}</label>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
<input value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} />
<span>–</span>
<input value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} />
<label className="label">{t("tariff.tierHours")}</label>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span style={{ color: "#777", fontSize: "0.8em" }}>{t("tariff.tierOvernight")}</span>
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label>{t("tariff.tierDates")}</label>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
<input type="date" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span>–</span>
<input type="date" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
<label className="label">{t("tariff.tierDates")}</label>
<span className="inline-flex items-center gap-2">
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span className="text-term-muted">–</span>
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div style={{ marginTop: "0.5rem" }}>
<div className="mt-3 border-t border-term-border pt-3">
<PricingEditor
t={t}
pricing={tr.pricing}
@@ -386,19 +390,19 @@ export function TariffComposer() {
</div>
</fieldset>
))}
<button type="button" onClick={addTier}>
<button type="button" className="btn btn-sm" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
{msg && (
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
)}
</div>
{msg && (
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
)}
</section>
);
}
@@ -417,29 +421,29 @@ function PricingEditor(props: {
const { t, pricing: p } = props;
return (
<div>
<div style={{ display: "flex", gap: "1rem", marginBottom: "0.4rem", fontSize: "0.9em" }}>
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
<input type="radio" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
<div className="mb-3 flex gap-4 text-[12px]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
<input type="radio" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
<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")}
</label>
</div>
{p.mode === "flat" ? (
<div style={{ display: "inline-flex", gap: "0.4rem", alignItems: "center" }}>
<span style={{ color: "#777" }}>{t("tariff.pricePerIncrement")}</span>
<input value={p.flat} onChange={(e) => props.onFlat(e.target.value)} style={{ width: 90 }} />
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
</div>
) : (
<>
<table style={{ borderCollapse: "collapse" }}>
<table className="w-full border-collapse">
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
<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 />
</tr>
</thead>
@@ -448,32 +452,38 @@ function PricingEditor(props: {
const isTail = i === p.blocks.length - 1;
return (
<tr key={i}>
<td style={{ padding: "0.15rem 0.5rem" }}>
<td className="px-2 py-1">
{isTail ? (
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
) : (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
<input value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} style={{ width: 70 }} />
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} style={{ width: 90 }} />
<td className="px-2 py-1">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
</td>
<td className="px-2">
{!isTail && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
{t("tariff.remove")}
</button>
)}
</td>
<td>{!isTail && <button type="button" onClick={() => props.onRemoveBlock(i)}>{t("tariff.remove")}</button>}</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ marginTop: "0.4rem", display: "flex", gap: "1rem", alignItems: "center" }}>
<button type="button" onClick={props.onAddBlock}>
<div className="mt-3 flex items-center gap-4">
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
{t("tariff.addBlock")}
</button>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center", fontSize: "0.9em" }}>
<span style={{ color: "#777" }}>{t("tariff.dailyCap")}</span>
<input value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} style={{ width: 90 }} />
<span className="inline-flex items-center gap-2">
<span className="label">{t("tariff.dailyCap")}</span>
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
</span>
</div>
</>