These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Next.js — Finder Builder v2

The Finder Builder v2 is the largest single feature in the Next.js codebase. It lives at src/components/finders/v2/ and provides a three-column editing interface (sidebar / editor panel / live preview) that lets workspace users configure every aspect of a finder widget without writing code. All config mutations flow through a local history reducer so undo/redo works entirely in the browser without touching global stores.

See also: routing for how the builder is mounted, state management for the Zustand stores the builder reads but does not own, data-flow-builder for the full save → publish → CDN pipeline, and the API reference for the finders endpoint family.


File map

src/components/finders/v2/ ├── FinderBuilderV2.tsx # Root orchestrator — route → fetch → render ├── FinderBuilderV2.module.scss ├── BuilderSidebar.tsx # Grouped section nav with state glyphs ├── BuilderSidebar.module.scss ├── LivePreview.tsx # Iframe + postMessage bridge to Svelte widget ├── LivePreview.module.scss ├── finderBuilderConfig.ts # V2Config type, historyReducer, finderToV2Config, v2ConfigToPayload ├── previewPayload.ts # buildFinderPreviewPayload — serializes state for the iframe ├── appearanceThemes.ts # 16 theme presets + WCAG helpers ├── localizationConfig.ts # Language/copy override structure ├── importFlow/ # Self-contained location-import wizard └── sections/ ├── LocationsSection.tsx ├── ContentSection.tsx ├── ThemeSection.tsx # was DesignSection — split in the Look & Feel revamp ├── TypographySection.tsx ├── ShapeSection.tsx ├── LayoutSection.tsx ├── SearchFiltersSection.tsx # covers Search, Filters, Results, Refinements subsections ├── MapsSection.tsx ├── LocalizationSection.tsx ├── PublishSection.tsx # covers Authorized Domains + Embed Code subsections ├── RefinementsSection.tsx # advanced rules builder — embedded in SearchFiltersSection ├── FieldManagementComposer.tsx ├── AssignmentMultiSelect.tsx ├── _shared/ # designHelpers, shared sub-components └── locationsSection.utils.ts

Component hierarchy

FinderBuilderV2 ├── BuilderSidebar (left column) ├── [section component] (center column — switched by renderSection()) │ ├── LocationsSection │ ├── ContentSection (subSection: listing-fields | tags | custom-fields) │ ├── SearchFiltersSection (subSection: search | filters | results | advanced-logic) │ │ └── RefinementsSection (embedded for advanced-logic) │ ├── ThemeSection │ ├── TypographySection │ ├── ShapeSection │ ├── LayoutSection │ ├── MapsSection │ ├── LocalizationSection │ └── PublishSection (subSection: security | export) └── LivePreview (right column, ref={previewRef})

FinderBuilderV2 takes a Props shape of { finder, finderId, initialSection, locations } and is a pure client component ('use client'). It is mounted by the Next.js page at /app/finders/[id]/[section]/page.tsx (see routing).


V2Config state shape

Defined in src/components/finders/v2/finderBuilderConfig.ts:99.

type V2Config = { meta: { name: string; token: string }; design: { colors, fonts, borderRadius, localization }; refinements: { filterOptions: V2FilterOption[]; sortOptions: V2SortOption[]; advancedRules: V2AdvancedRule[] }; listings: { showStreet, showCity, …, customFields, cardComposerFields, cardLayout }; behavior: { appLayout, filters, radius, unit, searchMode, autocompleteProvider, map, … }; publish: { authorizedUrls: Array<{ url: string }>; maps: number[] }; };

The complete type (with all nested fields) is at finderBuilderConfig.ts:99–203.

finderToV2Config(finder) (finderBuilderConfig.ts:686) converts the raw Finder API record into a V2Config at mount time. It merges finder.settings (a flat { name, value }[] array) with DEFAULT_SETTINGS using deepMerge, then re-hydrates complex blobs (custom fields, field composer, localization overrides, refinement rules) from dedicated settings rows by name key. The inverse is v2ConfigToPayload(config) (finderBuilderConfig.ts:815), which flattens V2Config back to the FinderPayload shape expected by PUT /finders/:id.


historyReducer: undo/redo without a global store

Source: src/components/finders/v2/finderBuilderConfig.ts:665

export type HistoryState = { past: V2Config[]; present: V2Config; future: V2Config[] }; export type HistoryAction = | { type: 'SET'; payload: V2Config | ((c: V2Config) => V2Config) } | { type: 'UNDO' } | { type: 'REDO' };

Action semantics

ActionBehaviour
SETAccepts a new config object or an updater function (c) => c. Performs a JSON-stringify equality check; no-ops if the config is unchanged. Pushes present onto past, truncates future, respects HISTORY_LIMIT = 50.
UNDOPops the last entry from past, moves present to future[0].
REDOPops future[0], pushes present to past.

Wiring in FinderBuilderV2

// FinderBuilderV2.tsx:229–233 const [history, dispatch] = useReducer(historyReducer, { past: [], present: initialConfig, future: [], }); const config = history.present;

The update helper (FinderBuilderV2.tsx:353) is a useCallback that wraps dispatch({ type: 'SET', payload }):

const update = useCallback( (payload: V2Config | ((c: V2Config) => V2Config)) => dispatch({ type: 'SET', payload }), [] );

This update function is the only mutation entry-point passed down to section components. Every section receives { config, update } as props — sections never call dispatch directly.

Undo/redo are exposed as plain functions and wired to keyboard shortcuts:

// FinderBuilderV2.tsx:372–373 const undo = () => dispatch({ type: 'UNDO' }); const redo = () => dispatch({ type: 'REDO' });

Cmd/Ctrl+Z triggers undo; Cmd/Ctrl+Y or Cmd/Ctrl+Shift+Z triggers redo (FinderBuilderV2.tsx:433–441). The header Undo/Redo icon buttons are disabled when history.past.length === 0 or history.future.length === 0 respectively (FinderBuilderV2.tsx:683–699).

⚠️ Warning: Do not add a secondary Zustand store or React context to hold finder builder config. All mutable builder state must go through dispatch so the history stack stays consistent.


Dispatch pattern: UI → dispatch → historyReducer → state

Every user interaction that changes the finder follows this chain:

User action → section component local handler → update(fn) [= dispatch({ type: 'SET', payload: fn })] → historyReducer: push present → past, compute new present → React re-render with new history.present → useMemo recomputes currentPayload + draftPreviewPayload → LivePreview useEffect fires → postMessage to iframe

Example from SearchFiltersSection

// sections/SearchFiltersSection.tsx:104–106 const set = <K extends keyof V2Config['behavior']>(key: K, val: V2Config['behavior'][K]) => update((current) => ({ ...current, behavior: { ...current.behavior, [key]: val } }));

A toggle that changes searchMode:

onClick={() => set('searchMode', 'natural_language')}

This calls update((c) => ({ ...c, behavior: { ...c.behavior, searchMode: 'natural_language' } })), which dispatches SET with an updater function. The reducer calls the function with state.present to produce next, then returns the new history state.

Example from ThemeSection

// sections/ThemeSection.tsx:68–78 const setColor = useCallback( (key: keyof ColorSystem, value: string) => { update((current) => ({ ...current, design: { ...current.design, colors: { ...current.design.colors, [key]: value } }, })); }, [update] );

💡 Tip: When a section needs to update multiple top-level keys atomically (e.g., refinements + behavior.filters), pass a single updater function that spreads both. This produces one history entry instead of two, keeping undo granular. See SearchFiltersSection.tsx:119–138 for an example.


Section component pattern

Each section receives these props (at minimum):

type SectionProps = { config: V2Config; update: (payload: V2Config | ((c: V2Config) => V2Config)) => void; sectionHeader?: ReactNode; };

sectionHeader is pre-rendered by renderSectionHeader() in FinderBuilderV2.tsx:626–632 and injected so each section can place the page title and description at the top of its scroll container without duplicating that logic.

Multi-subsection sections

Several sections cover multiple sidebar entries and use a subSection prop to scroll to the right DOM anchor:

ComponentsubSection valuesDOM anchor IDs
ContentSectionlisting-fields, tags, custom-fieldsfinder-builder__subsection--listing-fields, finder-builder__subsection--custom-fields
SearchFiltersSectionsearch, filters, results, advanced-logicfinder-builder__subsection--search, …--filters, …--results, …--advanced-logic
MapsSectionbase-mapfinder-builder__subsection--base-map
LocalizationSectionlanguagefinder-builder__subsection--language
PublishSectionsecurity, exportfinder-builder__subsection--security, …--export

renderSection() (FinderBuilderV2.tsx:520) matches the active SectionId to the correct component + subSection value via a switch statement.

Scroll-spy

FinderBuilderV2 runs a scroll-spy listener (FinderBuilderV2.tsx:404–431) that watches the editor panel’s scroll container. When a DOM anchor enters the viewport (within 80 px of the container top), it updates section state to the matching SectionId from the ANCHOR_TO_SECTION reverse map (FinderBuilderV2.tsx:56–68). This keeps the sidebar highlight synchronized with the user’s scroll position.

Programmatic sidebar clicks set programmaticScrollRef.current = true to suppress the spy from firing a redundant state update in response to the animated scroll (FinderBuilderV2.tsx:386–401).


BuilderSidebar

Source: src/components/finders/v2/BuilderSidebar.tsx

The sidebar is a pure-display component — it owns no state and makes no API calls. It receives:

interface BuilderSidebarProps { activeSection: SectionId; onSectionChange: (id: SectionId) => void; locationCount: number; hasAuthorizedUrls: boolean; visitedSections: Set<SectionId>; }

Sections are organized into four groups (BuilderSidebar.tsx:62–109):

GroupItems
SetupLocations, Listing content
How it behavesSearch, Filters & sort, Results behavior, Refinements Builder
Look & feelTheme & colors, Typography, Shape, Layout, Map style, Language & copy
LaunchAuthorized domains, Embed code

State glyphs

Each item and group header shows a StateGlyph indicating completion. The glyph states are:

StateVisualCondition
defaultHollow dotNot yet visited, or all sections visited (glyphs reset)
okCheck markVisited + required data present
warnExclamationVisited but missing required data (only security when !hasAuthorizedUrls)

The allVisited flag resets all glyphs to default once every top-level section has been seen, preventing the sidebar from feeling like a rigid checklist (BuilderSidebar.tsx:162).

The footer counter at the bottom of the sidebar reads N of 14 configured, where “configured” counts only locations (at least one location in the finder) and security (at least one authorized URL) — the two gating requirements for publishing (BuilderSidebar.tsx:160).

SectionId union

// BuilderSidebar.tsx:6–23 export type SectionId = | 'locations' | 'content' | 'fields' | 'tags' | 'custom' | 'search' | 'filters' | 'results' | 'refinements' | 'theme' | 'typography' | 'shape' | 'layout' | 'map' | 'language' | 'security' | 'embed';

fields, tags, and custom are sub-items under content in the sidebar — they share the ContentSection component but scroll to different anchors.


LivePreview: postMessage bridge

Source: src/components/finders/v2/LivePreview.tsx

LivePreview embeds the published Svelte finder widget in an <iframe src="/finder-preview?token=…&previewSession=…"> and keeps it up to date by posting draft config changes over window.postMessage.

Message types

TypeDirectionWhen sent
lf-preview:initNext.js → iframeOn iframe onLoad, 60 ms after load (to give the Svelte widget time to attach its listener)
lf-preview:updateNext.js → iframeDebounced 160 ms after draftPayload changes

Both messages carry the same shape:

{ type: 'lf-preview:init' | 'lf-preview:update', previewSession: string, // UUID generated once per builder session payload: Record<string, unknown>, }

The previewSession token is stable for the lifetime of the builder session (generated at mount, stored in previewSessionRef) and is posted as a URL param on the iframe src. The Svelte widget reads it on boot, fetches the draft from sessionStorage (lf-preview:{previewSession}), and also listens for postMessage updates.

Draft payload persistence

Before posting, LivePreview writes the payload to sessionStorage under the session key (LivePreview.tsx:91–99). This means the Svelte widget can recover the latest draft on iframe reload (e.g., the user refreshes the preview manually) without needing another postMessage.

LivePreviewHandle

FinderBuilderV2 holds a ref to LivePreview via forwardRef:

// FinderBuilderV2.tsx:209 const previewRef = useRef<LivePreviewHandle>(null);

The LivePreviewHandle exposes one method: reload(), which resets iframeRef.current.src to trigger a full iframe reload. This is called by the publish mutation on success (FinderBuilderV2.tsx:485) so the preview immediately reflects the newly published CDN payload.

buildFinderPreviewPayload

Source: src/components/finders/v2/previewPayload.ts:127

Before the payload is posted to the iframe, buildFinderPreviewPayload assembles the full structure the Svelte widget expects:

{ finder: { id, name, token, settings, design, authorized_urls, advanced_refinements, custom_field_definitions, maps }, locations: Location[], // scoped to the active map created: string | null, }

It also normalizes map provider/style/tileset values (normalizePreviewSettings) so the preview widget always resolves a valid map configuration regardless of what the user has half-configured.

The draftPreviewPayload is computed by a useMemo in FinderBuilderV2 (FinderBuilderV2.tsx:336–347) that depends on currentPayload, previewLocations, activeMap, effectiveMapLocationIds, and customFieldDefinitions. Any change to these triggers a recompute, which triggers the 160 ms debounced postMessage.


Save flow

Sources: FinderBuilderV2.tsx:451–470, src/lib/api/finders.ts:29–36

"Save draft" click → saveMutation.mutate() → saveDirty() → updateFinder(finderId, currentPayload) // PUT /finders/:id (with audit headers) → updateMap(activeMapId, { location_ids }) // if pending location changes → on success: savedFingerprint.current = JSON.stringify(currentPayload) setPendingLocationIds(null) queryClient.invalidateQueries(['finder', finderId]) addAlert('Draft saved.', 'success')

savedFingerprint is a useRef<string> that starts as JSON.stringify(v2ConfigToPayload(initialConfig)). The isDirty flag is a simple string comparison (FinderBuilderV2.tsx:349):

const isDirty = JSON.stringify(currentPayload) !== savedFingerprint.current || pendingLocationIds !== null;

isDirty drives the “Unsaved changes” indicator in the header and disables the Save Draft button when already clean.

updateFinder in src/lib/api/finders.ts:29 issues PUT /finders/{id} with auditAction: 'finders.update'. The body is toFinderPayload(data, false), which strips client-only fields before sending.

🔴 [NEEDS CLARIFICATION: updateMap issues a separate PUT /maps/:id call for location-list changes. Confirm whether the backend treats these as an atomic operation or whether a partial failure (finder saved, map not) leaves the system in an inconsistent state.]


Publish flow

Sources: FinderBuilderV2.tsx:473–489, src/lib/api/finders.ts:107–113

"Publish" click → publishMutation.mutate() → guard: publishBlockedReason? → throw (requires authorized URL) → if isDirty: saveDirty() first (auto-save before publish) → syncFinder(finder.token) // POST /ext/finders/details/{token}/sync → on success (FinderPublishResponse): setPendingLocationIds(null) publishedFingerprint.current = savedFingerprint.current previewRef.current?.reload() // iframe hard-reload to see CDN version addAlert(`Finder published · v${data.hash.slice(0, 7)}`)

syncFinder in src/lib/api/finders.ts:107 calls fetchExt (the external-API client) at POST /ext/finders/details/{token}/sync. On the backend, ExternalController::syncFinderDetails builds the complete widget payload, computes a sha1 hash, and uploads two R2 files: an immutable versioned file at finders/{key}/v/{hash}.json and a pointer file at finders/{key}/config.json. See data-flow-builder for the full CDN sync pipeline.

Publish guard

Publish is blocked (publishBlockedReason is non-null) unless the finder has at least one authorized URL (config.publish.authorizedUrls.length > 0). The Publish button is also disabled when !finder.token (no publish token assigned). Neither condition prevents saving drafts.

hasUnpublishedChanges

A separate client-side flag tracks whether the saved draft differs from the last publish in this session:

// FinderBuilderV2.tsx:493–496 const hasUnpublishedChanges = publishedFingerprint.current !== null ? savedFingerprint.current !== publishedFingerprint.current : false;

publishedFingerprint starts as null (meaning: no publish has happened this session, so the header badge is suppressed). After a successful publish it is set to savedFingerprint.current. If the user then saves a new draft, hasUnpublishedChanges becomes true and the “Unpublished changes” indicator appears.


appearanceThemes.ts: theme presets and WCAG helpers

Source: src/components/finders/v2/appearanceThemes.ts

The file exports 16 curated color presets organized into four categories: Light, Bold, Dark, and Warm. Every preset is guaranteed to pass WCAG 2.1 AA contrast for all defined color pairs at time of authoring (verified by inaccessiblePresets filter at the bottom of the file).

Key exports:

ExportPurpose
APPEARANCE_THEME_PRESETSArray of 16 AppearanceThemePreset objects
findMatchingThemePreset(colors)Detects whether current design.colors matches a preset exactly
getThemeAccessibilitySummary(colors)Returns { passesAA, failingPairs, report } for the full color set
getContrastReportByArea(report)Groups the contrast report by UI area (buttons, cards, inputs, tags) for the WCAG detail panel
contrastRatio(fg, bg)Raw WCAG contrast ratio computation
wcagLevel(ratio)Returns 'AAA' / 'AA' / 'AA Large' / 'Fail'

ThemeSection computes accessibilitySummary and accessibilityGroups with useMemo on every color change and renders a live WCAG status card showing passing/failing pairs (ThemeSection.tsx:43–51).


URL synchronization and SECTION_ANCHOR

The active section is reflected in the browser URL without a full navigation:

// FinderBuilderV2.tsx:375–384 window.history.replaceState(null, '', `${builderBasePath}/${section}`);

SECTION_ANCHOR (FinderBuilderV2.tsx:39–52) maps SectionId values to DOM anchor IDs for sidebar-click scrolling. Look & Feel sections (theme, typography, shape, layout) have no anchor entry because they are standalone pages — clicking them replaces the entire center column, not just the scroll position.

SECTION_COMPONENT (FinderBuilderV2.tsx:72–79) maps every SectionId to a component group string. The scroll-spy useEffect re-registers its scroll listener when sectionComponent changes — ensuring the spy’s cached anchor element list stays current when the rendered component switches (FinderBuilderV2.tsx:403–431).


Locations and pending state

Location membership (which locations belong to this finder) is managed via a backing_map_id / MapConfig — separate from the V2Config history. To avoid polluting the history reducer with large location arrays, location changes are tracked separately:

// FinderBuilderV2.tsx:290–291 const [pendingLocationIds, setPendingLocationIds] = useState<Set<number> | null>(null); const [locationDrafts, setLocationDrafts] = useState<Record<number, LocationPayload>>({});

pendingLocationIds is null when no local changes exist (server state is used as the truth). When the user adds or removes locations, the set is populated and contributes to isDirty. On save, updateMap(activeMapId, { location_ids: Array.from(pendingLocationIds) }) is called alongside updateFinder (FinderBuilderV2.tsx:451–459).

locationDrafts holds per-location field edits made inside LocationsSection (e.g., editing a location name inline). These are merged into previewLocations so the preview shows the draft values before saving.


Import flow integration

ImportFlow (src/components/finders/v2/importFlow/ImportFlow.tsx) is a self-contained location-import wizard that can render in two modes controlled by importIsDrawer:

  • Drawer mode (importIsDrawer = true): overlays the editor panel; the sidebar remains visible.
  • Full-canvas mode (importIsDrawer = false): replaces the entire three-column layout.

The import flow is triggered by LocationsSection callbacks and also by the resumeImportBatchId from useProcessStore (for “Continue import” from the Jobs Tray). On import completion, applyProcess is monitored via useProcessMonitor and the location + map queries are invalidated on success (FinderBuilderV2.tsx:274–279).


Key constants

ConstantValueSource
HISTORY_LIMIT50finderBuilderConfig.ts:209
CUSTOM_FIELDS_CONFIG_SETTING'design.listings.customFieldsConfig'finderBuilderConfig.ts:205
FIELD_COMPOSER_CONFIG_SETTING'design.listings.fieldComposerConfig'finderBuilderConfig.ts:206
REFINEMENT_RULES_CONFIG_SETTING'design.refinements.rulesConfig'finderBuilderConfig.ts:207
DESKTOP_PREVIEW_WIDTH1440LivePreview.tsx:86
DESKTOP_PREVIEW_HEIGHT980LivePreview.tsx:87
postMessage debounce160 msLivePreview.tsx:139
iframe init delay60 msLivePreview.tsx:277