feat(water-log): add Smartsheet setup instructions modal
Deploy to route.crispygoat.com / deploy (push) Successful in 3m54s

Adds a 'Setup instructions' button to the Connection card that opens
a modal with full onboarding guidance:

- Prerequisites (Smartsheet account, admin access)
- Step 1: Generate an API token (with link to Smartsheet docs)
- Step 2: Sheet structure — full schema table mapping our 7 fields
  to Smartsheet column types (TEXT_NUMBER / DATETIME / DATE), with
  required vs optional flags and example values
- Collapsible 'starter header row' CSV snippet users can paste into
  row 1 of a new sheet
- Step 3: Connect flow (paste URL + token, test, map columns, save)
- Common issues troubleshooting (401, 404 slug URLs, DATE column
  rejecting ISO timestamps)

Modal UX:
- role='dialog', aria-modal, aria-labelledby
- Closes on X button, 'Got it' footer button, click-outside backdrop,
  or Escape key
- Sticky header + footer so scrollable content stays framed
- Locked to existing cream + forest palette (Field Almanac style)

Implementation:
- Added 'setupModalOpen' to state, OPEN_SETUP_MODAL/CLOSE_SETUP_MODAL
  actions to reducer
- Extended Card subcomponent with optional 'action' slot (used here
  for the trigger button, reusable for future per-card actions)
- Added useEffect that registers a window keydown listener only
  while the modal is open; cleans up on close or unmount
- FIELD_SCHEMA const declared alongside the modal so future edits to
  the schema live next to their documentation
This commit is contained in:
Nora
2026-07-03 14:57:12 -06:00
parent 12557f947f
commit 242c195265
@@ -95,6 +95,8 @@ type State = {
// Recent activity // Recent activity
recentLog: SmartsheetSyncLogEntry[]; recentLog: SmartsheetSyncLogEntry[];
loadingLog: boolean; loadingLog: boolean;
/** Whether the setup-instructions modal is open. */
setupModalOpen: boolean;
}; };
type Action = type Action =
@@ -114,7 +116,9 @@ type Action =
| { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] } | { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] }
| { type: "TEST_FAIL"; message: string } | { type: "TEST_FAIL"; message: string }
| { type: "CLEAR_MESSAGE" } | { type: "CLEAR_MESSAGE" }
| { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] }; | { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] }
| { type: "OPEN_SETUP_MODAL" }
| { type: "CLOSE_SETUP_MODAL" };
const EMPTY_MAPPING: SmartsheetColumnMapping = { const EMPTY_MAPPING: SmartsheetColumnMapping = {
entry_id: "", entry_id: "",
@@ -141,6 +145,7 @@ const initialState: State = {
test: { state: "idle" }, test: { state: "idle" },
recentLog: [], recentLog: [],
loadingLog: false, loadingLog: false,
setupModalOpen: false,
}; };
function reducer(state: State, action: Action): State { function reducer(state: State, action: Action): State {
@@ -218,6 +223,10 @@ function reducer(state: State, action: Action): State {
return { ...state, message: null }; return { ...state, message: null };
case "REFRESH_LOG": case "REFRESH_LOG":
return { ...state, recentLog: action.log }; return { ...state, recentLog: action.log };
case "OPEN_SETUP_MODAL":
return { ...state, setupModalOpen: true };
case "CLOSE_SETUP_MODAL":
return { ...state, setupModalOpen: false };
default: default:
return state; return state;
} }
@@ -243,6 +252,18 @@ export default function SmartsheetIntegrationCard({
void loadAll(); void loadAll();
}, [loadAll]); }, [loadAll]);
// Close the setup modal on Escape. Listens only while the modal is open.
useEffect(() => {
if (!state.setupModalOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
dispatch({ type: "CLOSE_SETUP_MODAL" });
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [state.setupModalOpen]);
async function handleSave(e: React.FormEvent) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
dispatch({ type: "SAVE_START" }); dispatch({ type: "SAVE_START" });
@@ -331,6 +352,15 @@ export default function SmartsheetIntegrationCard({
<Card <Card
title="Connection" title="Connection"
subtitle="Smartsheet API token + target sheet." subtitle="Smartsheet API token + target sheet."
action={
<button
type="button"
onClick={() => dispatch({ type: "OPEN_SETUP_MODAL" })}
className="rounded-md border border-[#d4d9d3] bg-white px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-[#5a5d5a] transition hover:border-[#1a4d2e] hover:text-[#1a4d2e]"
>
Setup instructions
</button>
}
> >
{/* Sheet */} {/* Sheet */}
<div className="space-y-1"> <div className="space-y-1">
@@ -603,6 +633,12 @@ export default function SmartsheetIntegrationCard({
Paste an API token above to enable Save. Paste an API token above to enable Save.
</p> </p>
)} )}
{state.setupModalOpen && (
<SetupInstructionsModal
onClose={() => dispatch({ type: "CLOSE_SETUP_MODAL" })}
/>
)}
</form> </form>
); );
} }
@@ -612,17 +648,22 @@ export default function SmartsheetIntegrationCard({
function Card({ function Card({
title, title,
subtitle, subtitle,
action,
children, children,
}: { }: {
title: string; title: string;
subtitle?: string; subtitle?: string;
action?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm"> <section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
<header className="mb-4"> <header className="mb-4 flex items-start justify-between gap-3">
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2> <div className="min-w-0 flex-1">
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>} <h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</header> </header>
{children} {children}
</section> </section>
@@ -687,4 +728,340 @@ function TestResultPanel({ test }: { test: TestState }) {
<p className="font-semibold text-[#a4452b]"> {test.message}</p> <p className="font-semibold text-[#a4452b]"> {test.message}</p>
</div> </div>
); );
}
// ── Setup instructions modal ───────────────────────────────────────────────
const FIELD_SCHEMA: Array<{
key: keyof SmartsheetColumnMapping;
label: string;
required: boolean;
type: string;
example: string;
description: string;
}> = [
{
key: "entry_id",
label: "Entry ID",
required: true,
type: "TEXT_NUMBER",
example: "550e8400-e29b-41d4-a716-446655440000",
description:
"Unique UUID for each water log row. We use this for dedup — if the same Entry ID is already in your sheet, we skip the insert instead of creating a duplicate row.",
},
{
key: "logged_at",
label: "Logged At",
required: true,
type: "DATETIME (or DATE)",
example: "2026-07-03 14:50",
description:
"When the reading was taken. Use a DATETIME column for full timestamp, or DATE for day-only. We format automatically based on the column type.",
},
{
key: "headgate",
label: "Headgate",
required: false,
type: "TEXT_NUMBER",
example: "Section 4 — North Field",
description: "Display name of the headgate the reading came from.",
},
{
key: "measurement",
label: "Measurement",
required: false,
type: "TEXT_NUMBER",
example: "123.45",
description:
"The numeric reading. Sent as a string (e.g. \"123.45\"); Smartsheet's TEXT_NUMBER columns accept this and display it as a number.",
},
{
key: "unit",
label: "Unit",
required: false,
type: "TEXT_NUMBER",
example: "CFS",
description: "Display unit, e.g. CFS, GPM, AF/Day, inches.",
},
{
key: "irrigator",
label: "Irrigator",
required: false,
type: "TEXT_NUMBER",
example: "Jane Doe",
description: "Name of the irrigator who logged the reading.",
},
{
key: "notes",
label: "Notes",
required: false,
type: "TEXT_NUMBER",
example: "Replaced meter seal",
description: "Free-text notes attached to the entry (may be empty).",
},
];
function SetupInstructionsModal({ onClose }: { onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="smartsheet-setup-title"
onClick={(e) => {
// Close when clicking the backdrop (but not the panel itself).
if (e.target === e.currentTarget) onClose();
}}
>
<div className="relative max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-xl">
{/* Header */}
<header className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] bg-[#fdfaf2] px-6 py-4">
<div>
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 05.1 Setup
</p>
<h2
id="smartsheet-setup-title"
className="mt-1 text-xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Connect a Smartsheet
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="-mr-1 -mt-1 rounded-md p-2 text-[#5a5d5a] hover:bg-[#f4f1e8] hover:text-[#1d1d1f]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</header>
<div className="space-y-6 px-6 py-5">
{/* Prerequisites */}
<section>
<h3 className="text-sm font-semibold text-[#1d1d1f]">
Before you start
</h3>
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-[#5a5d5a]">
<li>You need a Smartsheet account.</li>
<li>
The target sheet must exist in your account. We can add
columns to it for you (see Step 2), but we cannot create
the sheet itself.
</li>
<li>
You need admin access to the Water Log settings page
(you&apos;re already here).
</li>
</ul>
</section>
{/* Step 1 */}
<section>
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
1
</span>
Generate an API token
</h3>
<ol className="mt-2 list-inside list-decimal space-y-1 text-sm text-[#5a5d5a]">
<li>
Go to{" "}
<a
href="https://app.smartsheet.com/b/home"
target="_blank"
rel="noopener noreferrer"
className="text-[#1a4d2e] underline underline-offset-2"
>
app.smartsheet.com
</a>
.
</li>
<li>
Click your avatar (top right) {" "}
<span className="font-mono text-xs">Account</span> {" "}
<span className="font-mono text-xs">Personal Settings</span>{" "}
<span className="font-mono text-xs">API Access</span>.
</li>
<li>
Click <strong>Generate new access token</strong>. Give it a
name like <span className="font-mono text-xs">RouteCommerce Water Log</span>.
</li>
<li>
Copy the token <em>immediately</em> Smartsheet only shows
it once.
</li>
</ol>
</section>
{/* Step 2 */}
<section>
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
2
</span>
Set up your sheet
</h3>
<p className="mt-2 text-sm text-[#5a5d5a]">
Your sheet needs at least the two <strong>required</strong>{" "}
columns below. The other five are optional but recommended
you decide which of your sheet&apos;s existing columns
receives each field.
</p>
<div className="mt-3 overflow-hidden rounded-lg border border-[#d4d9d3]">
<table className="w-full text-xs">
<thead className="bg-[#f4f1e8] text-[#5a5d5a]">
<tr>
<th className="px-3 py-2 text-left font-semibold">
Our field
</th>
<th className="px-3 py-2 text-left font-semibold">
Smartsheet column type
</th>
<th className="px-3 py-2 text-left font-semibold">
Example
</th>
</tr>
</thead>
<tbody className="divide-y divide-[#d4d9d3] bg-white">
{FIELD_SCHEMA.map((f) => (
<tr key={f.key}>
<td className="px-3 py-2 align-top">
<div className="font-semibold text-[#1d1d1f]">
{f.label}
{f.required && (
<span className="ml-1 text-[#a4452b]">*</span>
)}
</div>
<div className="mt-0.5 text-[10px] text-[#8a8b88]">
{f.description}
</div>
</td>
<td className="px-3 py-2 align-top font-mono text-[11px] text-[#5a5d5a]">
{f.type}
</td>
<td className="px-3 py-2 align-top font-mono text-[11px] text-[#5a5d5a]">
{f.example}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-[#8a8b88]">
<span className="text-[#a4452b]">*</span> Required. The
Entry ID column is what we use for dedup without it, a
re-sync would create duplicate rows.
</p>
<details className="mt-3 rounded-lg border border-[#d4d9d3] bg-white">
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#f4f1e8]">
Copy a starter header row
</summary>
<pre className="overflow-x-auto border-t border-[#d4d9d3] bg-[#fdfaf2] px-4 py-3 text-xs text-[#1d1d1f]">
<code>
Entry ID,Logged At,Headgate,Measurement,Unit,Irrigator,Notes
</code>
</pre>
<p className="border-t border-[#d4d9d3] px-4 py-2 text-xs text-[#5a5d5a]">
Paste this into row 1 of a new sheet, then click the
<span className="font-mono"> </span>
on the Entry ID cell and choose{" "}
<span className="font-mono">Column Type Text/Number</span>.
Repeat for the others.
</p>
</details>
</section>
{/* Step 3 */}
<section>
<h3 className="flex items-baseline gap-2 text-sm font-semibold text-[#1d1d1f]">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-xs font-bold text-white">
3
</span>
Connect
</h3>
<ol className="mt-2 list-inside list-decimal space-y-1 text-sm text-[#5a5d5a]">
<li>
Close this dialog and paste your <strong>sheet URL</strong>{" "}
or numeric sheet ID into the Sheet URL field. (File
Properties in Smartsheet shows the numeric ID.)
</li>
<li>
Paste your API token into the API Access Token field.
</li>
<li>
Click <strong>Test Connection</strong> we&apos;ll load
your sheet&apos;s columns into the dropdowns.
</li>
<li>
Map each of our fields to one of your sheet&apos;s
columns. Unmapped fields are simply skipped.
</li>
<li>Click <strong>Save Settings</strong>.</li>
</ol>
<p className="mt-3 rounded-lg border border-[#1a4d2e]/30 bg-[#1a4d2e]/5 px-3 py-2 text-xs text-[#1a4d2e]">
<strong>Tip:</strong> the first sync runs immediately if you
set frequency to <em>Real-time</em>, or on the next cron
tick for 15-minute / hourly. Submit a test entry from{" "}
<code className="font-mono">/water</code> to see it land in
your sheet.
</p>
</section>
{/* Troubleshooting */}
<section className="border-t border-[#d4d9d3] pt-4">
<h3 className="text-sm font-semibold text-[#1d1d1f]">
Common issues
</h3>
<ul className="mt-2 space-y-2 text-xs text-[#5a5d5a]">
<li>
<strong>401 Unauthorized</strong> the token is wrong or
expired. Re-generate it in Smartsheet.
</li>
<li>
<strong>404 Sheet not found</strong> if you pasted a
share URL with a slug (e.g. <code className="font-mono">/sheets/abc123</code>),
Smartsheet sometimes 404s on those. Use the numeric sheet
ID instead, found in{" "}
<span className="font-mono">File Properties</span>.
</li>
<li>
<strong>Test Connection succeeds but rows don&apos;t
appear</strong> the column type probably rejects our
value (most common: a DATE column getting an ISO
timestamp). Check the Recent Activity panel for the exact
error.
</li>
</ul>
</section>
</div>
{/* Footer */}
<footer className="sticky bottom-0 flex justify-end border-t border-[#d4d9d3] bg-[#fdfaf2] px-6 py-3">
<button
type="button"
onClick={onClose}
className="rounded-lg bg-[#1a4d2e] px-5 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24]"
>
Got it
</button>
</footer>
</div>
</div>
);
} }