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:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user