23d6379be8
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.
- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
validation dependsOn parking), parseEntitledModules / resolveModuleActivation
/ effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
from site_config, effective set, requireModule preHandler → 403
module_disabled); modules/index.ts registers folder-based modules by
iterating the registry (modules/validation); site-config GET exposes
modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
enforces entitlement + dependency rules (400 with reason) and signs one
config_change per module that actually flips; /api/auth/me carries the
effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
WEB_MODULES into nav + route tree (validate route no longer named there);
Setup → Site "Modules" panel (required shown disabled, dependencies as
hints, server refusal shown verbatim); validation sections + programs fetch
gated on the module; App invalidates the router whenever the session
changes (route-context consumers only re-read on navigation — the nav was
stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
round-trip on /setup/site verified live.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useRouteContext } from "@tanstack/react-router";
|
|
import {
|
|
fetchMe,
|
|
fetchOccupancy,
|
|
fetchSiteConfig,
|
|
fetchValidationPrograms,
|
|
saveSiteConfig,
|
|
saveValidationProgram,
|
|
type Occupancy,
|
|
type SiteConfig,
|
|
type ValidationProgramView,
|
|
} from "./api.js";
|
|
import { STATIONS, ValidationStationsPanel, defaultProgram, stationLabelKey, type StationId } from "./ValidationSetup.js";
|
|
import { MODULES, type ModuleId } from "@parking/shared";
|
|
import type { RouterContext } from "./router.js";
|
|
|
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
|
|
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
|
// (resolved at render); only `address` is multiline.
|
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
|
{ key: "email", labelKey: "site.fieldEmail" },
|
|
];
|
|
|
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
|
const [capInput, setCapInput] = useState("");
|
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
|
const [reserveSubs, setReserveSubs] = useState(false);
|
|
const [anprEntry, setAnprEntry] = useState(true);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
|
// Venue modules: what this site is entitled to (vendor-set), what the admin has
|
|
// activated, and the effective set. Toggling persists at once (the server signs a
|
|
// config_change per module that flips and validates dependencies). See
|
|
// wiki/decisions/venue-modules.md.
|
|
const [mods, setMods] = useState<{ entitled: ModuleId[]; activated: ModuleId[]; effective: ModuleId[] } | null>(null);
|
|
const [modMsg, setModMsg] = useState<string | null>(null);
|
|
const moduleOn = (id: ModuleId) => mods?.effective.includes(id) ?? false;
|
|
// The header nav gates module entries on the SESSION's module set (/api/auth/me),
|
|
// so a flip here must refresh the session too or the nav stays stale until reload
|
|
// (App re-validates the router whenever `user` changes).
|
|
const { setUser } = useRouteContext({ strict: false }) as RouterContext;
|
|
|
|
function reload() {
|
|
fetchOccupancy().then(setOcc).catch(() => {});
|
|
}
|
|
/** The validation programs are a module route — only ask for them while the
|
|
* module is effective (the server 403s otherwise, which would land in app_logs
|
|
* as a failed request every time an admin opens this page). */
|
|
function loadPrograms(effective: ModuleId[]) {
|
|
if (!canEdit || !effective.includes("validation")) {
|
|
setPrograms([]);
|
|
return;
|
|
}
|
|
fetchValidationPrograms()
|
|
.then((r) => setPrograms(r.programs))
|
|
.catch(() => {});
|
|
}
|
|
useEffect(() => {
|
|
reload();
|
|
fetchSiteConfig()
|
|
.then((c) => {
|
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
|
setExitVoucherDefault(c.exitVoucherDefault);
|
|
setReserveSubs(c.reserveSubscriberSpots);
|
|
setAnprEntry(c.anprEntryEnabled);
|
|
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
|
|
loadPrograms(c.modules);
|
|
const m: Record<string, string> = {};
|
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
|
setMeta(m);
|
|
})
|
|
.catch(() => {});
|
|
}, [canEdit]);
|
|
|
|
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
|
* config_change server-side), creating the well-known row with comp defaults on
|
|
* the first enable. Config details are edited in the right-column panel. */
|
|
async function toggleStation(id: StationId, active: boolean) {
|
|
const existing = programs.find((p) => p.id === id);
|
|
const body = existing
|
|
? { ...existing, active }
|
|
: { ...defaultProgram(id, t(stationLabelKey(id))), active };
|
|
try {
|
|
const saved = await saveValidationProgram(id, body);
|
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
/** Flip a module: send the full desired activation set; the server decides
|
|
* (required always on, must be entitled, dependencies) and echoes the result. */
|
|
async function toggleModule(id: ModuleId, on: boolean) {
|
|
if (!mods) return;
|
|
setModMsg(null);
|
|
const next = on ? [...new Set([...mods.activated, id])] : mods.activated.filter((m) => m !== id);
|
|
try {
|
|
const c = await saveSiteConfig({ modules: next });
|
|
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
|
|
loadPrograms(c.modules);
|
|
const me = await fetchMe();
|
|
if (me) setUser(me);
|
|
} catch (e) {
|
|
setModMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
setMsg(null);
|
|
const raw = capInput.trim();
|
|
const patch: Partial<SiteConfig> = {
|
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
|
exitVoucherDefault,
|
|
reserveSubscriberSpots: reserveSubs,
|
|
anprEntryEnabled: anprEntry,
|
|
};
|
|
// Send each metadata field; "" → null is applied server-side.
|
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
|
try {
|
|
await saveSiteConfig(patch);
|
|
reload();
|
|
setMsg(t("site.saved"));
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
|
<section className="card w-full max-w-md p-4">
|
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
|
<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 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-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={exitVoucherDefault}
|
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
|
/>
|
|
{t("site.printExitDefault")}
|
|
<span className="hint">{t("site.printExitHint")}</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={reserveSubs}
|
|
onChange={(e) => setReserveSubs(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.reserveSubs")}
|
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={anprEntry}
|
|
onChange={(e) => setAnprEntry(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.anprEntry")}
|
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
|
</span>
|
|
</label>
|
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("modules.sectionTitle")}
|
|
</div>
|
|
<span className="hint -mt-2">{t("modules.sectionHint")}</span>
|
|
<div className="grid gap-1.5">
|
|
{MODULES.filter((m) => mods?.entitled.includes(m.id)).map((m) => (
|
|
<label key={m.id} className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={moduleOn(m.id)}
|
|
disabled={m.required || !mods}
|
|
onChange={(e) => toggleModule(m.id, e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t(`modules.name.${m.id}`)}
|
|
{m.required && <span className="hint block">{t("modules.required")}</span>}
|
|
{m.dependsOn.length > 0 && (
|
|
<span className="hint block">
|
|
{t("modules.requires", { deps: m.dependsOn.map((d) => t(`modules.name.${d}`)).join(", ") })}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
{modMsg && <span className="text-[0.75rem] text-term-red">{modMsg}</span>}
|
|
</div>
|
|
{moduleOn("validation") && (
|
|
<>
|
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("val.sectionTitle")}
|
|
</div>
|
|
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
|
<div className="flex gap-6">
|
|
{STATIONS.map((id) => (
|
|
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={programs.find((p) => p.id === id)?.active ?? false}
|
|
onChange={(e) => toggleStation(id, e.target.checked)}
|
|
/>
|
|
{t(stationLabelKey(id))}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("site.parkDetails")}
|
|
</div>
|
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
|
<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}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
) : (
|
|
<input
|
|
className="input"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<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-[0.75rem] text-term-muted">{msg}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
{canEdit && moduleOn("validation") && (
|
|
<ValidationStationsPanel
|
|
programs={programs}
|
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|