feat(water-log): replace alert() report preview with 'Field Bulletin' modal
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s

- New ReportPreviewModal styled as a USGS/SCS field bulletin printout:
  forest header strip, perforated tear-strip, crop marks, Fraunces
  masthead, rotated PREVIEW rubber stamp, hairline-ruled data table
  with stagger-row reveal, first/last entry + top-gate stats, and a
  plain-text 'Copy' action backed by formatDailyWaterReport().
- ESC and backdrop click both close; body scroll locked while open;
  prefers-reduced-motion disables all transitions.
- Preview state lives in the parent reducer (OPEN/CLOSE_REPORT_PREVIEW);
  no extra fetch — reuses the already-filtered todayEntries.
This commit is contained in:
Tyler
2026-07-02 12:08:24 -06:00
parent d34fc2434a
commit 060520b7be
+787 -30
View File
@@ -22,7 +22,7 @@
* `src/actions/water-log/admin.ts`.
*/
import { useReducer, useEffect, useMemo } from "react";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -68,6 +68,17 @@ type UserForm = {
};
type PinInfo = { name: string; pin: string };
type ReportPreviewData = {
rows: WaterLogReportRow[];
total: number;
unit: string;
dateLabel: string;
dateIso: string;
recipientName?: string;
inSeason: boolean;
bulletinNumber: number;
};
type State = {
headgates: AdminHeadgate[];
users: AdminIrrigator[];
@@ -96,6 +107,7 @@ type State = {
showReportSettings: boolean;
seasonStart: IrrigationSeasonSettings;
recipientName: string;
reportPreview: ReportPreviewData | null;
};
type Action =
@@ -140,7 +152,9 @@ type Action =
| { type: "SET_TODAY"; today: string; label: string }
| { type: "TOGGLE_REPORT_SETTINGS" }
| { type: "SET_SEASON_START"; value: IrrigationSeasonSettings }
| { type: "SET_RECIPIENT_NAME"; value: string };
| { type: "SET_RECIPIENT_NAME"; value: string }
| { type: "OPEN_REPORT_PREVIEW"; preview: ReportPreviewData }
| { type: "CLOSE_REPORT_PREVIEW" };
function initSeasonStart(): IrrigationSeasonSettings {
if (typeof window === "undefined") return DEFAULT_SEASON;
@@ -184,6 +198,7 @@ function initState(props: {
showReportSettings: false,
seasonStart: initSeasonStart(),
recipientName: initRecipientName(),
reportPreview: null,
};
}
@@ -358,6 +373,10 @@ function reducer(state: State, action: Action): State {
return { ...state, seasonStart: action.value };
case "SET_RECIPIENT_NAME":
return { ...state, recipientName: action.value };
case "OPEN_REPORT_PREVIEW":
return { ...state, reportPreview: action.preview };
case "CLOSE_REPORT_PREVIEW":
return { ...state, reportPreview: null };
}
}
@@ -466,14 +485,54 @@ export default function WaterLogAdminPanel({
} catch {}
}
function openReportPreview() {
// The "Today's Summary" section has already filtered the rows for us;
// shape them for the report view (adds user_role default) and push
// the preview payload into state. The modal renders from that payload.
const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry);
const unit =
rows.find((r) => r.unit)?.unit ?? state.headgates[0]?.unit ?? "CFS";
const now = new Date();
// Deterministic-ish bulletin number from today's date + entry count so
// it changes per-day but feels like a real reference code.
const dayKey =
state.today.replace(/-/g, "") ||
`${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const bulletinNumber =
(parseInt(dayKey, 10) % 1000) + (state.entries.length % 7) * 13 + 1;
if (rows.length === 0) {
notify(
`No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`,
);
return;
}
dispatch({
type: "OPEN_REPORT_PREVIEW",
preview: {
rows,
total: todayTotal,
unit,
dateLabel: state.todayLabel || formatDate(now),
dateIso: state.today || now.toISOString().slice(0, 10),
recipientName: state.recipientName.trim() || undefined,
inSeason,
bulletinNumber,
},
});
}
function closeReportPreview() {
dispatch({ type: "CLOSE_REPORT_PREVIEW" });
}
// ── Render ────────────────────────────────────────────────────────
return (
<div className="relative">
<HeaderNav />
<TodaySummarySection
brandId={brandId}
today={state.today}
todayLabel={state.todayLabel}
todayEntries={todayEntries}
todayTotal={todayTotal}
@@ -483,10 +542,10 @@ export default function WaterLogAdminPanel({
showReportSettings={state.showReportSettings}
seasonStart={state.seasonStart}
recipientName={state.recipientName}
notify={notify}
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
onSeasonChange={persistSeason}
onRecipientNameChange={persistRecipientName}
onPreviewReport={openReportPreview}
/>
<HeadgatesSection
@@ -530,6 +589,12 @@ export default function WaterLogAdminPanel({
/>
<ToastNotification toast={state.toast} />
<ReportPreviewModal
data={state.reportPreview}
onClose={closeReportPreview}
notify={notify}
/>
</div>
);
}
@@ -559,8 +624,6 @@ function HeaderNav() {
}
type TodaySummarySectionProps = {
brandId: string;
today: string;
todayLabel: string;
todayEntries: AdminEntry[];
todayTotal: number;
@@ -570,15 +633,13 @@ type TodaySummarySectionProps = {
showReportSettings: boolean;
seasonStart: IrrigationSeasonSettings;
recipientName: string;
notify: (msg: string, kind?: "ok" | "err") => void;
onToggleReportSettings: () => void;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
onRecipientNameChange: (v: string) => void;
onPreviewReport: () => void;
};
function TodaySummarySection({
brandId,
today,
todayLabel,
todayEntries,
todayTotal,
@@ -588,30 +649,13 @@ function TodaySummarySection({
showReportSettings,
seasonStart,
recipientName,
notify,
onToggleReportSettings,
onSeasonChange,
onRecipientNameChange,
onPreviewReport,
}: TodaySummarySectionProps) {
async function handlePreviewReport() {
const all = await getWaterEntries(brandId, 1000);
const todayRows: WaterLogReportRow[] = [];
for (const e of all) {
if ((e.logged_date ?? e.logged_at.slice(0, 10)) === today) {
todayRows.push(shapeWaterLogEntry(e));
}
}
const report = formatDailyWaterReport(todayRows, {
recipientName: recipientName || undefined,
previewMode: true,
});
if (!report) {
notify(`No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`);
return;
}
// The browser's `alert` blocks; the modal preview is the better UX in
// a future iteration, but alert is dependency-free today.
window.alert(`Daily Report — ${inSeason ? "in season" : "out of season"}\n\n${report}`);
function handlePreviewReport() {
onPreviewReport();
}
return (
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
@@ -1786,6 +1830,719 @@ function DayInput({
);
}
// ─── Report Preview Modal ─────────────────────────────────────────────────
//
// A "Field Bulletin" — modeled after USDA Soil Conservation Service / USGS
// survey bulletins. Cream paper, deep forest ink, monospaced data with
// hairline rules, perforated tear-strip on the left, and a rotated
// PREVIEW rubber stamp to keep it clearly distinct from the actual
// deliverable. The composition is a single tactile sheet that opens with
// a soft paper-curl motion and staggers each entry row in.
function ReportPreviewModal({
data,
onClose,
notify,
}: {
data: ReportPreviewData | null;
onClose: () => void;
notify: (msg: string, kind?: "ok" | "err") => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
// ESC closes; lock body scroll while open.
useEffect(() => {
if (!data) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Move focus into the dialog for keyboard users.
const t = setTimeout(() => dialogRef.current?.focus(), 30);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
clearTimeout(t);
};
}, [data, onClose]);
if (!data) return null;
const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason, bulletinNumber } = data;
const totalUnit = rows[0]?.unit ?? unit ?? "CFS";
async function handleCopy() {
const text = formatDailyWaterReport(rows, {
recipientName: recipientName,
previewMode: true,
});
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
notify("Report copied to clipboard");
setTimeout(() => setCopied(false), 1600);
} catch {
notify("Copy failed — your browser blocked clipboard access", "err");
}
}
// Earliest entry time → "shift open", latest → "shift close" — a nice
// touch pulled straight from irrigation logbook conventions.
const times = rows
.map((r) => new Date(r.logged_at))
.filter((d) => !Number.isNaN(d.getTime()));
const firstTime = times.length
? times.reduce((a, b) => (a < b ? a : b))
: null;
const lastTime = times.length
? times.reduce((a, b) => (a > b ? a : b))
: null;
const fmtTime = (d: Date | null) =>
d
? d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})
: "—";
// Most active headgate (max sum of measurement) — useful color piece.
const byHeadgate = new Map<string, number>();
for (const r of rows) {
byHeadgate.set(r.headgate_name, (byHeadgate.get(r.headgate_name) ?? 0) + r.measurement);
}
const topHeadgate = Array.from(byHeadgate.entries()).sort((a, b) => b[1] - a[1])[0];
return (
<div
role="dialog"
aria-modal="true"
aria-label="Daily water report preview"
className="wl-modal-backdrop fixed inset-0 z-50 flex items-start justify-center overflow-y-auto px-4 py-8 sm:py-14"
onMouseDown={(e) => {
// Click on the backdrop (not the paper) closes.
if (e.target === e.currentTarget) onClose();
}}
>
{/* Backdrop wash — deep forest tint with subtle radial vignette. */}
<div
aria-hidden
className="wl-modal-backdrop__wash fixed inset-0"
style={{
background:
"radial-gradient(120% 80% at 50% 30%, rgba(26,77,46,0.42) 0%, rgba(15,42,28,0.72) 60%, rgba(8,28,18,0.82) 100%)",
backdropFilter: "blur(3px) saturate(0.9)",
WebkitBackdropFilter: "blur(3px) saturate(0.9)",
}}
/>
{/* The paper itself */}
<div
ref={dialogRef}
tabIndex={-1}
className="wl-modal-paper relative z-10 w-full max-w-2xl outline-none"
style={{
// soft drop shadow + warm paper gradient
background:
"linear-gradient(180deg, #fdfaf2 0%, #f7f1de 100%)",
boxShadow:
"0 1px 0 rgba(255,255,255,0.6) inset, 0 30px 60px -20px rgba(15,42,28,0.45), 0 18px 32px -22px rgba(15,42,28,0.6)",
border: "1px solid #1a4d2e",
}}
>
{/* Paper grain overlay — extremely subtle */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.10] mix-blend-multiply"
style={{
backgroundImage:
"radial-gradient(circle at 25% 15%, rgba(122,90,38,0.22) 0, transparent 35%), radial-gradient(circle at 80% 80%, rgba(26,77,46,0.18) 0, transparent 40%)",
}}
/>
{/* Perforated left tear-strip — like a printed form */}
<div
aria-hidden
className="wl-modal-perf absolute left-0 top-0 h-full w-3"
style={{
backgroundImage:
"radial-gradient(circle, #fdfaf2 1.2px, transparent 1.6px)",
backgroundSize: "8px 12px",
backgroundPosition: "2px 6px",
opacity: 0.55,
maskImage:
"linear-gradient(180deg, transparent 0, black 6%, black 94%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(180deg, transparent 0, black 6%, black 94%, transparent 100%)",
}}
/>
{/* Crop marks at the four corners — printer's registration marks */}
<CropMark className="absolute left-3 top-3" rotate={0} />
<CropMark className="absolute right-3 top-3" rotate={90} />
<CropMark className="absolute right-3 bottom-3" rotate={180} />
<CropMark className="absolute left-3 bottom-3" rotate={270} />
{/* Header band — forest ink strip with cream mono caps */}
<div
className="relative flex items-stretch border-b border-[#1a4d2e]"
style={{ background: "#1a4d2e", color: "#fdfaf2" }}
>
<div className="flex flex-1 flex-col justify-center gap-0.5 px-6 py-3">
<div
className="text-[10px] uppercase"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.32em",
}}
>
Tuxedo Ditch Co. · Water Log
</div>
<div
className="text-[11px] uppercase opacity-80"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.24em",
}}
>
Daily Field Bulletin
</div>
</div>
<div
className="flex flex-col items-end justify-center border-l border-[#fdfaf2]/30 px-5 py-3 text-right"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.18em",
}}
>
<div className="text-[10px] uppercase opacity-80">Bulletin </div>
<div className="text-[15px] font-medium tracking-[0.18em]">
{String(bulletinNumber).padStart(4, "0")}
</div>
</div>
</div>
{/* Title row + PREVIEW stamp */}
<div className="relative px-6 pb-2 pt-5 sm:px-8 sm:pt-6">
<h2
className="leading-[0.95] tracking-tight text-[#1a4d2e]"
style={{
fontFamily: "var(--font-display, Georgia, serif)",
fontWeight: 600,
fontSize: "clamp(34px, 5.4vw, 48px)",
letterSpacing: "-0.02em",
}}
>
Daily Water Report
</h2>
<p
className="mt-1 text-[12px] uppercase tracking-[0.24em] text-[#57694e]"
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
{dateLabel}
<span className="mx-2 text-[#d4d9d3]">/</span>
<span className="text-[#1a4d2e]">
{dateIso.replace(/-/g, " · ")}
</span>
</p>
{/* Rotated rubber stamp — PREVIEW */}
<div
aria-hidden
className="wl-modal-stamp pointer-events-none absolute right-4 top-4 select-none sm:right-8 sm:top-5"
style={{
transform: "rotate(-9deg)",
color: "#b1452a",
fontFamily: "var(--font-display, Georgia, serif)",
fontWeight: 800,
letterSpacing: "0.18em",
border: "2.5px double currentColor",
padding: "4px 12px 5px",
borderRadius: "3px",
opacity: 0.78,
boxShadow: "0 0 0 2px rgba(177,69,42,0.05)",
fontSize: "16px",
}}
>
PREVIEW
<span
className="ml-2 text-[10px] tracking-[0.3em]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
fontWeight: 500,
opacity: 0.85,
}}
>
ONLY
</span>
</div>
</div>
{/* Meta strip — date / season / total / count */}
<div className="relative grid grid-cols-2 gap-px border-y border-[#1a4d2e] bg-[#1a4d2e] sm:grid-cols-4">
<MetaCell label="Date" value={dateLabel} mono />
<MetaCell
label="Season"
value={inSeason ? "In season" : "Out of season"}
accent={inSeason ? "green" : "amber"}
/>
<MetaCell
label="Total volume"
value={`${total.toFixed(2)} ${totalUnit}`}
mono
emphasis
/>
<MetaCell
label="Entries"
value={`${rows.length} ${rows.length === 1 ? "log" : "logs"}`}
mono
/>
</div>
{/* Salutation */}
<div className="relative px-6 pt-5 sm:px-8">
{recipientName ? (
<p
className="text-[#1a4d2e]"
style={{
fontFamily: "var(--font-display, Georgia, serif)",
fontStyle: "italic",
fontSize: "20px",
lineHeight: 1.25,
}}
>
{recipientName},
</p>
) : (
<p
className="text-[#57694e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
fontSize: "11px",
letterSpacing: "0.22em",
textTransform: "uppercase",
}}
>
To the irrigator
</p>
)}
<p
className="mt-2 text-[13px] leading-relaxed text-[#3a4a36]"
style={{ fontFamily: "var(--font-manrope, system-ui, sans-serif)" }}
>
Below is the day&apos;s record of measured flow at every headgate
in service. Times are local; volumes are reported in the unit
declared for each gate.{" "}
<span className="text-[#a16207]">
This bulletin is a preview no message has been sent.
</span>
</p>
</div>
{/* Data table */}
<div className="relative mt-5 px-6 sm:px-8">
<div className="overflow-hidden rounded-sm border border-[#1a4d2e]/40 bg-[#fffaf0]/60">
<table className="w-full border-collapse">
<thead>
<tr
className="border-b border-[#1a4d2e]/50"
style={{
background:
"linear-gradient(180deg, rgba(26,77,46,0.08) 0%, rgba(26,77,46,0.02) 100%)",
}}
>
<Th className="w-[68px]">Time</Th>
<Th>Headgate</Th>
<Th>Irrigator</Th>
<Th className="w-[86px] text-right">Volume</Th>
<Th className="w-[28%]">Note</Th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const time = new Date(row.logged_at).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
);
const isTop =
topHeadgate &&
row.headgate_name === topHeadgate[0] &&
row.measurement === topHeadgate[1];
return (
<tr
key={i}
className="wl-modal-row border-b border-[#1a4d2e]/15 transition-colors last:border-b-0 hover:bg-[#fdfaf2]"
style={{
animationDelay: `${120 + i * 35}ms`,
}}
>
<Td className="text-[#3a4a36]">{time}</Td>
<Td>
<span
className="inline-flex items-center gap-1.5 text-[#1a4d2e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
}}
>
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full"
style={{
background: isTop ? "#ca8a04" : "#1a4d2e",
opacity: isTop ? 1 : 0.55,
}}
/>
{row.headgate_name}
</span>
</Td>
<Td className="text-[#3a4a36]">{row.user_name}</Td>
<Td className="text-right">
<span
className="text-[#1a4d2e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
}}
>
{row.measurement.toFixed(2)}
</span>
<span
className="ml-1 text-[10px] text-[#57694e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.08em",
}}
>
{row.unit}
</span>
</Td>
<Td className="text-[#57694e]">
{row.notes ? (
<span
className="italic"
style={{
fontFamily:
"var(--font-display, Georgia, serif)",
}}
>
&ldquo;{row.notes}&rdquo;
</span>
) : (
<span className="text-[#d4d9d3]">·</span>
)}
</Td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-[#1a4d2e]">
<Td className="!border-0 pt-2 text-[11px] uppercase tracking-[0.22em] text-[#57694e]">
Day total
</Td>
<Td className="!border-0 pt-2">
<span className="text-[#d4d9d3]">·</span>
</Td>
<Td className="!border-0 pt-2 text-right text-[11px] uppercase tracking-[0.22em] text-[#57694e]">
Σ
</Td>
<Td className="!border-0 pt-2 text-right">
<span
className="text-[#1a4d2e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
fontSize: "16px",
fontWeight: 600,
letterSpacing: "0.02em",
}}
>
{total.toFixed(2)}
</span>
<span
className="ml-1 text-[11px] text-[#57694e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.08em",
}}
>
{totalUnit}
</span>
</Td>
<Td className="!border-0 pt-2 text-right text-[11px] text-[#57694e]">
{rows.length} logs
</Td>
</tr>
</tfoot>
</table>
</div>
</div>
{/* Side stats — first/last entry, top headgate */}
<div className="relative grid grid-cols-3 gap-px border-y border-[#1a4d2e]/40 bg-[#1a4d2e]/10 px-6 sm:px-8 mt-5">
<SideStat label="First entry" value={fmtTime(firstTime)} />
<SideStat label="Last entry" value={fmtTime(lastTime)} />
<SideStat
label="Top gate"
value={topHeadgate ? topHeadgate[0] : "—"}
sub={
topHeadgate ? `${topHeadgate[1].toFixed(2)} ${totalUnit}` : ""
}
/>
</div>
{/* Footer signature */}
<div className="relative px-6 pb-6 pt-4 text-center sm:px-8">
<div className="mx-auto mb-3 h-px w-24 bg-[#1a4d2e]/40" />
<p
className="text-[10px] uppercase tracking-[0.36em] text-[#57694e]"
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
End of transmission
</p>
<p
className="mt-2 text-[11px] text-[#57694e]"
style={{
fontFamily: "var(--font-display, Georgia, serif)",
fontStyle: "italic",
}}
>
Filed by Route Commerce Water Log
</p>
</div>
{/* Action bar — sticky to the bottom of the paper */}
<div
className="relative flex flex-col-reverse items-stretch gap-2 border-t border-[#1a4d2e] px-5 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6"
style={{ background: "rgba(26,77,46,0.04)" }}
>
<button
type="button"
onClick={onClose}
className="rounded-sm border border-[#1a4d2e]/40 px-4 py-2 text-[11px] uppercase tracking-[0.24em] text-[#57694e] transition-colors hover:border-[#1a4d2e] hover:text-[#1a4d2e]"
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
Close · Esc
</button>
<button
type="button"
onClick={handleCopy}
className="group inline-flex items-center justify-center gap-2 rounded-sm border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-[11px] uppercase tracking-[0.24em] text-[#fdfaf2] transition-all hover:bg-[#163f25] active:translate-y-px"
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
{copied ? (
<>
<CheckMark /> Copied
</>
) : (
<>
<CopyMark /> Copy plain text
</>
)}
</button>
</div>
</div>
{/* Component-local animations */}
<style>{`
@keyframes wl-modal-pop {
0% { opacity: 0; transform: translateY(14px) scale(0.97); }
60% { opacity: 1; transform: translateY(-2px) scale(1.005); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes wl-modal-fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes wl-modal-row {
0% { opacity: 0; transform: translateX(-6px); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes wl-modal-stamp {
0% { opacity: 0; transform: rotate(-9deg) scale(1.4); }
60% { opacity: 0.78; transform: rotate(-9deg) scale(0.96); }
100% { opacity: 0.78; transform: rotate(-9deg) scale(1); }
}
.wl-modal-backdrop { animation: wl-modal-fade 200ms ease-out both; }
.wl-modal-paper {
animation: wl-modal-pop 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.wl-modal-row {
animation: wl-modal-row 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
opacity: 0;
}
.wl-modal-stamp { animation: wl-modal-stamp 480ms ease-out 220ms both; }
@media (prefers-reduced-motion: reduce) {
.wl-modal-backdrop,
.wl-modal-paper,
.wl-modal-row,
.wl-modal-stamp {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
`}</style>
</div>
);
}
function MetaCell({
label,
value,
mono = false,
emphasis = false,
accent,
}: {
label: string;
value: string;
mono?: boolean;
emphasis?: boolean;
accent?: "green" | "amber";
}) {
const accentColor =
accent === "green"
? "#1a4d2e"
: accent === "amber"
? "#a16207"
: undefined;
return (
<div className="flex flex-col gap-0.5 bg-[#fdfaf2] px-4 py-2.5">
<span
className="text-[9px] uppercase tracking-[0.28em] text-[#57694e]"
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
{label}
</span>
<span
className={
emphasis
? "text-[16px] text-[#1a4d2e]"
: mono
? "text-[13px] text-[#1a4d2e]"
: "text-[13px] text-[#1a4d2e]"
}
style={{
fontFamily: mono
? "var(--font-fragment-mono, monospace)"
: "var(--font-manrope, system-ui, sans-serif)",
fontWeight: emphasis ? 600 : 500,
letterSpacing: mono ? "0.02em" : undefined,
color: accentColor,
}}
>
{value}
</span>
</div>
);
}
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th
className={`px-3 py-2 text-left text-[10px] uppercase tracking-[0.22em] text-[#1a4d2e] ${className}`}
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
{children}
</th>
);
}
function Td({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<td
className={`px-3 py-2 text-[13px] text-[#1d1d1f] ${className}`}
style={{
fontFamily: "var(--font-manrope, system-ui, sans-serif)",
verticalAlign: "top",
}}
>
{children}
</td>
);
}
function SideStat({
label,
value,
sub,
}: {
label: string;
value: string;
sub?: string;
}) {
return (
<div className="flex flex-col gap-0.5 bg-[#fdfaf2] px-4 py-2.5">
<span
className="text-[9px] uppercase tracking-[0.28em] text-[#57694e]"
style={{ fontFamily: "var(--font-fragment-mono, monospace)" }}
>
{label}
</span>
<span
className="text-[14px] text-[#1a4d2e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.02em",
}}
>
{value}
</span>
{sub ? (
<span
className="text-[10px] text-[#57694e]"
style={{
fontFamily: "var(--font-fragment-mono, monospace)",
letterSpacing: "0.06em",
}}
>
{sub}
</span>
) : null}
</div>
);
}
function CropMark({ className = "", rotate = 0 }: { className?: string; rotate?: number }) {
return (
<svg
aria-hidden
width="10"
height="10"
viewBox="0 0 10 10"
className={className}
style={{ transform: `rotate(${rotate}deg)`, opacity: 0.5 }}
>
<path d="M0 5 L10 5 M5 0 L5 10" stroke="#1a4d2e" strokeWidth="0.6" />
</svg>
);
}
function CopyMark() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4">
<rect x="5" y="5" width="9" height="9" rx="1" />
<path d="M11 5 V3 a1 1 0 0 0 -1 -1 H3 a1 1 0 0 0 -1 1 V10 a1 1 0 0 0 1 1 H5" />
</svg>
);
}
function CheckMark() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M3 8 L7 12 L13 4" />
</svg>
);
}
// ─── Icons (1620px, currentColor) ──────────────────────────────────────
function QrIcon() {