Files
route-commerce/src/components/admin/water-log/SmartsheetIntegrationCard.tsx
T
Nora a470b6fe9d
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
feat(water-log/settings): sticky in-page section nav for Smartsheet card
The Smartsheet card has six stacked sections (Enable, Connection,
Sync frequency, Column mapping, Backfill, Recent activity) — at
1100px viewport that's a long scroll. Add a sticky 'On this page'
pill nav at the top of the card that highlights the active section
as the user scrolls, with anchor jump on click.

- Each <Card> gets a stable id (smartsheet-enable, -connection,
  -frequency, -mapping, -backfill, -activity) and scroll-mt-20 so
  anchor jumps clear the sticky nav.
- IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section
  is in the top 20% of the viewport and updates activeSection state.
- Pill click uses native scrollIntoView, which honors the global
  scroll-behavior: smooth on <html> (auto-flipped to instant under
  prefers-reduced-motion by globals.css — no JS matchMedia needed).
- URL hash updates via history.replaceState so the destination is
  shareable without triggering a second jump.
- Apple HIG polish: rounded-full pills, focus-visible ring,
  active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92
  with backdrop-blur for the sticky chrome.
- All changes additive — no existing Card/ToggleRow behavior modified.

Verified: npx tsc --noEmit clean, npm run build clean,
npm run lint on this file clean.
2026-07-03 15:56:16 -06:00

1604 lines
59 KiB
TypeScript

"use client";
/**
* Smartsheet Integration — `/admin/water-log/settings` section.
*
* Self-contained card that lets a brand admin configure the Smartsheet
* integration for their Water Log entries:
*
* 1. Enable/disable the integration
* 2. Smartsheet sheet URL or numeric sheet ID
* 3. API token (password field; never sent back to the client after save)
* 4. Sync frequency (realtime / every 15 minutes / hourly)
* 5. Column mapping (entry_id, logged_at, headgate, measurement,
* unit, irrigator, notes)
* 6. Test Connection button — fetches sheet metadata so the column
* dropdowns are populated
* 7. Recent Activity panel — last 10 sync attempts (green ✓ / red ✗)
*
* Visual language matches the parent Water Log settings page
* (cream + forest palette, "Field Almanac" style).
*
* Threading brandId:
* The card takes `brandId` as a prop per CLAUDE.md ("Brand ID
* Threading"). The current Water Log settings page still hardcodes
* TUXEDO_BRAND_ID; this card accepts it as a prop so the page can
* be lifted later without rewrites.
*/
import { useEffect, useReducer, useState, useCallback } from "react";
import {
getSmartsheetConfig,
saveSmartsheetConfig,
testSmartsheetConnection,
listSmartsheetSyncLog,
enqueueSmartsheetBackfill,
type EnqueueBackfillResult,
type SmartsheetConfigResponse,
type SmartsheetSyncLogEntry,
} from "@/actions/water-log/smartsheet";
import type {
SmartsheetColumnMapping,
SmartsheetFrequency,
} from "@/db/schema/water-log";
import { formatDateTime } from "@/lib/format-date";
const COLUMN_LABELS: Record<keyof SmartsheetColumnMapping, string> = {
entry_id: "Entry ID (UUID, for dedup)",
logged_at: "Logged at (timestamp)",
headgate: "Headgate name",
measurement: "Measurement",
unit: "Unit (CFS, GPM, …)",
irrigator: "Irrigator name",
notes: "Notes (optional)",
};
const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [
"entry_id",
"logged_at",
];
// IDs for the in-page section nav. Hoisted to module scope so the
// IntersectionObserver effect can list it once in its deps array.
const SECTION_IDS = [
"smartsheet-enable",
"smartsheet-connection",
"smartsheet-frequency",
"smartsheet-mapping",
"smartsheet-backfill",
"smartsheet-activity",
] as const;
type SectionId = (typeof SECTION_IDS)[number];
const FREQUENCY_LABELS: Record<SmartsheetFrequency, string> = {
realtime: "Real-time (after every entry)",
every_15_minutes: "Every 15 minutes",
hourly: "Hourly",
};
type ColumnOption = { id: string; title: string; type: string };
type TestState =
| { state: "idle" }
| { state: "loading" }
| {
state: "ok";
sheetName: string;
columnCount: number;
columns: ColumnOption[];
}
| { state: "error"; message: string };
type State = {
loading: boolean;
saving: boolean;
message: { type: "success" | "error"; text: string } | null;
// Persisted (saved) values — for displaying the masked token, etc.
savedConfig: SmartsheetConfigResponse | null;
// Editable form fields:
enabled: boolean;
sheetIdInput: string;
tokenInput: string; // empty string = keep existing
showToken: boolean;
frequency: SmartsheetFrequency;
mapping: SmartsheetColumnMapping;
// Available sheet columns (loaded via Test Connection)
availableColumns: ColumnOption[];
// Test Connection state
test: TestState;
// Recent activity
recentLog: SmartsheetSyncLogEntry[];
loadingLog: boolean;
/** Whether the setup-instructions modal is open. */
setupModalOpen: boolean;
/** Backfill UI state. */
backfill: {
running: boolean;
range: "all" | "30d" | "7d";
result: EnqueueBackfillResult | null;
};
};
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; config: SmartsheetConfigResponse; log: SmartsheetSyncLogEntry[] }
| { type: "SAVE_START" }
| { type: "SAVE_OK"; config: SmartsheetConfigResponse }
| { type: "SAVE_FAIL"; message: string }
| { type: "SAVE_DONE" }
| { type: "SET_ENABLED"; value: boolean }
| { type: "SET_SHEET_ID"; value: string }
| { type: "SET_TOKEN"; value: string }
| { type: "TOGGLE_SHOW_TOKEN" }
| { type: "SET_FREQUENCY"; value: SmartsheetFrequency }
| { type: "SET_MAPPING_KEY"; key: keyof SmartsheetColumnMapping; value: string }
| { type: "TEST_START" }
| { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] }
| { type: "TEST_FAIL"; message: string }
| { type: "CLEAR_MESSAGE" }
| { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] }
| { type: "OPEN_SETUP_MODAL" }
| { type: "CLOSE_SETUP_MODAL" }
| { type: "BACKFILL_START" }
| { type: "BACKFILL_RANGE"; value: "all" | "30d" | "7d" }
| { type: "BACKFILL_DONE"; result: EnqueueBackfillResult };
const EMPTY_MAPPING: SmartsheetColumnMapping = {
entry_id: "",
logged_at: "",
headgate: "",
measurement: "",
unit: "",
irrigator: "",
notes: null,
};
const initialState: State = {
loading: true,
saving: false,
message: null,
savedConfig: null,
enabled: false,
sheetIdInput: "",
tokenInput: "",
showToken: false,
frequency: "hourly",
mapping: { ...EMPTY_MAPPING },
availableColumns: [],
test: { state: "idle" },
recentLog: [],
loadingLog: false,
setupModalOpen: false,
backfill: { running: false, range: "all", result: null },
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "LOAD_START":
return { ...state, loading: true };
case "LOAD_DONE": {
const c = action.config;
return {
...state,
loading: false,
savedConfig: c,
enabled: c.syncEnabled,
sheetIdInput: c.sheetId ?? "",
// tokenInput stays empty — we don't preload from saved.
frequency: c.syncFrequency,
mapping: c.columnMapping ?? { ...EMPTY_MAPPING },
recentLog: action.log,
};
}
case "SAVE_START":
return { ...state, saving: true, message: null };
case "SAVE_OK":
return {
...state,
saving: false,
message: { type: "success", text: "Smartsheet settings saved" },
savedConfig: action.config,
// Clear token input on save (token is now stored server-side).
tokenInput: "",
showToken: false,
};
case "SAVE_FAIL":
return {
...state,
saving: false,
message: { type: "error", text: action.message },
};
case "SAVE_DONE":
return { ...state, saving: false };
case "SET_ENABLED":
return { ...state, enabled: action.value };
case "SET_SHEET_ID":
return { ...state, sheetIdInput: action.value };
case "SET_TOKEN":
return { ...state, tokenInput: action.value };
case "TOGGLE_SHOW_TOKEN":
return { ...state, showToken: !state.showToken };
case "SET_FREQUENCY":
return { ...state, frequency: action.value };
case "SET_MAPPING_KEY":
return {
...state,
mapping: {
...state.mapping,
[action.key]: action.value === "" ? null : action.value,
} as SmartsheetColumnMapping,
};
case "TEST_START":
return { ...state, test: { state: "loading" } };
case "TEST_OK":
return {
...state,
test: {
state: "ok",
sheetName: action.sheetName,
columnCount: action.columns.length,
columns: action.columns,
},
availableColumns: action.columns,
};
case "TEST_FAIL":
return { ...state, test: { state: "error", message: action.message } };
case "CLEAR_MESSAGE":
return { ...state, message: null };
case "REFRESH_LOG":
return { ...state, recentLog: action.log };
case "OPEN_SETUP_MODAL":
return { ...state, setupModalOpen: true };
case "CLOSE_SETUP_MODAL":
return { ...state, setupModalOpen: false };
case "BACKFILL_START":
return {
...state,
backfill: { ...state.backfill, running: true },
};
case "BACKFILL_RANGE":
return {
...state,
backfill: { ...state.backfill, range: action.value },
};
case "BACKFILL_DONE":
return {
...state,
backfill: {
...state.backfill,
running: false,
result: action.result,
},
};
default:
return state;
}
}
export default function SmartsheetIntegrationCard({
brandId,
}: {
brandId: string;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const loadAll = useCallback(async () => {
dispatch({ type: "LOAD_START" });
const [config, log] = await Promise.all([
getSmartsheetConfig(brandId),
listSmartsheetSyncLog(brandId, 10),
]);
dispatch({ type: "LOAD_DONE", config, log });
}, [brandId]);
useEffect(() => {
void 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]);
// ── In-page section nav: which section is currently visible ────────────
// The "active zone" is the top 20% of the viewport (under the sticky nav).
// When a section enters that zone, it becomes active. We keep the previous
// active section during the gap between sections so the highlight doesn't
// blink off when scrolling between two adjacent cards.
const [activeSection, setActiveSection] = useState<SectionId>(
"smartsheet-enable",
);
useEffect(() => {
const elements = SECTION_IDS.map((id) => document.getElementById(id)).filter(
(el): el is HTMLElement => el !== null,
);
if (elements.length === 0) return;
const visible = new Set<string>();
let lastActive: SectionId | null = null;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visible.add(entry.target.id);
} else {
visible.delete(entry.target.id);
}
}
// Among visible sections, prefer the earliest (topmost) in DOM order.
const ordered = SECTION_IDS.filter((id) => visible.has(id));
const nextActive = (ordered[0] ?? lastActive) as SectionId | null;
if (nextActive && nextActive !== lastActive) {
lastActive = nextActive;
setActiveSection(nextActive);
}
},
{
// Top 20% of the viewport = the visible "reading zone" under the nav.
rootMargin: "0px 0px -80% 0px",
threshold: 0,
},
);
for (const el of elements) observer.observe(el);
return () => observer.disconnect();
}, []);
// Smooth scroll on pill click. `scroll-behavior: smooth` is set globally on
// <html> and the prefers-reduced-motion media query in globals.css flips
// it to `auto`, so we don't need a JS-side matchMedia check here.
const handleSectionClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
const el = document.getElementById(id);
if (!el) return;
e.preventDefault();
el.scrollIntoView({ block: "start" });
// Reflect the destination in the URL without triggering a jump.
if (typeof window !== "undefined" && window.history) {
window.history.replaceState(null, "", `#${id}`);
}
},
[],
);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
dispatch({ type: "SAVE_START" });
const result = await saveSmartsheetConfig(brandId, {
sheetId: state.sheetIdInput,
token: state.tokenInput,
syncEnabled: state.enabled,
syncFrequency: state.frequency,
columnMapping: state.mapping,
});
if (result.success && result.config) {
dispatch({ type: "SAVE_OK", config: result.config });
// Refresh log so any pending activity from this turn is visible.
const log = await listSmartsheetSyncLog(brandId, 10);
dispatch({ type: "REFRESH_LOG", log });
} else {
dispatch({ type: "SAVE_FAIL", message: result.error ?? "Save failed" });
}
}
async function handleTest() {
if (!state.sheetIdInput) {
dispatch({ type: "TEST_FAIL", message: "Enter a Sheet URL or ID first" });
return;
}
if (!state.tokenInput && !state.savedConfig?.hasToken) {
dispatch({
type: "TEST_FAIL",
message: "Paste your API token to test, or save the config first",
});
return;
}
dispatch({ type: "TEST_START" });
const result = await testSmartsheetConnection(
brandId,
state.sheetIdInput,
state.tokenInput,
);
if (result.success) {
dispatch({
type: "TEST_OK",
sheetName: result.sheetName,
columns: result.columns,
});
} else {
dispatch({ type: "TEST_FAIL", message: result.error });
}
}
async function handleBackfill() {
if (state.backfill.running) return;
if (!state.savedConfig?.configured || !state.enabled) {
// Surface as a notice rather than call into the action just to fail.
dispatch({
type: "BACKFILL_DONE",
result: {
success: false,
error: "Save and enable your Smartsheet config first.",
considered: 0,
newlyQueued: 0,
alreadyQueued: 0,
drained: { synced: 0, skipped: 0, failed: 0, remaining: 0 },
},
});
return;
}
const rangeLabel =
state.backfill.range === "30d"
? "the last 30 days"
: state.backfill.range === "7d"
? "the last 7 days"
: "all time";
if (
!window.confirm(
`Sync ${rangeLabel} of water log entries to Smartsheet? This is safe to re-run — already-pushed entries are deduped by Entry ID.`,
)
) {
return;
}
dispatch({ type: "BACKFILL_START" });
const since =
state.backfill.range === "30d"
? new Date(Date.now() - 30 * 86_400_000)
: state.backfill.range === "7d"
? new Date(Date.now() - 7 * 86_400_000)
: undefined;
const result = await enqueueSmartsheetBackfill(brandId, { since });
dispatch({ type: "BACKFILL_DONE", result });
// Refresh the log so the drain results show up immediately.
const log = await listSmartsheetSyncLog(brandId, 10);
dispatch({ type: "REFRESH_LOG", log });
}
if (state.loading) {
return (
<Card title="Smartsheet Integration" subtitle="Loading…">
<p className="text-sm text-[#5a5d5a]">Reading saved configuration.</p>
</Card>
);
}
const noTokenYet = !state.savedConfig?.hasToken && state.tokenInput.length === 0;
return (
<form onSubmit={handleSave} className="space-y-6">
<SectionNav activeId={activeSection} onNavigate={handleSectionClick} />
{state.message && (
<div
role={state.message.type === "error" ? "alert" : "status"}
aria-live={state.message.type === "error" ? "assertive" : "polite"}
className={`smartsheet-slide-down flex items-start gap-3 rounded-lg border px-4 py-3 text-[13.5px]
${
state.message.type === "success"
? "border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.05] text-[#143d24]"
: "border-[#a4452b]/25 bg-[#a4452b]/[0.05] text-[#7a341f]"
}`}
>
<span
aria-hidden
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white ${
state.message.type === "success" ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
}`}
>
{state.message.type === "success" ? (
<svg viewBox="0 0 12 12" className="h-3 w-3">
<path
d="M2 6.5L5 9.5L10 3.5"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 12 12" className="h-3 w-3">
<path
d="M6 3V6.5M6 8.5V9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)}
</span>
<p className="font-medium leading-snug">{state.message.text}</p>
</div>
)}
<Card
id="smartsheet-enable"
title="Smartsheet Integration"
subtitle="Push every new water log entry to a Smartsheet sheet automatically."
>
<ToggleRow
label="Enable"
description="When disabled, no new rows are pushed. Existing rows in Smartsheet are untouched."
value={state.enabled}
onChange={(v) => dispatch({ type: "SET_ENABLED", value: v })}
/>
</Card>
<Card
id="smartsheet-connection"
title="Connection"
subtitle="Smartsheet API token + target sheet."
action={
<button
type="button"
onClick={() => dispatch({ type: "OPEN_SETUP_MODAL" })}
className="inline-flex min-h-[32px] items-center justify-center rounded-lg border border-[#d4d9d3] bg-white px-3.5 py-1.5
text-[12.5px] font-semibold text-[#5a5d5a]
motion-safe:transition-[background-color,border-color,color] motion-safe:duration-150
hover:border-[#1a4d2e] hover:bg-[#f4f1e8] hover:text-[#1a4d2e]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/20
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out"
>
Setup instructions
</button>
}
>
{/* Sheet */}
<div className="space-y-1">
<label
htmlFor="smartsheet-sheet"
className="block text-xs font-medium text-[#5a5d5a]"
>
Sheet URL or sheet ID
</label>
<input
id="smartsheet-sheet"
type="text"
value={state.sheetIdInput}
onChange={(e) =>
dispatch({ type: "SET_SHEET_ID", value: e.target.value })
}
placeholder="https://app.smartsheet.com/sheets/AbCdEfGhIjKlMn or 123456789012345"
className="block min-h-[44px] w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5
font-mono text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0]
shadow-[inset_0_1px_0_rgba(15,23,18,0.02)]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
autoComplete="off"
/>
<p className="text-xs text-[#8a8b88]">
Paste the share URL from your browser address bar, or the
numeric sheet ID. If a share URL doesn&apos;t work, use the
numeric ID from <em>File Properties</em> in Smartsheet.
</p>
</div>
{/* Token */}
<div className="mt-4 space-y-1">
<label
htmlFor="smartsheet-token"
className="block text-xs font-medium text-[#5a5d5a]"
>
API access token
</label>
<div className="flex gap-2">
<input
id="smartsheet-token"
type={state.showToken ? "text" : "password"}
value={state.tokenInput}
onChange={(e) =>
dispatch({ type: "SET_TOKEN", value: e.target.value })
}
placeholder={
state.savedConfig?.hasToken
? `Leave blank to keep the saved token (${state.savedConfig.maskedToken ?? "••••"})`
: "Paste your Smartsheet API token"
}
className="min-h-[44px] flex-1 rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5
font-mono text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0]
shadow-[inset_0_1px_0_rgba(15,23,18,0.02)]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
autoComplete="off"
/>
<button
type="button"
onClick={() => dispatch({ type: "TOGGLE_SHOW_TOKEN" })}
className="inline-flex min-h-[44px] items-center justify-center rounded-lg border border-[#d4d9d3] bg-white px-3
text-[13px] font-medium text-[#5a5d5a]
motion-safe:transition-colors motion-safe:duration-150
hover:bg-[#f4f1e8] hover:text-[#1d1d1f]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/20
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out"
aria-label={state.showToken ? "Hide token" : "Show token"}
>
{state.showToken ? "Hide" : "Show"}
</button>
</div>
<p className="text-xs text-[#8a8b88]">
Generate a token at{" "}
<a
href="https://app.smartsheet.com/b/home"
target="_blank"
rel="noopener noreferrer"
className="text-[#1a4d2e] underline underline-offset-2"
>
app.smartsheet.com
</a>{" "}
<span className="font-mono">Account</span> {" "}
<span className="font-mono">Personal Settings</span> {" "}
<span className="font-mono">API Access</span>. The token is
encrypted with AES-256-GCM at rest.
</p>
</div>
{/* Test Connection */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={handleTest}
disabled={state.test.state === "loading"}
className="inline-flex min-h-[40px] items-center justify-center rounded-lg border border-[#1a4d2e] bg-white px-4 py-2
text-[14px] font-semibold text-[#1a4d2e]
motion-safe:transition-[background-color,color,transform] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#1a4d2e] hover:text-white
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
active:scale-[0.97]
disabled:opacity-50 disabled:cursor-not-allowed"
>
{state.test.state === "loading" ? (
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none" />
Testing
</span>
) : (
"Test Connection"
)}
</button>
<TestResultPanel test={state.test} />
</div>
</Card>
<Card
id="smartsheet-frequency"
title="Sync frequency"
subtitle="How often new water log entries are pushed to Smartsheet."
>
<div className="space-y-2">
{(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => (
<label
key={f}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3
motion-safe:transition-[border-color,background-color] motion-safe:duration-150
${
state.frequency === f
? "border-[#1a4d2e] bg-[#1a4d2e]/[0.04]"
: "border-[#d4d9d3] bg-white hover:border-[#a8b4a4] hover:bg-[#fcfaf3]"
}
focus-within:ring-4 focus-within:ring-[#1a4d2e]/15 focus-within:border-[#1a4d2e]
active:scale-[0.997] motion-safe:transition-transform motion-safe:ease-out`}
>
<input
type="radio"
name="smartsheet-frequency"
value={f}
checked={state.frequency === f}
onChange={() =>
dispatch({ type: "SET_FREQUENCY", value: f })
}
className="mt-0.5 h-4 w-4 cursor-pointer accent-[#1a4d2e]"
/>
<span className="flex-1 text-[14px] font-medium text-[#1d1d1f]">
{FREQUENCY_LABELS[f]}
</span>
</label>
))}
</div>
</Card>
<Card
id="smartsheet-mapping"
title="Column mapping"
subtitle="Map our water log fields to columns in your Smartsheet. entry_id and logged_at are required."
>
{state.availableColumns.length === 0 ? (
<p className="text-xs text-[#8a8b88]">
Run <strong>Test Connection</strong> above to load the sheet&apos;s
columns. You can also paste column IDs manually below.
</p>
) : (
<p className="text-xs text-[#8a8b88]">
Loaded {state.availableColumns.length} columns from sheet{" "}
<span className="font-mono">
{state.test.state === "ok" ? state.test.sheetName : "—"}
</span>
. Pick from the dropdowns below.
</p>
)}
<div className="mt-4 space-y-3">
{(Object.keys(COLUMN_LABELS) as Array<keyof SmartsheetColumnMapping>).map(
(key) => {
const required = REQUIRED_COLUMNS.includes(key);
const value = state.mapping[key];
const valueAsString = value ?? "";
return (
<div key={key} className="flex items-center gap-3">
<label
htmlFor={`map-${key}`}
className="w-1/3 text-sm font-medium text-[#1d1d1f]"
>
{COLUMN_LABELS[key]}
{required && (
<span className="ml-1 text-xs text-[#a4452b]">
required
</span>
)}
</label>
{state.availableColumns.length > 0 ? (
<select
id={`map-${key}`}
value={valueAsString}
onChange={(e) =>
dispatch({
type: "SET_MAPPING_KEY",
key,
value: e.target.value,
})
}
className="min-h-[40px] flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2
text-[14px] text-[#1d1d1f]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
>
<option value="">
{required ? "Select a column" : "Not mapped"}
</option>
{state.availableColumns.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
{c.type !== "TEXT_NUMBER"
? ` (${c.type.toLowerCase()})`
: ""}
</option>
))}
</select>
) : (
<input
id={`map-${key}`}
type="text"
value={valueAsString}
onChange={(e) =>
dispatch({
type: "SET_MAPPING_KEY",
key,
value: e.target.value,
})
}
placeholder="Column ID (numeric)"
className="min-h-[40px] flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2
font-mono text-[14px] text-[#1d1d1f] placeholder:text-[#a3a5a0]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15"
/>
)}
</div>
);
},
)}
</div>
</Card>
<BackfillCard
id="smartsheet-backfill"
state={state.backfill}
onRun={handleBackfill}
onRangeChange={(v) => dispatch({ type: "BACKFILL_RANGE", value: v })}
/>
<Card
id="smartsheet-activity"
title="Recent activity"
subtitle="Last 10 sync attempts. Use this to spot token / sheet issues quickly."
>
{state.loadingLog ? (
<p className="text-sm text-[#5a5d5a]">Loading</p>
) : state.recentLog.length === 0 ? (
<p className="text-sm text-[#5a5d5a]">
No sync attempts yet. Save the config and submit a water log
entry from <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water</code>.
</p>
) : (
<ul className="divide-y divide-[#d4d9d3]">
{state.recentLog.map((log) => (
<li
key={log.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<span
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ${
log.success ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
}`}
aria-label={log.success ? "success" : "failure"}
>
{log.success ? "✓" : "✗"}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
<span className="font-mono font-semibold text-[#1d1d1f]">
{log.action}
</span>
<span className="text-[#5a5d5a]">
{formatDateTime(log.createdAt)}
</span>
{log.durationMs != null && (
<span className="text-[#8a8b88]">
{log.durationMs}ms
</span>
)}
</div>
{log.error && (
<p className="mt-0.5 text-xs text-[#a4452b]">{log.error}</p>
)}
</div>
</li>
))}
</ul>
)}
</Card>
<button
type="submit"
disabled={state.saving || noTokenYet}
className="inline-flex min-h-[48px] w-full items-center justify-center rounded-lg bg-[#1a4d2e] px-6 py-3
text-[14px] font-semibold tracking-tight text-white
shadow-[0_1px_0_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.06)]
motion-safe:transition-[background-color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#143d24] hover:shadow-[0_2px_4px_rgba(15,23,18,0.1),inset_0_1px_0_rgba(255,255,255,0.06)]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fdfaf2]
active:scale-[0.985] active:bg-[#0f2e1c]
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-[#1a4d2e]"
>
{state.saving ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
Saving
</span>
) : (
"Save changes"
)}
</button>
{noTokenYet && (
<p className="text-center text-xs text-[#8a8b88]">
Paste an API token above to enable Save.
</p>
)}
{state.setupModalOpen && (
<SetupInstructionsModal
onClose={() => dispatch({ type: "CLOSE_SETUP_MODAL" })}
/>
)}
</form>
);
}
// ── Subcomponents (mirror the parent settings page style) ──────────────────
function SectionNav({
activeId,
onNavigate,
}: {
activeId: string;
onNavigate: (e: React.MouseEvent<HTMLAnchorElement>, id: string) => void;
}) {
const sections: Array<{ id: string; label: string }> = [
{ id: "smartsheet-enable", label: "Enable" },
{ id: "smartsheet-connection", label: "Connection" },
{ id: "smartsheet-frequency", label: "Frequency" },
{ id: "smartsheet-mapping", label: "Mapping" },
{ id: "smartsheet-backfill", label: "Backfill" },
{ id: "smartsheet-activity", label: "Activity" },
];
return (
<nav
aria-label="On this page"
className="smartsheet-fade-in sticky top-2 z-20 -mx-1 flex flex-wrap items-center gap-1 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2]/92 px-3 py-2 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)] backdrop-blur-md"
>
<span className="mr-1 hidden text-[10px] font-mono uppercase tracking-[0.22em] text-[#8a6b3b] sm:inline">
On this page
</span>
<ul className="flex flex-wrap items-center gap-1">
{sections.map((s) => {
const active = activeId === s.id;
return (
<li key={s.id}>
<a
href={`#${s.id}`}
aria-current={active ? "true" : undefined}
onClick={(e) => onNavigate(e, s.id)}
className={`inline-flex min-h-[28px] items-center justify-center rounded-full px-2.5 text-[12px] font-semibold tracking-tight
motion-safe:transition-[background-color,color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
active:scale-[0.96]
${
active
? "bg-[#1a4d2e] text-white shadow-[0_1px_2px_rgba(15,23,18,0.1)]"
: "text-[#5a5d5a] hover:bg-[#f0ebde] hover:text-[#1d1d1f]"
}`}
>
{s.label}
</a>
</li>
);
})}
</ul>
</nav>
);
}
function Card({
id,
title,
subtitle,
action,
children,
}: {
id?: string;
title: string;
subtitle?: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section
id={id}
// `scroll-mt-20` keeps anchor jumps clear of the sticky "On this page" nav.
className="scroll-mt-20 rounded-xl border border-[#d4d9d3] bg-white/95 p-6 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)] motion-safe:transition-shadow"
>
<header className="mb-5 flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
{title}
</h2>
{subtitle && (
<p className="mt-1 text-[13px] leading-snug text-[#5a5d5a]">
{subtitle}
</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</header>
{children}
</section>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0 flex-1">
<p className="text-[14px] font-medium leading-snug text-[#1d1d1f]">
{label}
</p>
{description && (
<p className="mt-0.5 text-[12.5px] leading-snug text-[#5a5d5a]">
{description}
</p>
)}
</div>
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(!value)}
className={`group relative inline-flex h-[26px] w-[46px] shrink-0 items-center rounded-full
motion-safe:transition-colors motion-safe:duration-200
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25 focus-visible:ring-offset-2 focus-visible:ring-offset-white
active:scale-[0.97] motion-safe:transition-transform motion-safe:ease-out
${value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"}`}
>
<span
className={`inline-block h-[20px] w-[20px] transform rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.2),0_1px_1px_rgba(0,0,0,0.06)]
motion-safe:transition-transform motion-safe:duration-200
motion-safe:ease-[cubic-bezier(0.32,0.72,0,1)]
${value ? "translate-x-[22px]" : "translate-x-[3px]"}`}
/>
</button>
</div>
);
}
function TestResultPanel({ test }: { test: TestState }) {
if (test.state === "idle") return null;
if (test.state === "loading") {
return (
<span className="inline-flex items-center gap-2 text-[13px] text-[#5a5d5a]">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-[#d4d9d3] border-t-[#1a4d2e] motion-reduce:animate-none" />
Testing connection
</span>
);
}
if (test.state === "ok") {
return (
<div className="flex flex-1 items-start gap-2 rounded-lg border border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.04] px-3 py-2 text-[13px]">
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-white">
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden>
<path
d="M2 6.5L5 9.5L10 3.5"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<p className="text-[#1a4d2e]">
Connected to <span className="font-semibold">&ldquo;{test.sheetName}&rdquo;</span>{" "}
<span className="text-[#3a6649]"> {test.columnCount} columns loaded</span>
</p>
</div>
);
}
return (
<div className="flex flex-1 items-start gap-2 rounded-lg border border-[#a4452b]/25 bg-[#a4452b]/[0.04] px-3 py-2 text-[13px]">
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#a4452b] text-white">
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden>
<path
d="M3 3L9 9M9 3L3 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
<p className="text-[#a4452b]">{test.message}</p>
</div>
);
}
// ── Backfill card ──────────────────────────────────────────────────────────
type BackfillState = {
running: boolean;
range: "all" | "30d" | "7d";
result: EnqueueBackfillResult | null;
};
function BackfillCard({
id,
state,
onRun,
onRangeChange,
}: {
id?: string;
state: BackfillState;
onRun: () => void;
onRangeChange: (value: "all" | "30d" | "7d") => void;
}) {
const result = state.result;
// Success block — counts from the inline drain that ran after enqueue.
const showResult = result && result.success;
const showError = result && !result.success;
return (
<Card
id={id}
title="Backfill historical entries"
subtitle="Push existing water log entries to your Smartsheet. Safe to re-run — already-pushed entries are deduped by Entry ID."
>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="smartsheet-backfill-range"
className="text-sm font-medium text-[#1d1d1f]"
>
Range
</label>
<select
id="smartsheet-backfill-range"
value={state.range}
onChange={(e) =>
onRangeChange(e.target.value as "all" | "30d" | "7d")
}
className="min-h-[40px] rounded-lg border border-[#d4d9d3] bg-white px-3 py-2
text-[14px] text-[#1d1d1f]
outline-none motion-safe:transition-[border-color,box-shadow] motion-safe:duration-150
focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/15
disabled:opacity-50"
disabled={state.running}
>
<option value="all">All time</option>
<option value="30d">Last 30 days</option>
<option value="7d">Last 7 days</option>
</select>
<button
type="button"
onClick={onRun}
disabled={state.running}
className="inline-flex min-h-[40px] items-center justify-center rounded-lg border border-[#1a4d2e] bg-white px-4 py-2
text-[14px] font-semibold text-[#1a4d2e]
motion-safe:transition-[background-color,color,transform] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#1a4d2e] hover:text-white
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
active:scale-[0.97]
disabled:opacity-50 disabled:cursor-not-allowed"
>
{state.running ? (
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none" />
Running backfill
</span>
) : (
"Run backfill"
)}
</button>
</div>
{state.running && (
<p className="text-xs text-[#5a5d5a]">
Counting entries, enqueueing, and draining one batch inline. Any
remaining entries will be drained by the cron (every 15 minutes).
</p>
)}
{showError && (
<div
role="alert"
aria-live="assertive"
className="flex items-start gap-2.5 rounded-lg border border-[#a4452b]/25 bg-[#a4452b]/[0.05] px-3 py-2.5 text-[13px]"
>
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#a4452b] text-white">
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5">
<path
d="M3 3L9 9M9 3L3 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
<p className="font-medium leading-snug text-[#7a341f]">
{result.error}
</p>
</div>
)}
{showResult && result && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2.5 rounded-lg border border-[#1a4d2e]/25 bg-[#1a4d2e]/[0.04] px-3 py-2.5 text-[13px]"
>
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#1a4d2e] text-white">
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5">
<path
d="M2 6.5L5 9.5L10 3.5"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<div className="text-[#143d24]">
<p className="font-medium leading-snug">
Found {result.considered}{" "}
{result.considered === 1 ? "entry" : "entries"} matching the
range
{result.considered > 0 && (
<>
{" "}
({result.newlyQueued} new, {result.alreadyQueued} already
queued).
</>
)}
</p>
{(result.drained.synced +
result.drained.skipped +
result.drained.failed >
0 || result.drained.remaining > 0) && (
<p className="mt-1 leading-snug text-[#1a4d2e]/85">
Inline drain: {result.drained.synced} synced /{" "}
{result.drained.skipped} skipped / {result.drained.failed}{" "}
failed.
{result.drained.remaining > 0 && (
<>
{" "}
<strong className="font-semibold text-[#1a4d2e]">
{result.drained.remaining} still queued
</strong>{" "}
they&apos;ll drain on the next cron tick (within 15
minutes).
</>
)}
{result.drained.remaining === 0 &&
result.considered > 0 && (
<> All entries are now in Smartsheet.</>
)}
</p>
)}
</div>
</div>
)}
</div>
</Card>
);
}
// ── 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="smartsheet-fade-in fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 backdrop-blur-[6px]
sm:items-center sm: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-[92vh] w-full max-w-2xl overflow-y-auto rounded-t-2xl border border-[#d4d9d3]
bg-[#fdfaf2] shadow-[0_-12px_40px_-12px_rgba(15,23,18,0.2),0_24px_60px_-20px_rgba(15,23,18,0.25)]
smartsheet-sheet-up
sm:rounded-2xl"
>
{/* Header */}
<header className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3]/80 bg-[#fdfaf2]/95 px-6 py-4 backdrop-blur-sm">
<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-[22px] font-medium leading-tight tracking-tight text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Connect a Smartsheet
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="-mr-2 -mt-1 inline-flex h-9 w-9 items-center justify-center rounded-full
text-[#5a5d5a]
motion-safe:transition-colors motion-safe:duration-150
hover:bg-[#f0ebde] hover:text-[#1d1d1f]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
active:scale-95 motion-safe:transition-transform motion-safe:ease-out"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5"
aria-hidden
>
<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 gap-2 border-t border-[#d4d9d3]/80 bg-[#fdfaf2]/95 px-6 py-3 backdrop-blur-sm">
<button
type="button"
onClick={onClose}
className="inline-flex min-h-[40px] items-center justify-center rounded-lg bg-[#1a4d2e] px-5
text-[14px] font-semibold tracking-tight text-white
shadow-[0_1px_0_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.06)]
motion-safe:transition-[background-color,transform] motion-safe:duration-150 motion-safe:ease-out
hover:bg-[#143d24]
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/30
active:scale-[0.97] active:bg-[#0f2e1c]"
>
Got it
</button>
</footer>
</div>
</div>
);
}