@habit.analytics/habit-claims-journey-components
v2.4.0
Published
Visual graph-based journey builder and step-by-step form runner React widgets for claims workflows
Readme
Journey Builder & Form Runner
A linear, step-based form configurator and a self-contained form runner, distributed as two embeddable React widgets.
- Builder (
JourneyBuilderWidget) — configure a journey as ordered Steps → Rows → Fields, define per-step navigation rules (JSON Logic) and optional API calls, plus a Summary panel and Init values. Outputs a portableJourneySpec(v2.0). - Runner (
FormRunnerWidget) — feeds thatJourneySpecto a step-by-step engine that evaluates conditions, optionally calls APIs between steps (delegated to the host), collects answers, and renders a review/summary screen.
v2.0 — breaking change vs v1.x. The visual graph canvas (React Flow /
@xyflow/react) has been removed. Legacy v1.x journeys (nodes/edges) are still accepted and auto-converted on import for read-only consumption, but new journeys are authored against theform_configmodel described below.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Host Application │
│ │
│ ┌──────────────────────────┐ ┌────────────────────────┐ │
│ │ JourneyBuilderWidget │ │ FormRunnerWidget │ │
│ │ │ │ │ │
│ │ specs ──► Catalog │ │ journey ──► Engine │ │
│ │ journey ──► Editor │ │ initialAnswers ──► │ │
│ │ Steps/Rows │ │ prefill │ │
│ │ Fields tab │ │ onStepApiCall ◄──── │ │
│ │ Init / JSON │ │ onStepApiError ◄──── │ │
│ │ Summary │ │ onFileSelect ◄──── │ │
│ │ onSave / onPublish ──► │ │ onSubmit(answers) ──► │ │
│ │ onChange ──► │ │ onSave (draft) ──► │ │
│ └──────────────────────────┘ └────────────────────────┘ │
│ │
│ Both consume `JourneySpec` + types from │
│ `src/types/journey.ts` │
└─────────────────────────────────────────────────────────────┘Tech Stack
| Layer | Technology |
|---|---|
| UI framework | React 18 + TypeScript |
| State management | Zustand (scoped per widget instance, 50-step undo/redo, dirty tracking) |
| Condition engine | json-logic-js |
| Styling | Tailwind CSS (prefixed cj-) + scoped CSS variables (--claims-journey-*) |
| Build tool | Vite |
Peer deps: react, react-dom. The previous @xyflow/react peer dep has been removed in v2.0.
Quick Start
npm install
npm run devDemo app at http://localhost:5173:
/— Builder with sample specs/runner— Runner loaded from the builder's current journey
Installation (as a library)
npm install habit-claims-journey-componentsimport {
JourneyBuilderWidget,
FormRunnerWidget,
} from 'habit-claims-journey-components';
import type {
JourneySpec,
ClaimPropertySpec,
FormConfig,
EnrichedAnswer,
StepApiRequest,
InitApiRequest,
} from 'habit-claims-journey-components';CSS is auto-injected — do not import a separate style.css.
The parent of JourneyBuilderWidget must have an explicit height (the widget fills 100%).
See INTEGRATION.md for full install methods and copy-paste setup.
Usage
JourneyBuilderWidget
import { JourneyBuilderWidget } from 'habit-claims-journey-components';
import type { ClaimPropertySpec, JourneySpec } from 'habit-claims-journey-components';
function BuilderPage({
specs,
existing,
}: {
specs: ClaimPropertySpec[];
existing?: JourneySpec | null;
}) {
return (
<div style={{ width: '100%', height: '100vh' }}>
<JourneyBuilderWidget
specs={specs}
journey={existing}
onSave={(spec) => saveDraft(spec)}
onPublish={(spec) => publish(spec)}
onChange={(spec) => console.log('changed', spec)}
onOpenRunner={() => navigate('/runner')}
readOnly={false}
googleMapsApiKey={import.meta.env.VITE_GOOGLE_MAPS_KEY}
/>
</div>
);
}Props
| Prop | Type | Required | Description |
|---|---|---|---|
| specs | ClaimPropertySpec[] | — | Field specifications shown in the catalog and "Add field" dialogs |
| journey | JourneySpec \| null | — | Existing journey to load on mount. v1.x graph and v2.x form_config both accepted |
| onSave | (spec: JourneySpec) => void | — | Fired by the Save button. Marks the store as clean |
| onPublish | (spec: JourneySpec) => void | — | Fired by the Publish button |
| onChange | (spec: JourneySpec) => void | — | Fired on every config mutation (steps/rows/fields/navigation/summary/init/theme) |
| onUnlockEdit | () => void | — | Shown as "Unlock edit" when readOnly is true |
| onOpenRunner | () => void | — | Shown as a "Preview" button in the header |
| readOnly | boolean | — | Disables editing, hides destructive controls, surfaces the unlock action |
| googleMapsApiKey | string | — | Enables the Google Places address widget in previews |
The builder UI exposes four tabs: Steps, Fields, Init, JSON.
FormRunnerWidget
import { FormRunnerWidget } from 'habit-claims-journey-components';
import type { JourneySpec, StepApiRequest } from 'habit-claims-journey-components';
function RunnerPage({ journey }: { journey: JourneySpec }) {
return (
<FormRunnerWidget
journey={journey}
initialAnswers={{ /* optional pre-fill */ }}
enrichedOutput
onSubmit={(answers) => submitToApi(answers)}
onSave={(partial) => saveDraft(partial)}
onFileSelect={async (file, key) => uploadToS3(file, key)}
onStepApiCall={async (req: StepApiRequest) => {
const res = await fetch(req.url, {
method: req.method,
headers: { 'Content-Type': 'application/json', ...req.headers },
body: req.body ? JSON.stringify(req.body) : undefined,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}}
onStepApiError={(err, req) => trackError(err, req)}
onInitApiCall={async (req) => (await fetch(req.url, { method: req.method, headers: req.headers })).json()}
googleMapsApiKey={import.meta.env.VITE_GOOGLE_MAPS_KEY}
fullScreen
/>
);
}Props
| Prop | Type | Required | Description |
|---|---|---|---|
| journey | JourneySpec | ✅ | The journey to run. v1.x or v2.x |
| onSubmit | (answers: Record<string, unknown>) => void | — | Called when the user confirms the summary screen. Defaults to copying JSON to clipboard |
| onSave | (partial: Record<string, unknown>) => void | — | Called whenever the user reaches a checkpoint mid-journey, suitable for draft persistence |
| onFileSelect | (file: File, questionKey: string) => Promise<string \| UploadResult \| void> \| void | — | Host-side upload handler for upload widgets. Returns a URL or { url, name, ... } |
| onStepApiCall | (req: StepApiRequest) => Promise<unknown> | — | Recommended. Delegates per-step API calls to the host (auth, CORS, retries, telemetry). Return value is stored as that step's api_response and is referenceable from later JSON Logic and the Summary |
| onStepApiError | (err: unknown, req: StepApiRequest) => void | — | Called when a step API call fails. When provided, the runner skips its default error toast. Fires regardless of the on_error policy. Exceptions thrown by this handler are caught and logged — they never crash the runner |
| onInitApiCall | (req: InitApiRequest) => Promise<unknown> | — | Delegates the optional init/prefill API call to the host |
| initialAnswers | Record<string, unknown> | — | Seeds the runner with existing values. The engine walks forward until it hits the first unanswered question, or straight to the summary if everything is filled |
| enrichedOutput | boolean | — | When true, onSubmit receives { [key]: { data, schema } } instead of the flat answers map |
| googleMapsApiKey | string | — | Enables the Google Places address widget |
| fullScreen | boolean | — | Renders the runner full-viewport (otherwise it fills its parent) |
StepApiRequest
type StepApiRequest = {
step_id: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
headers: Record<string, string>;
body?: unknown; // already-interpolated JSON
answers: Record<string, unknown>; // current answers snapshot
};InitApiRequest
type InitApiRequest = {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
headers?: Record<string, string>;
};Step API on_error policy
Each step's navigation.api_call.on_error controls what happens when the call (or onStepApiCall) throws:
| Value | Behavior |
|---|---|
| block (default) | Stay on the current step, surface an error. apiResponses[step_id] is left untouched (preserves any previous successful response) |
| continue | Proceed using the step's navigation rules. api_response for this step stays unset; any previous successful value is preserved |
| goto:<step_id> | Jump straight to the given step (e.g. an error screen), bypassing rules |
onStepApiError is always called when present, regardless of policy. If the handler itself throws, the runner catches the error, logs it (onStepApiError handler threw), and still honors the configured on_error policy.
Data Model — JourneySpec (v2.0)
A JourneySpec is a portable envelope that wraps a form_config plus journey-level metadata.
interface JourneySpec {
journey_id: string;
claimspec_id: string;
version: string; // "2.0.x" for the linear model
form_config: FormConfig; // ◀ the new authoring model
theme?: JourneyTheme;
mode?: JourneyMode;
// Legacy v1.x — preserved when re-exporting an imported v1 spec.
nodes?: JourneySpecNode[];
edges?: JourneySpecEdge[];
start_node_id?: string | null;
}FormConfig
interface FormConfig {
steps: FormStep[]; // ordered
fields: FormField[]; // flat catalog, referenced by id
summary: SummaryPanelConfig;
init: InitConfig;
}FormStep
interface FormStep {
step_id: string;
title: string;
description?: string;
rows: FormRow[]; // each row has field_ids[]
navigation: {
api_call?: StepApiCall; // fired when leaving the step
rules: NavRule[]; // JSON Logic; first match wins
button_label?: string;
};
}NavRule
interface NavRule {
rule_id: string;
condition: Record<string, unknown> | null; // null = always (default)
next_step_id: string; // or "__submit__"
priority?: number;
}StepApiCall
interface StepApiCall {
enabled: boolean;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
headers?: Record<string, string>;
body_template?: string; // tokens like {{answers.foo}} interpolated by the runner
on_error?: 'block' | 'continue' | `goto:${string}`; // defaults to 'block'
}FormField
interface FormField {
field_id: string;
namespace: string; // answer key
label: string;
labelOverride?: string;
specId?: string; // or "custom"
schema: string; // e.g. "v1_string", "v2_date-1"
options?: ClaimPropertySpecOption[] | null;
widget?: string; // override (e.g. "email", "textarea")
required?: boolean;
actor?: 'customer' | 'operator';
bindings?: { target_namespace: string }[];
uploadConfig?: UploadConfig;
allowFuture?: boolean;
placeholder?: string;
helpText?: string;
}SummaryPanelConfig
interface SummaryPanelConfig {
title: string;
show_pricing?: boolean;
show_sidebar?: boolean; // defaults to true
sections: SummarySection[];
}
interface SummarySection {
section_id: string;
title: string;
visible_from_step?: number; // 1-based
until_step?: number; // 1-based
display_fields: SummaryDisplayField[];
}
interface SummaryDisplayField {
display_id: string;
source: 'form_field' | 'api_response';
field_id?: string; // when source === 'form_field'
step_id?: string; // when source === 'api_response'
response_path?: string; // dot-path inside api_response
label: string;
format?: 'text' | 'date' | 'datetime' | 'currency' | 'number';
}InitConfig
interface InitConfig {
values: Record<string, unknown>;
api?: {
enabled: boolean;
method: 'GET' | 'POST';
url: string;
headers?: Record<string, string>;
mapping?: Record<string, string>; // namespace -> response_path
};
}JourneyTheme
CSS variables injected on the runner/builder root, overriding --claims-journey-* defaults. Colors are HSL triplets (e.g. "222 47% 11%").
interface JourneyTheme {
primaryColor?: string;
backgroundColor?: string;
cardColor?: string;
foregroundColor?: string;
fontFamilyHeading?: string;
fontFamilyBody?: string;
fontSizeBase?: number; // px
borderRadius?: number; // rem
spacing?: 'compact' | 'cozy' | 'spacious';
}JSON Schema validation
Draft-07 JSON Schemas describing FormConfig and JourneySpec v2 ship with the package and are re-exported as plain objects:
import Ajv from 'ajv';
import { formConfigSchema, journeySpecSchema } from 'habit-claims-journey-components';
const ajv = new Ajv({ allErrors: true, strict: false });
ajv.addSchema(formConfigSchema); // resolved by $id from journeySpecSchema
const validate = ajv.compile(journeySpecSchema);
if (!validate(spec)) console.error(validate.errors);Schema sources:
See docs/SCHEMAS.md for the full reference of required fields, types, and enumerations.
Supported Schema Types
| Schema | Widget | Validation |
|---|---|---|
| v1_string | Text input | — |
| v2_single_option_select-1 | Dropdown select | Must pick an option |
| v2_date-1 | Date picker | Valid date; allowFuture controls future dates |
| v2_currency-1 | Currency input | Numeric |
| v2_single_asset_upload | File upload (via onFileSelect) | Type/size from uploadConfig |
| v2_phone-1 | Phone with country code | Length check |
| v2_email-1 | Email input | Format regex |
Upload Configuration (uploadConfig)
interface UploadConfig {
accept?: string; // MIME types or extensions (e.g. "image/*,.pdf")
maxSizeMB?: number; // Max file size in MB
multiple?: boolean; // When true, allows multiple files
maxFiles?: number; // Max number of files (when multiple is true)
}Single-file upload (default): uploadConfig without multiple or with multiple: false.
Multi-file upload: set uploadConfig.multiple: true. When maxFiles is set, the widget enforces the limit.
Multi-File Upload Example
{
"field_id": "f-photos",
"namespace": "claim.photos",
"label": "Photos",
"schema": "v2_single_asset_upload",
"widget": "upload",
"uploadConfig": {
"accept": "image/*,.pdf",
"maxSizeMB": 10,
"multiple": true,
"maxFiles": 5
}
}When multiple: true, the answer value in onSubmit is an array of UploadResult:
{
"claim.photos": [
{ "filename": "photo1.jpg", "url": "https://...", "mimetype": "image/jpeg", "size": 12345 },
{ "filename": "photo2.jpg", "url": "https://...", "mimetype": "image/jpeg", "size": 67890 }
]
}See docs/MULTI-UPLOAD.md for a full migration guide.
Fields can override the default widget via widget (e.g. a v1_string rendered as "textarea" or "email").
Builder Features
- Steps tab — three-pane layout (Steps list / Step editor / Summary editor) with drag-and-drop rows and inline field reuse from the catalog
- Fields tab — global catalog with Used/Unused filter and quick-jump chips that take you straight to the Step using each field
- Init tab — static initial values + optional prefill API call mapping (
namespace → response_path) - JSON tab — full
JourneySpecview with copy / download / paste-to-import escape hatch - Per-step API calls — method, URL, headers, JSON body template (
{{answers.foo}}), andon_errorpolicy - Per-step navigation rules — JSON Logic conditions, priority,
__submit__target - Theme panel — primary/background/card/foreground colors, fonts, base size, radius, spacing scale
- Persistence — dirty-state tracking, Save / Publish, 50-action undo/redo, read-only mode with unlock action
Runner Features
- Step-by-step navigation with non-destructive back/forward and mid-journey draft persistence (via
initialAnswers) - Auto-progression through steps whose only effect is a navigation rule
- JSON Logic evaluation against
{ answers, api_responses } - Host-delegated APIs — per-step (
onStepApiCall) and init (onInitApiCall), with threeon_errorpolicies - File upload — async
onFileSelectreturning a URL or richUploadResult - Schema-aware widgets with mobile-first touch targets (48px min) and iOS-zoom-safe inputs
- Vertical summary screen rendering the configured
SummaryPanelConfig - Enriched output —
enrichedOutputflag wraps each answer as{ data, schema }
Project Structure
src/
├── index.ts # Barrel exports
├── types/journey.ts # Public types & helpers
├── components/
│ ├── form-configurator/ # ◀ v2.0 Builder
│ │ ├── JourneyBuilderWidget.tsx # Entry point
│ │ ├── FormConfigurator.tsx # Tabs + layout
│ │ ├── steps/ # Steps tab
│ │ ├── fields/ # Fields tab (Used/Unused filter)
│ │ ├── init/ # Init tab
│ │ ├── json/ # JSON tab
│ │ └── summary/ # Summary editor
│ ├── runner/
│ │ └── FormRunnerWidget.tsx # ◀ Runner entry point
│ ├── journey-builder/ # Shared previews, theme panel, portal context
│ │ └── preview-widgets/ # Schema-specific widgets
│ └── ui/ # shadcn/ui primitives
├── stores/
│ └── formConfiguratorStore.ts # Zustand store + legacy converter
├── lib/
│ └── theme-css.ts # CSS-variable builder for JourneyTheme
└── pages/ # Demo app
├── Index.tsx
└── Runner.tsxMigration v1 → v2
v2.0 replaces the React Flow graph canvas with a linear Steps → Rows → Fields configurator. Legacy v1.x specs (nodes/edges/start_node_id) are still accepted on import and auto-converted to a form_config.
Removed dependency
- "@xyflow/react": "^12.x" // no longer a peer dependencyCSS
- import 'habit-claims-journey-components/style.css';
+ // CSS is auto-injected by the bundle — no separate import neededJourneyBuilderWidget props
| v1.x | v2.x | Notes |
|---|---|---|
| specs | specs | Unchanged |
| journey | journey | Accepts both v1 and v2 shapes |
| onSave(spec) | onSave(spec) | Now also marks the store as clean |
| — | onPublish(spec) | New — dedicated publish action |
| — | onChange(spec) | New — fires on every mutation |
| — | onOpenRunner() | New — header "Preview" button |
| — | readOnly | New — disables editing |
| — | onUnlockEdit() | New — shown when readOnly is on |
| — | googleMapsApiKey | New — enables Location widget previews |
| onNodeClick, onEdgeClick, nodeTypes, edgeTypes | — | Removed (no canvas) |
<JourneyBuilderWidget
specs={specs}
journey={existing}
onSave={spec => save(spec)}
+ onPublish={spec => publish(spec)}
+ onChange={spec => setDraft(spec)}
+ onOpenRunner={() => navigate('/runner')}
+ readOnly={false}
+ googleMapsApiKey={import.meta.env.VITE_GOOGLE_MAPS_KEY}
/>FormRunnerWidget props
| v1.x | v2.x | Notes |
|---|---|---|
| journey | journey | Now drives form_config (or auto-converted legacy graph) |
| onSubmit(answers) | onSubmit(answers) | Set enrichedOutput for { data, schema } shape |
| onFileSelect(file, key) | onFileSelect(file, key) | May now return UploadResult { url, filename, mimetype?, size? } |
| onApiCall(req) | onStepApiCall(req: StepApiRequest) | Renamed; request includes step_id and answers snapshot |
| — | onStepApiError(err, req) | New — suppresses the default error toast; safe even if it throws |
| — | onInitApiCall(req: InitApiRequest) | New — host-delegated init/prefill call |
| — | onSave(partial) | New — mid-journey draft persistence |
| initialValues | initialAnswers | Renamed for consistency with the engine |
| — | enrichedOutput | New — wraps every answer as { data, schema } |
| — | fullScreen | New — viewport-fill layout |
| mode, groups | — | Ignored; layout is now driven by form_config.steps/rows |
<FormRunnerWidget
journey={journey}
- initialValues={draft}
+ initialAnswers={draft}
+ enrichedOutput
- onApiCall={async (req) => fetchJson(req.url, req)}
+ onStepApiCall={async (req) => fetchJson(req.url, req)}
+ onStepApiError={(err, req) => logger.warn(err, req)}
+ onInitApiCall={async (req) => fetchJson(req.url, req)}
+ onSave={(partial) => saveDraft(partial)}
onSubmit={(answers) => submit(answers)}
/>JourneySpec shape
{
"journey_id": "j-001",
"claimspec_id": "cs-001",
- "version": "1.0",
- "nodes": [ /* JourneySpecNode[] */ ],
- "edges": [ /* JourneySpecEdge[] */ ],
- "start_node_id": "node-1",
- "mode": "sequential",
- "groups": []
+ "version": "2.0",
+ "form_config": {
+ "steps": [ /* FormStep[] — rows -> field_ids */ ],
+ "fields": [ /* FormField[] — flat catalog */ ],
+ "summary": { "title": "Review", "sections": [] },
+ "init": { "values": {} }
+ }
}Importing a v1 spec is transparent: the store converts nodes/edges to form_config on load. Re-exporting preserves the legacy fields for downstream consumers that still read them.
Step API errors
v1 always blocked navigation on a failing API call. v2 introduces an explicit per-step policy on navigation.api_call.on_error:
| Value | Behavior |
|---|---|
| block (default) | Stay on the step; apiResponses[step_id] is preserved |
| continue | Run navigation rules; api_response for this step stays unset |
| goto:<step_id> | Jump to the named step (e.g. an error screen) |
Full Examples
Minimal FormConfig
A two-step journey with a select that branches to a follow-up step or skips to submit.
{
"steps": [
{
"step_id": "step-vehicle",
"title": "Your vehicle",
"rows": [
{ "row_id": "r1", "layout": "vertical", "field_ids": ["f-plate", "f-has-damage"] }
],
"navigation": {
"rules": [
{
"rule_id": "rule-damage",
"condition": { "==": [{ "var": "answers.vehicle.has_damage" }, "yes"] },
"next_step_id": "step-damage",
"priority": 10
},
{ "rule_id": "rule-default", "condition": null, "next_step_id": "__submit__" }
]
}
},
{
"step_id": "step-damage",
"title": "Describe the damage",
"rows": [{ "row_id": "r2", "layout": "vertical", "field_ids": ["f-damage-notes"] }],
"navigation": {
"rules": [{ "rule_id": "r-end", "condition": null, "next_step_id": "__submit__" }]
}
}
],
"fields": [
{ "field_id": "f-plate", "namespace": "vehicle.plate", "label": "License plate", "schema": "v1_string", "required": true },
{
"field_id": "f-has-damage",
"namespace": "vehicle.has_damage",
"label": "Is the vehicle damaged?",
"schema": "v2_single_option_select-1",
"required": true,
"options": [
{ "data": "yes", "label": "Yes" },
{ "data": "no", "label": "No" }
]
},
{ "field_id": "f-damage-notes", "namespace": "vehicle.damage_notes", "label": "Damage description", "schema": "v1_string", "widget": "textarea" }
],
"summary": {
"title": "Review your claim",
"sections": [
{
"section_id": "sec-vehicle",
"title": "Vehicle",
"display_fields": [
{ "display_id": "d-plate", "source": "form_field", "field_id": "f-plate", "label": "Plate" },
{ "display_id": "d-damage", "source": "form_field", "field_id": "f-damage-notes", "label": "Damage" }
]
}
]
},
"init": { "values": { "vehicle.country": "BR" } }
}Full JourneySpec with API call + init prefill
Per-step api_call with on_error: "goto:...", a summary section reading from api_response, and an init.api prefill.
{
"journey_id": "j-auto-claim-001",
"claimspec_id": "cs-auto-1",
"version": "2.0",
"form_config": {
"steps": [
{
"step_id": "step-id",
"title": "Find your policy",
"rows": [{ "row_id": "r1", "layout": "horizontal", "field_ids": ["f-doc"] }],
"navigation": {
"api_call": {
"enabled": true,
"method": "POST",
"url": "https://api.example.com/policies/lookup",
"headers": { "x-tenant": "acme" },
"body_template": "{\"document\":\"{{answers.customer.document}}\"}",
"on_error": "goto:step-not-found"
},
"rules": [
{ "rule_id": "r-ok", "condition": null, "next_step_id": "step-confirm" }
]
}
},
{
"step_id": "step-confirm",
"title": "Confirm your policy",
"rows": [{ "row_id": "r2", "layout": "vertical", "field_ids": ["f-accept"] }],
"navigation": {
"rules": [{ "rule_id": "r-end", "condition": null, "next_step_id": "__submit__" }]
}
},
{
"step_id": "step-not-found",
"title": "We couldn't find your policy",
"rows": [],
"navigation": { "rules": [] }
}
],
"fields": [
{ "field_id": "f-doc", "namespace": "customer.document", "label": "Document ID", "schema": "v1_string", "required": true },
{ "field_id": "f-accept", "namespace": "consent.accepted", "label": "I confirm the details above", "schema": "v2_single_option_select-1", "required": true,
"options": [{ "data": "yes", "label": "Yes" }] }
],
"summary": {
"title": "Review",
"sections": [
{
"section_id": "sec-policy",
"title": "Policy",
"display_fields": [
{ "display_id": "d-holder", "source": "api_response", "step_id": "step-id", "response_path": "policy.holder.name", "label": "Holder" },
{ "display_id": "d-plate", "source": "api_response", "step_id": "step-id", "response_path": "policy.vehicle.plate", "label": "Plate" }
]
}
]
},
"init": {
"values": {},
"api": {
"enabled": true,
"method": "GET",
"url": "https://api.example.com/session/prefill",
"headers": { "x-tenant": "acme" },
"mapping": {
"customer.full_name": "user.name",
"customer.email": "user.email"
}
}
}
},
"theme": {
"primaryColor": "222 47% 11%",
"fontFamilyHeading": "Poppins, sans-serif",
"fontFamilyBody": "Roboto, sans-serif",
"borderRadius": 0.75,
"spacing": "cozy"
}
}Handling StepApiRequest on the host
The runner builds a StepApiRequest whenever a step has an enabled api_call. body is already interpolated from body_template against the live answers; answers is a snapshot for host-side enrichment (auth, tenant context, etc.).
import type { StepApiRequest } from 'habit-claims-journey-components';
async function onStepApiCall(req: StepApiRequest) {
// req = { step_id, method, url, headers, body?, answers }
const res = await fetch(req.url, {
method: req.method,
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${getToken()}`,
...req.headers,
},
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// The returned value is stored as api_responses[req.step_id]
// and is referenceable from later JSON Logic rules and the Summary.
return res.json();
}Handling InitApiRequest on the host
Fired once on runner mount when form_config.init.api.enabled is true. The response is walked through init.api.mapping (namespace → dot-path) to seed answers before the first step renders.
import type { InitApiRequest } from 'habit-claims-journey-components';
async function onInitApiCall(req: InitApiRequest) {
// req = { method, url, headers? }
const res = await fetch(req.url, { method: req.method, headers: req.headers });
if (!res.ok) throw new Error(`Init HTTP ${res.status}`);
return res.json();
}License
MIT
