Skip to content

Build a quote form

You’ll build a cyber quote form that renders whatever the server asks, validates as the customer types, gets quotes and hands off to buy. You’ll use no framework: plain TypeScript and DOM. The same shape works in React, Vue or anything else.

This page follows examples/session-journey.ts.

customer types ──▶ session.edit(name, value) ──▶ debounced PATCH ──▶ server
│
your render(screen) ◀── toScreen(state) ◀── session state ◀────────────┘
  • The session holds state: answers, outstanding questions, errors, quotes. Its actions never throw; failures land in state.
  • The view-model turns that state into a Screen and per-question Fields. Your code switches on kind and renders.
journey.ts
import { apiKey, createClient } from '@partner-sdk/core';
import { createSession, sessionStorageAdapter } from '@partner-sdk/core/session';
import { screenEquals, toScreen, type Screen } from '@partner-sdk/core/view-model';
const client = createClient({ baseUrl: 'https://api.example.com', auth: apiKey(key) });
const session = createSession({
client,
vertical: 'cyber',
storage: sessionStorageAdapter(), // survives a reload in this tab
});
let sectionId: string | undefined; // the view owns which section is showing
// Re-render only when something visible changes.
session.select((s) => toScreen(s, { sectionId }), render, screenEquals);
await session.start(); // resumes a stored journey, or creates a new one

toScreen() returns one Screen per phase of the journey. Switch on screen.kind:

function render(screen: Screen) {
switch (screen.kind) {
case 'loading':
return showSpinner();
case 'error':
return showError('Something went wrong. Try again.');
case 'section':
return renderSection(screen);
case 'waiting':
return showSpinner('Getting your quotes…');
case 'quotes':
return renderQuotes(screen);
case 'failed':
return renderFailed(screen.reason);
case 'handoff':
return location.assign(screen.url); // the customer buys here
}
}

A section screen tells you which questions to show (section.names), whether the customer can get quotes yet (ready), and where to send focus if something needs attention.

import { toField } from '@partner-sdk/core/view-model';
function renderSection(screen: Extract<Screen, { kind: 'section' }>) {
const state = session.getSnapshot();
const form = document.querySelector('#form')!;
form.replaceChildren();
for (const name of screen.section.names) {
const question = state.questions[name];
if (question) form.append(renderField(toField(question)));
}
nextButton.hidden = !screen.nextId;
nextButton.onclick = () => { sectionId = screen.nextId; rerender(); };
quoteButton.disabled = !screen.ready;
}

toField() turns a server question into a Field. There are seven kinds; map each to a control from your design system. Labels and hints are HTML.

field.kind Render as
text <input type={field.inputType}> with autoComplete, inputMode, maxLength, pattern
number <input type="number">, with a $ prefix when field.prefix is set
date <input type="date" min max>; values are YYYY-MM-DD
choice Radios when presentation is "radio" (4 or fewer options), else a <select>
multi A checkbox group, or a single checkbox when presentation is "checkbox"
search A trade search: see step 6
info Read-only content. Never blocks quotes
import type { Field } from '@partner-sdk/core/view-model';
function renderField(field: Field): HTMLElement {
const wrapper = el('div', { class: 'field' });
wrapper.append(el('label', { for: field.name, innerHTML: field.label }));
switch (field.kind) {
case 'text':
case 'number':
case 'date': {
const input = el('input', {
id: field.name,
type: field.kind === 'text' ? field.inputType : field.kind,
required: field.required,
});
input.oninput = () => session.edit(field.name, input.value);
input.onblur = () => { reveal(field.name); session.flush(); };
wrapper.append(input);
break;
}
case 'choice':
wrapper.append(renderChoice(field)); // calls session.edit + session.flush on change
break;
// ...multi, search, info
}
watchField(field.name, wrapper);
return wrapper;
}

Each field has its own state: its value, and the error to show. Subscribe per field so a keystroke re-renders one field, not the page.

import { fieldState, sameFieldState } from '@partner-sdk/core/view-model';
const revealed = new Set<string>();
const reveal = (name: string) => revealed.add(name);
function watchField(name: string, wrapper: HTMLElement) {
session.select(
(s) => fieldState(s, name, { reveal: revealed.has(name) }),
({ error, invalid }) => {
wrapper.toggleAttribute('data-invalid', invalid);
wrapper.querySelector('.error')?.replaceChildren(error ?? '');
},
sameFieldState,
);
}

There are two sources of errors, and fieldState merges them:

  • Client checks (required, length, email, phone, zip, number range, date bounds) run only when you pass reveal: true. You decide when: on blur, or when the customer presses Continue.
  • Server errors always win, and suppress client checks for that field.

The trade question has kind search. It has no options; the customer types and picks a result.

import { toTradeOption, TRADE_MESSAGES } from '@partner-sdk/core/view-model';
searchInput.oninput = debounce(() => session.searchTrade(field.name, searchInput.value), 250);
session.select((s) => s.search[field.name], (slice) => {
const options = (slice?.results ?? []).map((r) => toTradeOption(r));
renderOptions(options, (option) => {
if (option.blocked) return showMessage(TRADE_MESSAGES.blocked); // we don't cover this trade
session.edit(field.name, option.value);
session.flush();
});
});

Or drop in <sb-trade-selector>, which does all of this.

When screen.ready is true, the server has everything it needs.

quoteButton.onclick = () => session.requestQuotes();

requestQuotes() flushes pending answers, then polls. The screen moves to waiting, then quotes (or failed). If the customer edits an answer while quoting, polling stops and the journey goes back to questions.

If the customer presses the button before the form is complete, the section screen’s missing lists what’s outstanding and attention points at the first question to focus.

function renderQuotes(screen: Extract<Screen, { kind: 'quotes' }>) {
for (const card of screen.cards) {
list.append(quoteCard(card, () => session.selectQuote(card.id)));
}
if (!screen.selectable) {
// Every quote was referred or declined: send them to the comparison page anyway.
continueButton.onclick = () => session.handOff();
}
}

Each QuoteCard has insurer, premium (formatted currency), decision and selectable. Selecting a quote clears stored answers and moves to handoff; your render redirects to screen.url.

session.dispose(); // persists once, then stops every timer, request and listener
  1. createSession({ client, vertical }), then session.start().
  2. session.select(toScreen, render, screenEquals), and switch on screen.kind.
  3. For each name in screen.section.names: toField(question), and switch on field.kind.
  4. Input calls session.edit(name, value); commit calls session.flush().
  5. fieldState(state, name, { reveal }) for errors.
  6. session.requestQuotes(), then selectQuote(id) or handOff().
  7. Redirect to the handoff screen’s url.