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.tsComponent 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
| Action | Behaviour |
|---|---|
SET | Accepts 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. |
UNDO | Pops the last entry from past, moves present to future[0]. |
REDO | Pops 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 iframeExample 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:
| Component | subSection values | DOM anchor IDs |
|---|---|---|
ContentSection | listing-fields, tags, custom-fields | finder-builder__subsection--listing-fields, finder-builder__subsection--custom-fields |
SearchFiltersSection | search, filters, results, advanced-logic | finder-builder__subsection--search, …--filters, …--results, …--advanced-logic |
MapsSection | base-map | finder-builder__subsection--base-map |
LocalizationSection | language | finder-builder__subsection--language |
PublishSection | security, export | finder-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>;
}Nav groups
Sections are organized into four groups (BuilderSidebar.tsx:62–109):
| Group | Items |
|---|---|
| Setup | Locations, Listing content |
| How it behaves | Search, Filters & sort, Results behavior, Refinements Builder |
| Look & feel | Theme & colors, Typography, Shape, Layout, Map style, Language & copy |
| Launch | Authorized domains, Embed code |
State glyphs
Each item and group header shows a StateGlyph indicating completion. The glyph states are:
| State | Visual | Condition |
|---|---|---|
default | Hollow dot | Not yet visited, or all sections visited (glyphs reset) |
ok | Check mark | Visited + required data present |
warn | Exclamation | Visited 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
| Type | Direction | When sent |
|---|---|---|
lf-preview:init | Next.js → iframe | On iframe onLoad, 60 ms after load (to give the Svelte widget time to attach its listener) |
lf-preview:update | Next.js → iframe | Debounced 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:
| Export | Purpose |
|---|---|
APPEARANCE_THEME_PRESETS | Array 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
| Constant | Value | Source |
|---|---|---|
HISTORY_LIMIT | 50 | finderBuilderConfig.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_WIDTH | 1440 | LivePreview.tsx:86 |
DESKTOP_PREVIEW_HEIGHT | 980 | LivePreview.tsx:87 |
| postMessage debounce | 160 ms | LivePreview.tsx:139 |
| iframe init delay | 60 ms | LivePreview.tsx:277 |