b8ddda86e7
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
435 lines
20 KiB
TypeScript
435 lines
20 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ApiError,
|
||
armCapture,
|
||
cancelCapture,
|
||
createSubscription,
|
||
deleteSubscription,
|
||
fetchReaders,
|
||
fetchSiteConfig,
|
||
fetchSubscriptions,
|
||
pollCapture,
|
||
printSubscription,
|
||
revokeSubscription,
|
||
updateSubscription,
|
||
type ReaderInfo,
|
||
type Subscription,
|
||
type SubscriptionCredential,
|
||
type SubscriptionInput,
|
||
} from "./api.js";
|
||
|
||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
|
||
// subscription is mutable master data; every USE of it is a signed ledger event
|
||
// elsewhere. See wiki/entities/subscription.md.
|
||
|
||
const DEFAULT_CURRENCY = "ALL";
|
||
|
||
interface FormState {
|
||
holderName: string;
|
||
contact: string;
|
||
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
||
currency: string;
|
||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||
maxConcurrent: string;
|
||
validFrom: string;
|
||
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
|
||
validTo: string;
|
||
credentials: SubscriptionCredential[];
|
||
platesText: string; // comma/space separated
|
||
}
|
||
|
||
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
|
||
function todayISODate(): string {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
|
||
return {
|
||
holderName: "",
|
||
contact: "",
|
||
priceMajor: defaultPriceMajor,
|
||
currency,
|
||
carBound: true,
|
||
maxConcurrent: "1",
|
||
validFrom: todayISODate(),
|
||
months: "1",
|
||
validTo: "",
|
||
credentials: [{ kind: "qr", value: "" }],
|
||
platesText: "",
|
||
};
|
||
}
|
||
function formFrom(s: Subscription): FormState {
|
||
return {
|
||
holderName: s.holderName ?? "",
|
||
contact: s.contact ?? "",
|
||
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
||
currency: s.currency ?? DEFAULT_CURRENCY,
|
||
carBound: s.maxConcurrent != null,
|
||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||
validFrom: s.validFrom ?? "",
|
||
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
|
||
validTo: s.validTo ?? "",
|
||
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
|
||
platesText: s.plates.join(", "),
|
||
};
|
||
}
|
||
|
||
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
|
||
* server's addMonths so the form can preview the coverage end. */
|
||
function addMonthsDate(date: string, months: number): string | null {
|
||
const d = new Date(`${date}T00:00:00Z`);
|
||
if (Number.isNaN(d.getTime())) return null;
|
||
const day = d.getUTCDate();
|
||
d.setUTCMonth(d.getUTCMonth() + months);
|
||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
const STATUS_KEY: Record<Subscription["status"], string> = {
|
||
active: "subs.statusActive",
|
||
suspended: "subs.statusSuspended",
|
||
revoked: "subs.statusRevoked",
|
||
};
|
||
|
||
function toInput(f: FormState): SubscriptionInput {
|
||
const major = Number(f.priceMajor);
|
||
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
|
||
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
|
||
return {
|
||
holderName: f.holderName.trim() || null,
|
||
contact: f.contact.trim() || null,
|
||
priceMinor: priceSet ? Math.round(major * 100) : null,
|
||
period: "monthly",
|
||
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||
validFrom: f.validFrom.trim() || null,
|
||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
||
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
|
||
validTo: f.validTo.trim() || null,
|
||
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
|
||
// server auto-generates the code. RF (and pre-existing QR) keep their value.
|
||
credentials: f.credentials
|
||
.filter((c) => c.kind === "qr" || c.value.trim())
|
||
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
|
||
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
|
||
};
|
||
}
|
||
|
||
function priceLabel(s: Subscription, t: (k: string) => string): string {
|
||
if (s.priceMinor == null) return t("subs.noPrice");
|
||
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
|
||
}
|
||
|
||
export function SubscriptionManager() {
|
||
const { t } = useTranslation();
|
||
const [subs, setSubs] = useState<Subscription[] | null>(null);
|
||
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
|
||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||
// Credential capture ("Read card"): which credential index is being captured, the
|
||
// reader picker list, and a live status line. null = no capture in progress.
|
||
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
|
||
const [readers, setReaders] = useState<ReaderInfo[]>([]);
|
||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
function reload() {
|
||
fetchSubscriptions()
|
||
.then((r) => setSubs(r.subscriptions))
|
||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||
}
|
||
useEffect(() => {
|
||
reload();
|
||
// Pull the site default monthly price to pre-fill new subscriptions.
|
||
fetchSiteConfig()
|
||
.then((c) => {
|
||
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
|
||
})
|
||
.catch(() => {
|
||
/* non-fatal — the form just won't pre-fill */
|
||
});
|
||
}, []);
|
||
|
||
function startNew() {
|
||
setForm(emptyForm(defaultPriceMajor));
|
||
setEditing("new");
|
||
setMsg(null);
|
||
}
|
||
function startEdit(s: Subscription) {
|
||
setForm(formFrom(s));
|
||
setEditing(s.id);
|
||
setMsg(null);
|
||
}
|
||
|
||
async function save() {
|
||
setMsg(null);
|
||
try {
|
||
if (editing === "new") {
|
||
const created = await createSubscription(toInput(form));
|
||
setEditing(null);
|
||
reload();
|
||
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
||
// operator can use "Print code" to retry).
|
||
if (created.printed) {
|
||
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
|
||
} else if (created.printError) {
|
||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
|
||
} else {
|
||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||
}
|
||
return;
|
||
}
|
||
if (editing) await updateSubscription(editing, toInput(form));
|
||
setEditing(null);
|
||
reload();
|
||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||
} catch (e) {
|
||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||
}
|
||
}
|
||
async function doPrint(s: Subscription) {
|
||
setMsg(null);
|
||
try {
|
||
const r = await printSubscription(s.id);
|
||
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
|
||
} catch (e) {
|
||
setMsg({ kind: "err", text: (e as Error).message });
|
||
}
|
||
}
|
||
async function doRevoke(s: Subscription) {
|
||
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
|
||
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||
reload();
|
||
}
|
||
async function doDelete(s: Subscription) {
|
||
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
|
||
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||
reload();
|
||
}
|
||
|
||
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
|
||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||
}
|
||
|
||
function clearPoll() {
|
||
if (pollRef.current) {
|
||
clearInterval(pollRef.current);
|
||
pollRef.current = null;
|
||
}
|
||
}
|
||
// Stop a capture in progress (cancel on the server + clear local state).
|
||
function stopCapture() {
|
||
clearPoll();
|
||
void cancelCapture().catch(() => {});
|
||
setCapture(null);
|
||
}
|
||
// "Read card" on credential i → load readers + show the picker.
|
||
async function startCapture(i: number) {
|
||
setMsg(null);
|
||
try {
|
||
const r = await fetchReaders();
|
||
setReaders(r.readers);
|
||
setCapture({ credIndex: i, phase: "pick" });
|
||
} catch (e) {
|
||
setMsg({ kind: "err", text: (e as Error).message });
|
||
}
|
||
}
|
||
// Operator picked a reader → arm it and poll until captured / expired.
|
||
async function pickReader(deviceId: string) {
|
||
const cap = capture;
|
||
if (!cap) return;
|
||
try {
|
||
await armCapture(deviceId);
|
||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
|
||
clearPoll();
|
||
pollRef.current = setInterval(async () => {
|
||
try {
|
||
const st = await pollCapture();
|
||
if (st.status === "captured") {
|
||
clearPoll();
|
||
setCred(cap.credIndex, { value: st.value });
|
||
void cancelCapture().catch(() => {}); // clear the server-side result
|
||
setCapture(null);
|
||
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
|
||
} else if (st.status === "expired" || st.status === "idle") {
|
||
clearPoll();
|
||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
|
||
}
|
||
} catch {
|
||
/* transient poll error — keep polling */
|
||
}
|
||
}, 700);
|
||
} catch (e) {
|
||
setMsg({ kind: "err", text: (e as Error).message });
|
||
setCapture(null);
|
||
}
|
||
}
|
||
// Stop polling if the form closes or the component unmounts.
|
||
useEffect(() => clearPoll, []);
|
||
|
||
// Live coverage preview: when months + validFrom are set, show the end date and
|
||
// (if priced) the N×monthly total the operator should collect.
|
||
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
|
||
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
|
||
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
|
||
const totalDue =
|
||
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
|
||
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
|
||
: null;
|
||
const coverageHint = coverageEnd
|
||
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
|
||
: null;
|
||
|
||
if (!subs) return null;
|
||
|
||
return (
|
||
<section style={{ marginTop: "2rem" }}>
|
||
<h2>{t("subs.title")}</h2>
|
||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||
{subs.map((s) => (
|
||
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
|
||
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
|
||
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
|
||
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
|
||
<span style={{ color: "#666" }}>
|
||
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
|
||
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
|
||
</span>
|
||
<span style={{ flex: 1 }} />
|
||
{/* Print code — only when the subscription has a QR credential to encode. */}
|
||
{s.credentials.some((c) => c.kind === "qr") && (
|
||
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
|
||
)}
|
||
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
|
||
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
|
||
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
|
||
</li>
|
||
))}
|
||
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
|
||
</ul>
|
||
|
||
{editing == null ? (
|
||
<button type="button" onClick={startNew}>{t("subs.add")}</button>
|
||
) : (
|
||
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
|
||
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
|
||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
|
||
<label>{t("subs.holderName")}</label>
|
||
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||
<label>{t("subs.contact")}</label>
|
||
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||
<label>{t("subs.monthlyPrice")}</label>
|
||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||
<input
|
||
value={form.priceMajor}
|
||
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
|
||
inputMode="decimal"
|
||
placeholder={t("subs.pricePlaceholder")}
|
||
style={{ width: 110 }}
|
||
/>
|
||
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
|
||
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
|
||
</span>
|
||
<label>{t("subs.carLimit")}</label>
|
||
<span>
|
||
<label style={{ marginRight: "0.5rem" }}>
|
||
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||
</label>
|
||
{form.carBound && (
|
||
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
|
||
)}
|
||
</span>
|
||
<label>{t("subs.validFrom")}</label>
|
||
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||
<label>{t("subs.months")}</label>
|
||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||
<input
|
||
value={form.months}
|
||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
||
inputMode="numeric"
|
||
placeholder="1"
|
||
style={{ width: 50 }}
|
||
/>
|
||
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
|
||
{/* Live preview of the coverage end + the N×price total. */}
|
||
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
|
||
</span>
|
||
<label>{t("subs.validToOverride")}</label>
|
||
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||
<label>{t("subs.boundPlates")}</label>
|
||
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||
</div>
|
||
|
||
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||
{form.credentials.map((c, i) => (
|
||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||
(read off a card via "Read card"). */}
|
||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||
<option value="qr">{t("subs.qr")}</option>
|
||
<option value="rf">{t("subs.rfCardTag")}</option>
|
||
</select>
|
||
{c.kind === "qr" ? (
|
||
// QR codes are server-generated. Blank → "will be generated"; an
|
||
// existing code is shown read-only (it can be printed; never typed).
|
||
c.value.trim() ? (
|
||
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
|
||
) : (
|
||
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
|
||
)
|
||
) : (
|
||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||
// arms a chosen reader and fills the captured value.
|
||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
|
||
)}
|
||
{c.kind === "rf" && (
|
||
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||
)}
|
||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||
</div>
|
||
))}
|
||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||
|
||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||
the credential. The OTHER reader keeps serving the live flow. */}
|
||
{capture && (
|
||
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
|
||
{capture.phase === "pick" ? (
|
||
<>
|
||
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
|
||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
|
||
{readers.map((r) => (
|
||
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
|
||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||
</button>
|
||
))}
|
||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||
<span>{capture.status ?? t("subs.captureWaiting")}</span>
|
||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||
{t("subs.needCredentialOrPlate")}
|
||
</p>
|
||
|
||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||
<button type="button" onClick={save}>{t("subs.save")}</button>
|
||
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
|
||
</section>
|
||
);
|
||
}
|