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.
How it fits together
Section titled “How it fits together” 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
Screenand per-questionFields. Your code switches onkindand renders.
1. Create the session
Section titled “1. Create the session”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 one2. Render the screen
Section titled “2. Render the screen”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 }}3. Render a section of questions
Section titled “3. Render a section of questions”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;}4. Render a field by kind
Section titled “4. Render a field by kind”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;}5. Show errors
Section titled “5. Show errors”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.
6. Trade search
Section titled “6. Trade search”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.
7. Get quotes
Section titled “7. Get quotes”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.
8. Show quotes and hand off
Section titled “8. Show quotes and hand off”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.
9. Clean up
Section titled “9. Clean up”session.dispose(); // persists once, then stops every timer, request and listenercreateSession({ client, vertical }), thensession.start().session.select(toScreen, render, screenEquals), and switch onscreen.kind.- For each name in
screen.section.names:toField(question), and switch onfield.kind. - Input calls
session.edit(name, value); commit callssession.flush(). fieldState(state, name, { reveal })for errors.session.requestQuotes(), thenselectQuote(id)orhandOff().- Redirect to the
handoffscreen’surl.
Next steps
Section titled “Next steps”- Concepts: resume, PII, errors as data.
- Reference:
createSession,toScreen,toField,fieldState.