@geoseal/react-views
v0.6.0
Published
Embeddable React components for Geoseal Views — visit proof, shift report, live location, fleet map, route replay, signal integrity, and the write-capable place editor, availability editor, and approvals queue — each rendered from a single signed Geoseal
Maintainers
Readme
@geoseal/react-views
Drop-in React components for the nine Geoseal Views:
<GeosealVisit>— a natively-rendered visit proof view: Mapbox map with the facility's two geofence rings, the worker's location track, a verified-presence event timeline, and the AI closeout summary.<GeosealShiftReport>— the hosted per-shift proof page (route replay, KPIs, tracked-% bar, four-source timesheets) in a sandboxed iframe.<GeosealLive>— the hosted live location page for a shift (pulsing current-position marker inside the geofence) in a sandboxed iframe.<GeosealFleet>— the hosted app-wide live map: every tracked subject as a pin, the watched places and their rings, and on-site / outside / offline counts.<GeosealReplay>— the hosted route playback for one shift: the trail as a polyline with play / pause / speed / scrub and an event timeline.<GeosealIntegrity>— the hosted signal-quality table: tracking health per subject, worst-first, with severity chips (no map).<GeosealPlaceEditor>— the hosted, write-capable geofence editor for a place (draggable center, radius rings, polygon fences, hours & closures) in a sandboxed iframe. Saves land in Geoseal with full audit.<GeosealAvailability>— the hosted, write-capable availability editor for one subject: the weekly local-time windows they can work, plus dated exceptions. A mirror of your scheduler, never a gate on a shift.<GeosealApprovals>— the hosted, write-capable approvals queue: pending timesheet proposals and open dispute threads, actioned through the same RPCs the Geoseal console uses.
All nine render from a single signed, time-boxed, de-identified embed token
(etok_…) that you mint server-side (see Mint a token).
The six view components need settings.embed_views_enabled = true on your App.
Each write component has its own switch instead — embed_editor_enabled,
embed_availability_editor_enabled (which also needs the
availability_enabled domain flag), and embed_approvals_enabled — so
turning on one never turns on another. Everything is off by default.
New — v0.6.0. Two write-capable views:
<GeosealAvailability>(one subject's weekly availability + dated exceptions) and<GeosealApprovals>(the pending timesheet-proposal / dispute queue), plus the"availability_editor"and"approvals"kinds onbuildEmbedUrl(). Both mint with the editor posture (actor+allowed_originsrequired, 900s default TTL / 3600s max) and each has its own feature flag.onSavednow receives anEmbedSavedDetailnaming what was written ({ resource, id }) — an existingonSaved={() => …}handler is unaffected.v0.5.0. Appearance v2:
appearance.brand(logo, corner-radius scale, font stack) andappearance.locale(en/es), serialized as theradius/font/localeembed params. Views also inherit the brand preset saved on your App, so you can brand once instead of per-iframe — anything you pass here still wins for that embed. The logo is the exception: it has no URL param, so the hosted views take it from the App preset only.v0.4.0. Three read views:
<GeosealFleet>(app-wide live map),<GeosealReplay>(per-shift route playback),<GeosealIntegrity>(signal quality table) — andbuildEmbedUrl()gains the"fleet","replay", and"integrity"kinds. Fleet tokens are App-scoped, so their mint requiresallowed_originsand is capped at a 3600s TTL.v0.3.0.
<GeosealPlaceEditor>— the first write-capable embed — withonSaved/onDirtyChangehost events, and the"place_editor"kind.v0.2.0. Appearance API (
appearanceprop: theme / accent / basemap / hide) on every component,<GeosealLive>, auto-height iframes, host events (onReady/onError/onStateChange), andbuildEmbedUrl()for hand-rolled iframes. Thethemeprop on<GeosealVisit>is deprecated in favor ofappearance.theme.Published versions: as of 2026-08-04 (UTC) the npm
latesttag is 0.5.0 (npm view @geoseal/react-views version; published: 0.1.0, 0.1.1, 0.2.0, 0.3.0, 0.4.0, 0.5.0) — 0.6.0 is this source tree and is not on npm yet, sonpm install @geoseal/react-viewsstill resolves to the Appearance-v2 release, without the availability and approvals components.
Install
npm install @geoseal/react-views mapbox-gl
# react and react-dom (>=18) are peer dependencies you already havemapbox-gl is a peer dependency — <GeosealVisit> uses the publishable
Mapbox token returned inside the view payload, so you do not configure Mapbox
yourself. The iframe components don't touch it at all.
Usage
import {
GeosealVisit,
GeosealShiftReport,
GeosealLive,
GeosealFleet,
GeosealReplay,
GeosealIntegrity,
GeosealPlaceEditor,
GeosealAvailability,
GeosealApprovals,
} from "@geoseal/react-views";
// Visit proof (native render)
<GeosealVisit token={etok} live appearance={{ theme: "auto", accent: "6d28d9" }} />
// Shift report (hosted iframe, auto-sizes to content)
<GeosealShiftReport token={etok} appearance={{ theme: "light" }} />
// Live location (hosted iframe; token minted with kind "live")
<GeosealLive
token={etok}
appearance={{ theme: "dark", accent: "6d28d9", basemap: "satellite" }}
onStateChange={(live) => setBadge(live ? "On shift" : "Ended")}
/>
// Fleet map (hosted iframe, app-wide; token minted with kind "fleet")
<GeosealFleet token={etok} appearance={{ theme: "dark", basemap: "dark" }} />
// Route replay for a shift (hosted iframe; token minted with kind "replay")
<GeosealReplay token={etok} onStateChange={(live) => setFollowing(live)} />
// Signal integrity table (hosted iframe, app-wide; token minted with kind "integrity")
<GeosealIntegrity token={etok} appearance={{ theme: "light" }} />
// Branded + localized (any component): logo, radius scale, font, language
<GeosealShiftReport
token={etok}
appearance={{
accent: "6d28d9",
brand: { logoUrl: "https://cdn.example.com/logo.svg", radius: "round" },
locale: "es",
}}
/>
// Place editor (hosted iframe, WRITE-capable; token minted with kind "place_editor")
<GeosealPlaceEditor
token={etok}
appearance={{ theme: "auto" }}
onDirtyChange={(dirty) => setUnsaved(dirty)}
onSaved={() => toast("Geofence updated")}
/>
// Availability for one subject (hosted iframe, WRITE-capable;
// token minted with kind "availability_editor")
<GeosealAvailability
token={etok}
onDirtyChange={(dirty) => setUnsaved(dirty)}
onSaved={() => refetchAvailability()}
/>
// Approvals queue (hosted iframe, app-wide, WRITE-capable;
// token minted with kind "approvals")
<GeosealApprovals
token={etok}
onSaved={({ resource }) => refetchQueue(resource)}
/>That is the whole integration: pass the etok_… token, get a view.
The appearance prop (all components)
| Field | Type | Default | Notes |
| --------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------- |
| theme | 'light' \| 'dark' \| 'auto' | per view | auto follows prefers-color-scheme. Visit defaults dark, shift report light; every other view defaults to auto. |
| accent | string | Geoseal teal | 6-digit hex, # optional (e.g. "6d28d9"). Colors buttons, chips, KPI highlights, the track line, and the live marker. Invalid values are ignored. |
| basemap | 'auto' \| 'streets' \| 'dark' \| 'satellite' | 'auto' | auto matches the resolved theme. |
| hide | string[] | [] | Sections to hide, per view: visit — header, timeline, closeout; shift report — header, kpis, timesheets, activity; fleet — header, counts; replay — header, timeline, playback; integrity — header, footnote; approvals — header, counts; live / place editor / availability — header. Unknown values are ignored; the "Powered by GeoSeal" footer is not hideable. |
| brand | GeosealBrand | — | Logo / radius / font tokens (see below). |
| locale | 'en' \| 'es' | 'en' | Language for the view's own strings and its date/time formatting. A string without a translation falls back to English, never a lookup key. Unset, <GeosealVisit> renders English strings and keeps the viewer's own runtime time format. |
brand fields, all optional:
| Field | Type | Default | Notes |
| --------- | ------------------------------------- | ---------- | --------------------------------------------------------------------------- |
| logoUrl | string | — | <GeosealVisit> only (it renders in your own page). Shown in the view header at most 120x32 (object-fit: contain); must be https: and ≤512 chars — data: and plain-http URLs are dropped. The hosted iframe views take a logo from your App's brand preset instead: it is the one appearance field with no URL param, so holding an embed link can never change whose mark a GeoSeal page shows. The "Powered by GeoSeal" attribution stays either way. |
| radius | 'sharp' \| 'soft' \| 'round' | 'soft' | Corner-radius scale for cards, chips, and buttons. soft is the stock geometry. |
| font | 'system' \| 'mono' | 'system' | System stacks only — the hosted pages fetch no webfont (strict CSP). |
On the iframe components the appearance is serialized onto the embed URL; on
<GeosealVisit> it drives the native renderer directly.
Precedence. A field you set here always wins, then the brand preset saved
on your App (set once in the GeoSeal console), then the built-in default. So an
App-level preset re-brands every embed without touching a single iframe URL,
and an existing URL keeps rendering exactly as it did before the preset
existed. The hosted pages apply the preset themselves; <GeosealVisit> reads
it off the payload (EmbedView.appearance, {} when unset) and merges it the
same way — so custom renderers built on useEmbedView can honor it too.
<GeosealVisit> props
| Prop | Type | Default | Notes |
| --------------------- | ----------------------- | ------------------- | ------------------------------------------------------------ |
| token | string | — | Required. The etok_… Geoseal Views token. |
| baseUrl | string | live functions host | Override to target a different Geoseal deployment. |
| live | boolean | false | Poll for updates while the visit is open. |
| pollIntervalSeconds | number | 15 | Poll cadence when live. Stops automatically once closed. |
| height | number \| string | 520 | Overall height of the view. |
| appearance | GeosealAppearance | — | Theme / accent / basemap / hide / brand / locale (see above). |
| theme | 'dark' \| 'light' | 'dark' | Deprecated — use appearance.theme; it wins when set. This prop still outranks the App preset, so saving one never re-themes an integration that pinned a theme here. |
| className / style | — | — | Applied to the root element. |
Hosted read-view props
<GeosealShiftReport>, <GeosealLive>, <GeosealFleet>, <GeosealReplay>,
and <GeosealIntegrity> take the same iframe props:
| Prop | Type | Default | Notes |
| --------------------- | --------------------------- | ---------------------- | ---------------------------------------------------------------------- |
| token | string | — | Required. Minted with the component's kind (see below). |
| embedOrigin | string | https://geoseal.dev | Hosted embed origin. |
| appearance | GeosealAppearance | — | Serialized onto the embed URL. |
| autoHeight | boolean | true | Follow the page's resize messages. An explicit height disables it. |
| height | number \| string | auto (per-view start) | Fixed height; opts out of autoHeight. |
| onReady | () => void | — | The page rendered its payload. |
| onError | (code: string) => void | — | e.g. expired_embed_token, revoked_embed_token, origin_not_allowed. |
| onStateChange | (live: boolean) => void | — | Live-ness changed (shift started / ended, visit closed). Shift report, live, and replay only — fleet and integrity have no single live-ness. |
| title | string | per component | iframe title for assistive tech. |
| className / style | — | — | Applied to the iframe. |
| Component | Mint kind | Scope | Starting height | Refresh |
| --------------------- | -------------- | -------------- | --------------- | ---------- |
| <GeosealShiftReport>| shift_report | one shift | 720 | on load |
| <GeosealLive> | live | one shift | 480 | ~5s while live (60s once it ends) |
| <GeosealFleet> | fleet | the whole App | 560 | ~10s |
| <GeosealReplay> | replay | one shift | 640 | ~15s while live |
| <GeosealIntegrity> | integrity | the whole App | 480 | ~60s |
<GeosealFleet> and <GeosealIntegrity> are App-scoped — one token covers
everyone currently tracked, not a single shift. Fleet is the widest surface in
the set, so its mint is held to the editor's posture: allowed_origins is
required and the TTL defaults to 900s (max 3600s). Pass ops_group at mint
time to narrow the map to one operational group. Both cap and de-identify what
they return — fleet serves at most 200 subjects (truncated: true when it
clips) and 50 places, and no App or external IDs cross the wire.
<GeosealReplay> serves the same trail as the shift report, clamped to the
shift window ± 45 min and capped at 2000 points.
<GeosealPlaceEditor> props
Same iframe props as above (token, embedOrigin, appearance, autoHeight,
height — starts at 640, onReady, onError, title, className / style),
minus onStateChange (an editor has no live-ness), plus the write callbacks:
| Prop | Type | Notes |
| --------------- | ----------------------------------- | ------------------------------------------------------------------ |
| onSaved | (detail: EmbedSavedDetail) => void| A save round-tripped and persisted (refresh your own UI, webhooks already fired). detail is { resource?, id? } — resource: "place", plus id on a create (the only write that names a new row; an edit sends resource alone). |
| onDirtyChange | (dirty: boolean) => void | Unsaved edits appeared (true) or were saved/discarded (false) — e.g. warn before navigating away. |
onError additionally surfaces embed_editor_disabled when the Place Editor
flag is off for the App.
A place_editor token minted without place_id opens the editor in
create mode — map-drop or address search, name, rings — and onSaved
fires with detail.id set to the place it created. Create and edit tokens are
not interchangeable: the mode is fixed in the token's claims at mint time.
The callbacks are driven by the hosted pages' postMessage envelope
({ source: "geoseal", version: 1, type, … }), which carries no view or place
data — only lifecycle signals (ready, resize, error, state, saved,
dirty).
<GeosealAvailability> and <GeosealApprovals> props
Both take the same iframe props as <GeosealPlaceEditor> (token,
embedOrigin, appearance, autoHeight, height, onReady, onError,
title, className / style). What differs is the kind they are minted
with, what that kind covers, and which write callbacks fire:
| Component | Mint kind | Scope | Starting height | Write callbacks |
| ----------------------- | ---------------------- | --------------- | --------------- | --------------------------- |
| <GeosealAvailability> | availability_editor | one subject | 640 | onSaved, onDirtyChange |
| <GeosealApprovals> | approvals | the whole App | 720 | onSaved only |
<GeosealApprovals> has no onDirtyChange: a queue has no draft state — each
approve / dismiss / resolve is explicit and immediate, and fires onSaved
with the resource that moved ({ resource: "proposal" | "thread" } — the same
two words the queue's items are discriminated by).
onError surfaces embed_availability_editor_disabled and
embed_approvals_disabled respectively when the kind's flag is off for the
App — each is a separate switch from embed_views_enabled and from the Place
Editor's, and the availability editor additionally needs the
availability_enabled domain flag.
What an availability token can reach. Exactly one subject's own windows: the de-identified handle, the resolved timezone, the weekly rules, and the dated exceptions. No positions, no shifts, no other subject — and availability is a mirror, so nothing you save here can block a shift, a crossing, or a payroll decision. It only feeds an additive warning signal.
What an approvals token can do. Take payroll-affecting actions for the
whole App. Every action is dispatched to the same RPC the Geoseal console
calls and is audited as embed:<actor>, so it is reviewable next to console
actions — but treat the link like the console session it stands in for: scope
allowed_origins tightly and keep the TTL short.
The useEmbedView hook
Render it yourself if you don't want the built-in UI:
import { useEmbedView } from "@geoseal/react-views";
const { data, loading, error, refetch } = useEmbedView(etok, { live: true });
// data is a fully-typed EmbedView (or null); error.kind tells you why (expired,
// not_found, network, server).useShiftReport does the same for the shift-report payload.
The iframe alternative
If you don't want a React dependency at all, embed the hosted page directly —
buildEmbedUrl builds the exact URL the components use, appearance included:
import { buildEmbedUrl } from "@geoseal/react-views";
buildEmbedUrl("live", "etok_…", { appearance: { theme: "dark", accent: "6d28d9" } });
// -> "https://geoseal.dev/embed/live/etok_…?theme=dark&accent=6d28d9"
buildEmbedUrl("fleet", "etok_…");
// -> "https://geoseal.dev/embed/fleet/etok_…"
buildEmbedUrl("shift_report", "etok_…", {
appearance: { brand: { radius: "round", font: "mono" }, locale: "es" },
});
// -> "…/embed/shift-report/etok_…?radius=round&font=mono&locale=es"
buildEmbedUrl("availability_editor", "etok_…", { appearance: { locale: "es" } });
// -> "https://geoseal.dev/embed/availability/etok_…?locale=es"Appearance params, in the order they are serialized: theme, accent,
basemap, hide, radius, font, locale. brand.logoUrl is not among
them — the hosted pages take a logo from the App preset only — and any param
you leave out falls through to that preset.
Kinds: "visit", "shift_report", "live", "fleet", "replay",
"integrity", "place_editor", "availability_editor" (page:
/embed/availability/…), "approvals" — each maps to its hosted page under
/embed/…. The kind must match the kind the token was minted with; a
mismatch fails closed with wrong_embed_kind.
<iframe
src="https://geoseal.dev/embed/visit/etok_…?theme=dark"
width="100%"
height="520"
style="border:0;border-radius:12px"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
title="Geoseal — live shift"
></iframe>Mint a token
A token authorizes exactly one visit, shift, place, or subject — or, for
the App-scoped fleet, integrity, and approvals kinds, one slice of your
own App — is time-boxed and revocable, and carries no other account access.
Mint it server-side with your secret key (sk_, never in the browser):
# Visit proof
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "visit_id": "vis_…", "ttl_seconds": 3600 }'
# Live location for a shift
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "shift_ref": "SI-1234", "kind": "live", "ttl_seconds": 14400 }'
# -> { "token": "etok_…", "embed_url": "https://geoseal.dev/embed/live/etok_…", "expires_at": "…" }
# Route replay for a shift
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "shift_ref": "SI-1234", "kind": "replay", "ttl_seconds": 3600 }'
# Fleet map (App-scoped, so stricter): "allowed_origins" is REQUIRED and the
# TTL defaults to 900s, max 3600s. "ops_group" is optional and narrows the map.
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "kind": "fleet", "allowed_origins": ["https://app.example.com"],
"ops_group": "north-region", "ttl_seconds": 900 }'
# Signal integrity table (App-scoped, normal read TTL)
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "kind": "integrity", "ttl_seconds": 3600 }'
# Place editor (write-capable, so stricter): "actor" — your user's reference,
# recorded on every audit entry — and "allowed_origins" are REQUIRED.
# TTL defaults to 900s, max 3600s.
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "place_id": "place_…", "kind": "place_editor", "actor": "jane@yourapp",
"allowed_origins": ["https://app.example.com"], "ttl_seconds": 900 }'
# Place editor in CREATE mode: the same kind with NO "place_id".
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "kind": "place_editor", "actor": "jane@yourapp",
"allowed_origins": ["https://app.example.com"] }'
# Availability editor (write-capable, scoped to ONE subject): same required
# "actor" + "allowed_origins", same 900s/3600s TTL.
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "subject_id": "sub_…", "kind": "availability_editor", "actor": "jane@yourapp",
"allowed_origins": ["https://app.example.com"], "ttl_seconds": 900 }'
# Approvals queue (write-capable, App-scoped — its actions move payroll, so
# keep the origin list tight and the TTL short).
curl -X POST https://api.geoseal.dev/functions/v1/v1-embed/embed/tokens \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "kind": "approvals", "actor": "jane@yourapp",
"allowed_origins": ["https://app.example.com"], "ttl_seconds": 900 }'Hand the returned token to the matching component. To kill a live link,
revoke the token server-side (DELETE /v1/embed/tokens/{jti}) and the next
load fails closed. You can also pin a view token to your own site at mint time
with allowed_origins — other embedders get origin_not_allowed; on fleet
and the three write kinds it is not optional. Every write is audited exactly
like the console edit it stands in for, attributed embed:<actor>.
Full reference: docs/embed-views.md.
What <GeosealVisit> renders
The component fetches GET {baseUrl}/v1-embed/embed/visits/{token} (public, no
auth) and shows:
- Map — the facility with its
facility_radius_m(inner) andperimeter_radius_m(outer) geofence rings, the worker's track as a line, and a marker at the latest fix (pulsing while live). - Timeline — arrival → dwell → departure using the Geoseal presence colors, with any tracking outages folded in.
- AI closeout — the closeout summary card when present. When the tenant has
not opted into names, the narrative is withheld and only the structured
confidence is shown (
closeout.redacted).
Graceful loading, error, and expired / revoked states are built in.
License
Apache-2.0
