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
+1 -1
View File
@@ -108,7 +108,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)}
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
className="btn btn-pay btn-sm shrink-0"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
+8 -11
View File
@@ -186,7 +186,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
type="button"
onClick={handleOpenShift}
disabled={openingShift}
className="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
className="btn btn-go btn-sm mt-2"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
</button>
@@ -263,11 +263,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
key={tn}
type="button"
onClick={() => setTender(tn)}
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
tender === tn
? "border-term-amber text-term-amber"
: "border-term-border text-term-muted hover:text-term-text"
}`}
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.${tn}`)}
</button>
@@ -279,6 +275,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<label className="flex items-center gap-2 text-[12px]">
<input
type="checkbox"
className="accent-term-amber"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
@@ -304,7 +301,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
type="button"
onClick={handleReprintReceipt}
disabled={reprinting}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text disabled:opacity-50"
className="btn btn-sm"
>
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
</button>
@@ -312,7 +309,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
className="btn btn-primary btn-sm"
>
{t("common.close")}
</button>
@@ -322,7 +319,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
className="btn btn-ghost btn-sm"
>
{t("common.cancel")}
</button>
@@ -333,7 +330,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
@@ -342,7 +339,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
+2 -5
View File
@@ -111,12 +111,9 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
onChange={(e) => setValue(e.target.value)}
placeholder={t("booth.scanPlaceholder")}
inputMode="numeric"
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
className="input h-11 flex-1 px-3 text-lg tabular-nums"
/>
<button
type="submit"
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
>
<button type="submit" className="btn btn-primary btn-lg">
{t("booth.open")}
</button>
</form>
+23 -29
View File
@@ -23,37 +23,31 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
}
return (
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
<h1>{t("auth.title")}</h1>
<form onSubmit={submit}>
<div style={{ margin: "0.5rem 0" }}>
<label>
{t("auth.username")}
<br />
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
style={{ width: "100%" }}
/>
</label>
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
<form onSubmit={submit} className="card w-full max-w-sm p-6">
<h1 className="mb-5 text-h5 font-semibold uppercase tracking-widest text-term-amber">{t("auth.title")}</h1>
<div className="field mb-3">
<label className="label">{t("auth.username")}</label>
<input
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</div>
<div style={{ margin: "0.5rem 0" }}>
<label>
{t("auth.password")}
<br />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
style={{ width: "100%" }}
/>
</label>
<div className="field mb-3">
<label className="label">{t("auth.password")}</label>
<input
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<button type="submit" disabled={busy || !username || !password}>
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
{busy ? t("auth.signingIn") : t("auth.signIn")}
</button>
</form>
+44 -42
View File
@@ -85,76 +85,78 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
}
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>{t("shift.label")}</strong>{" "}
{startedAt ? (
<>
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
{new Date(startedAt).toLocaleString()}{" "}
<button type="button" onClick={end} disabled={busy}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</>
) : (
<>
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
<button type="button" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")}
</button>
</>
)}
<section className="card mt-6 max-w-md p-4">
<div className="flex flex-wrap items-center gap-2 text-[13px]">
<strong className="uppercase tracking-wider text-term-muted">{t("shift.label")}</strong>
{startedAt ? (
<>
<span className="font-semibold text-term-green">{t("shift.open")}</span>
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</>
) : (
<>
<span className="text-term-muted">{t("shift.notStarted")}</span>
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")}
</button>
</>
)}
</div>
{/* Live drawer balance (what's in the till right now / inherited). */}
{drawerMinor != null && (
<div style={{ marginTop: "0.5rem", color: "#555" }}>
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
<div className="mt-2 text-[12px] text-term-text">
{t("shift.drawer")} <strong className="tabular-nums">{money(drawerMinor, currency)}</strong>
{startedAt && <span className="text-term-muted"> {t("shift.openingFloatInherited")}</span>}
</div>
)}
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
{isAdmin && (
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
<div className="mt-4 border-t border-term-border pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
{t("shift.drawerCashAdmin")}
</div>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<div className="flex flex-wrap items-center gap-2">
<input
className="input w-28"
value={moveAmount}
onChange={(e) => setMoveAmount(e.target.value)}
placeholder={t("shift.amount")}
inputMode="decimal"
style={{ width: 90 }}
/>
<input
className="input min-w-36 flex-1"
value={moveReason}
onChange={(e) => setMoveReason(e.target.value)}
placeholder={t("shift.reasonPlaceholder")}
style={{ flex: 1, minWidth: 140 }}
/>
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
</div>
)}
{report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
<div>{t("shift.payments")} {report.paymentCount}</div>
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}>
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
<div className="text-term-text">{t("shift.payments")} {report.paymentCount}</div>
<div className="text-term-text">{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
<div className="text-term-text">{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div className="text-term-text">{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div className="font-semibold text-term-text">
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
<div className={report.printed ? "mt-1 text-term-green" : "mt-1 text-term-amber"}>
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
</div>
</div>
+36 -29
View File
@@ -62,44 +62,50 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
}
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>{t("site.occupancy")}</strong>{" "}
{occ == null ? (
"…"
) : (
<>
<span style={{ fontWeight: 600 }}>{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
{occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
)}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</>
)}
<section className="card mt-6 max-w-md p-4">
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? (
<span className="text-term-muted">…</span>
) : (
<>
<span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
<span className="tabular-nums text-term-muted">
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
</span>
{occ.capacity != null && (
<span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
)}
{occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
<button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
</>
)}
</div>
{canEdit && (
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label>
{t("site.capacityLabel")}{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<div className="mt-4 grid gap-3">
<div className="field">
<span className="label">{t("site.capacityLabel")}</span>
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
</div>
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)}
/>
{t("site.printExitDefault")}
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
<span className="hint">{t("site.printExitHint")}</span>
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{t(labelKey)}
<div key={key} className="field">
<span className="label">{t(labelKey)}</span>
{multiline ? (
<textarea
className="textarea"
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
@@ -107,16 +113,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
/>
) : (
<input
className="input"
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={phKey ? t(phKey) : undefined}
/>
)}
</label>
</div>
))}
<div>
<button type="button" onClick={save}>{t("site.save")}</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
</div>
</div>
)}
+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>
</>
+161
View File
@@ -194,3 +194,164 @@ body {
outline: 1px solid var(--color-term-amber);
outline-offset: 1px;
}
/* ============================================================
COMPONENT LAYER
The TRM tokens are good, but every screen hand-rolled inputs and
buttons as bare outlines on near-black panels, so a field, a card,
and a button were visually indistinguishable. These classes give
each control a real identity:
- inputs read as RECESSED slots (lighter fill + inset shadow)
- the primary button is FILLED (accent body, dark text) — the
one obvious action; neutrals are filled grey, not bare outlines
- explicit hover / active / focus / disabled states everywhere
Use @apply so the classes compose with Tailwind utilities.
============================================================ */
@layer components {
/* ---- Form fields: a recessed slot, clearly an input ---- */
.input,
.select,
.textarea {
@apply w-full rounded-term border bg-term-bg px-2.5 text-term-text
placeholder:text-term-muted;
border-color: #3a414c; /* lighter than panel borders */
height: var(--control-h-md);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);
transition: border-color 120ms var(--ease-snap), box-shadow 120ms var(--ease-snap);
}
.textarea {
height: auto;
@apply py-2 leading-snug;
}
.input::placeholder,
.textarea::placeholder {
@apply text-term-muted;
}
.input:hover,
.select:hover,
.textarea:hover {
border-color: #4a525f;
}
.input:focus,
.select:focus,
.textarea:focus {
outline: none;
border-color: var(--color-term-amber);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--color-term-amber);
}
.input:disabled,
.select:disabled,
.textarea:disabled {
@apply cursor-not-allowed opacity-50;
}
/* Small / dense variant for inline table cells */
.input-sm {
height: var(--control-h-sm);
@apply px-2 text-[12px];
}
.field {
@apply flex flex-col gap-1;
}
.label {
@apply text-[11px] uppercase tracking-wider text-term-muted;
}
.hint {
@apply text-[11px] leading-snug text-term-muted;
}
/* ---- Buttons: a button must look pressable, never like a field ---- */
.btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-term border
px-3 text-[12px] font-semibold uppercase tracking-wider
transition-colors select-none;
height: var(--control-h-md);
/* Neutral default: a filled grey body, not a bare outline. */
background: var(--color-term-panel-2);
border-color: #3a414c;
color: var(--color-term-text);
}
.btn:hover:not(:disabled) {
background: #2a313b;
border-color: #4a525f;
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
@apply cursor-not-allowed opacity-40;
}
.btn-sm {
height: var(--control-h-sm);
@apply px-2.5 text-[11px];
}
.btn-lg {
height: var(--control-h-lg);
@apply px-5 text-[13px];
}
/* Primary: FILLED amber, dark text — the unmistakable main action. */
.btn-primary {
background: var(--color-term-amber);
border-color: var(--color-term-amber);
color: #0b0d10;
}
.btn-primary:hover:not(:disabled) {
background: #ffb733;
border-color: #ffb733;
}
/* Semantic filled variants (entry / payment / destructive). */
.btn-go {
background: var(--color-term-green);
border-color: var(--color-term-green);
color: #f2f2ee;
}
.btn-go:hover:not(:disabled) {
background: #38a85a;
border-color: #38a85a;
}
.btn-pay {
background: var(--color-term-cyan);
border-color: var(--color-term-cyan);
color: #f2f2ee;
}
.btn-pay:hover:not(:disabled) {
background: #2f74e0;
border-color: #2f74e0;
}
.btn-danger {
background: transparent;
border-color: var(--color-term-red);
color: var(--color-term-red);
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-term-red) 14%, transparent);
}
/* Ghost: lowest-emphasis (cancel, secondary nav) — text + hover only. */
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--color-term-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-term-panel-2);
color: var(--color-term-text);
border-color: transparent;
}
/* ---- Card: a panel that is clearly a container, not a field ---- */
.card {
@apply rounded-term border border-term-border bg-term-panel;
}
.card-head {
@apply flex items-center justify-between border-b border-term-border
bg-term-panel-2 px-4 py-2 text-[12px] uppercase tracking-wider text-term-muted;
}
.card-body {
@apply p-4;
}
}
/* ============================================================