<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Multi-Step Booking Form UX: Qualify Without Drop-Off]]></title><description><![CDATA[Multi-Step Booking Form UX: Qualify Without Drop-Off]]></description><link>https://xenon-builds.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a99475c5d0d96c0db675991/e7ab6e1f-2f77-47cd-9d8a-0274d700b3d7.png</url><title>Multi-Step Booking Form UX: Qualify Without Drop-Off</title><link>https://xenon-builds.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 20:02:14 GMT</lastBuildDate><atom:link href="https://xenon-builds.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Modelling a Service Pricing Page as Data, Not Layout]]></title><description><![CDATA[Most service business pricing pages are three hardcoded cards in a flex row. The numbers live inside text nodes, the tier logic lives in the designer's head, and the schema markup, if it exists at all]]></description><link>https://xenon-builds.hashnode.dev/modelling-a-service-pricing-page-as-data-not-layout</link><guid isPermaLink="true">https://xenon-builds.hashnode.dev/modelling-a-service-pricing-page-as-data-not-layout</guid><category><![CDATA[webdev]]></category><category><![CDATA[framer]]></category><category><![CDATA[SEO]]></category><category><![CDATA[nocode]]></category><category><![CDATA[Web Design]]></category><dc:creator><![CDATA[Xenon Studio]]></dc:creator><pubDate>Fri, 11 Sep 2026 09:43:49 GMT</pubDate><content:encoded><![CDATA[<p>Most service business pricing pages are three hardcoded cards in a flex row. The numbers live inside text nodes, the tier logic lives in the designer's head, and the schema markup, if it exists at all, describes the organisation rather than anything it sells.</p>
<p>That works until the client raises prices. Then the number exists in six places: three cards, a FAQ answer, the booking form's total, and the meta description. Five of them get updated. The sixth is the one a customer screenshots.</p>
<p>This post covers modelling the pricing page as data instead of layout: the field model, the vehicle class multiplier problem, the <code>Offer</code> schema that makes the numbers machine readable, and what to validate before shipping.</p>
<h2>The failure mode is duplication, not design</h2>
<p>A price is a fact about the business. Facts belong in one place with one owner.</p>
<p>When a price is typed into a layout, the layout becomes the database, and layouts are the worst database you can pick. They have no validation, no single source of truth, no way to query "which services cost more than $400", and no way to emit structured output.</p>
<p>The fix is ordinary content modelling, applied to something teams routinely skip because it looks like design work.</p>
<h2>The field model</h2>
<p>Start with the smallest model that covers real variation. For a vehicle service business that means the price is not one number, it is a base plus a class modifier.</p>
<pre><code class="language-ts">type VehicleClass = "sedan" | "midSuv" | "truck" | "threeRow"

interface ServiceTier {
  slug: string
  name: string
  order: number
  basePrice: number          // cents, sedan baseline
  durationMinutes: number
  inclusions: string[]
  depositPercent: number     // 0 disables deposit for this tier
  quoteOnly: boolean         // true suppresses price, renders range instead
  rangeLow?: number
  rangeHigh?: number
}

const CLASS_MULTIPLIER: Record&lt;VehicleClass, number&gt; = {
  sedan: 1,
  midSuv: 1.15,
  truck: 1.25,
  threeRow: 1.35,
}
</code></pre>
<p>The multiplier map is the part that saves you. The naive model is one CMS item per tier per vehicle class, which is four tiers times four classes equals sixteen items to keep in sync, and sixteen chances for the truck price on the middle tier to drift. One base price plus one shared multiplier table is two things to maintain.</p>
<pre><code class="language-ts">const priceFor = (tier: ServiceTier, vc: VehicleClass) =&gt;
  Math.round((tier.basePrice * CLASS_MULTIPLIER[vc]) / 100) * 100
</code></pre>
<p>Rounding to the nearest dollar at render time keeps <code>$332.35</code> off the page without storing a rounded value you then cannot recompute.</p>
<p><code>quoteOnly</code> deserves its own boolean rather than a null price. Full vehicle paint film and multi stage correction cannot be priced from a card, and the renderer needs to branch to a range plus an inspection explanation rather than rendering an empty slot. A null price is ambiguous. A flag is not.</p>
<h2>Rendering is now boring</h2>
<pre><code class="language-jsx">export function TierCard({ tier, vehicleClass }) {
  if (tier.quoteOnly) {
    return (
      &lt;Card&gt;
        &lt;h3&gt;{tier.name}&lt;/h3&gt;
        &lt;p&gt;From ${tier.rangeLow} to ${tier.rangeHigh}, set at inspection&lt;/p&gt;
        &lt;InclusionList items={tier.inclusions} /&gt;
      &lt;/Card&gt;
    )
  }

  const price = priceFor(tier, vehicleClass)
  return (
    &lt;Card&gt;
      &lt;h3&gt;{tier.name}&lt;/h3&gt;
      &lt;p&gt;From ${price} for {LABEL[vehicleClass]}&lt;/p&gt;
      &lt;p&gt;About {Math.round(tier.durationMinutes / 60)} hours&lt;/p&gt;
      &lt;InclusionList items={tier.inclusions} /&gt;
      {tier.depositPercent &gt; 0 &amp;&amp; (
        &lt;p&gt;Reserve with a ${Math.round(price * tier.depositPercent / 100)} deposit&lt;/p&gt;
      )}
    &lt;/Card&gt;
  )
}
</code></pre>
<p>In Framer this maps cleanly onto a CMS collection plus a code component, so the shop owner edits a number in one field and every surface updates. We build Framer sites and booking systems for automotive shops, so this comes up constantly: the client changes prices seasonally and nobody wants that to be a developer ticket.</p>
<p>The deposit line matters more than it looks. Industry data on service bookings puts no-show rates at 15 to 25 percent without a deposit and 3 to 5 percent with one, and reports that 83 percent of consumers accept a deposit on services over $100 (<a href="https://schedulingkit.com/statistics/appointment-deposit-statistics">SchedulingKit</a>). That is a business outcome that depends entirely on the price being a computable number rather than a string in a heading.</p>
<h2>Making the price machine readable</h2>
<p>Once the price is data, emitting schema is a serialisation step rather than a copywriting exercise. <code>Service</code> with nested <code>Offer</code> is the right shape for a bookable service.</p>
<pre><code class="language-json">{
  "@context": "https://schema.org",
  "@type": "Service",
  "serviceType": "Full interior and exterior detail",
  "provider": {
    "@type": "AutoDetailing",
    "name": "Example Detail Co",
    "url": "https://example.com"
  },
  "areaServed": { "@type": "City", "name": "Jacksonville" },
  "offers": {
    "@type": "Offer",
    "priceCurrency": "USD",
    "price": "289.00",
    "priceSpecification": {
      "@type": "PriceSpecification",
      "minPrice": "289.00",
      "maxPrice": "389.00",
      "valueAddedTaxIncluded": false
    },
    "availability": "https://schema.org/InStock",
    "url": "https://example.com/book/full-detail"
  }
}
</code></pre>
<p>Two details that trip people up. <code>AutoDetailing</code> is a real schema.org type under <code>AutomotiveBusiness</code>, so use it instead of a generic <code>LocalBusiness</code>. And when the price varies by vehicle class, emit <code>priceSpecification</code> with <code>minPrice</code> and <code>maxPrice</code> rather than picking one number and hoping, because a single <code>price</code> that the page contradicts is worse than no markup.</p>
<p>Generate this from the same model that renders the cards. If the JSON-LD is hand written, it is already stale.</p>
<h2>What actually changes</h2>
<table>
<thead>
<tr>
<th>Surface</th>
<th>Hardcoded cards</th>
<th>Modelled with schema</th>
</tr>
</thead>
<tbody><tr>
<td>Price update cost</td>
<td>Edit 6 places, miss one</td>
<td>Edit 1 field</td>
</tr>
<tr>
<td>Vehicle class variants</td>
<td>16 duplicated items</td>
<td>4 items, 1 multiplier map</td>
</tr>
<tr>
<td>Google parsing</td>
<td>Text in a heading</td>
<td><code>Offer</code> with price and currency</td>
</tr>
<tr>
<td>LLM answer extraction</td>
<td>Guesses from prose</td>
<td>Reads the declared range</td>
</tr>
<tr>
<td>Booking form total</td>
<td>Separate hardcoded value</td>
<td>Computed from same source</td>
</tr>
</tbody></table>
<p>The LLM row is the one that has changed fastest. Assistants answering "how much does a full detail cost near me" are pulling from whatever is parseable. Prose buried in a design layer parses badly. A declared <code>priceSpecification</code> parses exactly.</p>
<p>For context on whether the numbers are even in the right band, a 2026 analysis of 89 mobile operators across four US metros put packages around $158, $278 and $443 (<a href="https://www.getjobber.com/academy/auto-detailing/how-much-to-charge-for-car-detailing/">Jobber</a>). Useful as a sanity check on seed data before a client supplies real figures.</p>
<h2>Ship checklist</h2>
<ol>
<li><p><strong>Model first, design second.</strong> Write the interface before opening the canvas. If a field cannot be named, the page cannot be built.</p>
</li>
<li><p><strong>One base price per tier, one shared multiplier map.</strong> Never store the same price twice.</p>
</li>
<li><p><strong>Flag quote only work explicitly.</strong> Branch the renderer, do not null the price.</p>
</li>
<li><p><strong>Serialise JSON-LD from the model.</strong> No hand written schema blocks.</p>
</li>
<li><p><strong>Validate with the</strong> <a href="https://search.google.com/test/rich-results"><strong>Rich Results Test</strong></a> <strong>and the</strong> <a href="https://validator.schema.org/"><strong>schema.org validator</strong></a><strong>.</strong> The first tells you what Google accepts, the second tells you what is actually valid. They disagree more often than you expect.</p>
</li>
<li><p><strong>Diff the rendered price against the schema price in CI.</strong> A one line assertion catches the entire class of drift bugs this post is about.</p>
</li>
<li><p><strong>Recheck after a CMS field rename.</strong> Renaming a field in a visual CMS silently breaks the serialiser, and nothing on the page looks wrong.</p>
</li>
</ol>
<p>Step six is the one worth the effort. The bug is never that the schema is missing. It is that the schema says $289 while the card says $329, and nobody notices for four months.</p>
<hr />
<p>Written by the team at <a href="https://xenonstudio.net">Xenon Builds</a>. We build Framer sites and booking systems for automotive shops across the US, which means we spend a lot of time on conversion, page speed and form UX for a very specific kind of small business.</p>
]]></content:encoded></item><item><title><![CDATA[Modelling Recurring Bookings: Subscription State vs Appointment State]]></title><description><![CDATA[Most booking flows are modelled as a single table of appointments, and that model holds up right until somebody buys a plan instead of a slot. The moment a customer can pause, skip or reschedule a rec]]></description><link>https://xenon-builds.hashnode.dev/modelling-recurring-bookings-subscription-state</link><guid isPermaLink="true">https://xenon-builds.hashnode.dev/modelling-recurring-bookings-subscription-state</guid><category><![CDATA[webdev]]></category><category><![CDATA[framer]]></category><category><![CDATA[nocode]]></category><category><![CDATA[UX]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[Xenon Studio]]></dc:creator><pubDate>Mon, 07 Sep 2026 09:39:45 GMT</pubDate><content:encoded><![CDATA[<p>Most booking flows are modelled as a single table of appointments, and that model holds up right until somebody buys a plan instead of a slot. The moment a customer can pause, skip or reschedule a recurring visit, one table starts encoding two different lifecycles, and the bugs that follow are the confusing kind: a cancelled visit that keeps billing, a paused plan that keeps generating slots, a reschedule that silently deletes a paid-for month.</p>
<p>This is a modelling problem, not a UI problem. Here is the shape that works, the state machine underneath it, and the schema that makes the tiers readable to search engines and AI assistants.</p>
<h2>Why one table breaks</h2>
<p>The naive model looks reasonable. An appointment row carries a customer, a service, a datetime and a status, and recurrence gets bolted on as <code>recurring: true</code> plus an interval.</p>
<p>The trouble is that "cancelled" now means two incompatible things. Cancelling one Tuesday in November is not the same event as cancelling the commercial relationship, but a single status column cannot tell you which happened. Neither can your reporting, which is how a shop ends up with a churn number that does not reconcile against revenue.</p>
<p>The same collision hits every other transition. Pausing a plan should stop future occurrences without touching the two visits already scheduled. Rescheduling one visit should not extend the billing period. Skipping a month may or may not credit the customer, and that is a business rule that has to live somewhere explicit.</p>
<h2>Two entities, two lifecycles</h2>
<p>Split it. A subscription is a billing relationship. An appointment is a scheduled occurrence that belongs to one. They reference each other and they change state independently.</p>
<pre><code class="language-ts">type SubscriptionStatus =
  | 'active'
  | 'paused'
  | 'past_due'
  | 'cancelled'      // no future occurrences, history retained
  | 'pending'        // checkout started, payment not confirmed

type Tier = 'small' | 'midsize' | 'fullsize' | 'fleet'

interface Subscription {
  id: string
  customerId: string
  tier: Tier
  vehicleIds: string[]          // fleet accounts hold many
  intervalDays: number          // 30, 60, 90
  status: SubscriptionStatus
  currentPeriodEnd: string      // ISO, drives billing not scheduling
  pausedUntil?: string
  skipCreditCount: number       // skipped visits owed back
}

type AppointmentStatus =
  | 'scheduled'
  | 'confirmed'
  | 'completed'
  | 'skipped'        // member moved it, plan unaffected
  | 'no_show'
  | 'voided'         // parent subscription cancelled

interface Appointment {
  id: string
  subscriptionId?: string       // absent for one-off retail bookings
  customerId: string
  vehicleId: string
  startsAt: string
  durationMinutes: number
  status: AppointmentStatus
  rescheduledFrom?: string      // preserves the original slot
}
</code></pre>
<p>Two details in there matter more than they look. <code>subscriptionId</code> is optional, which is what lets member visits and walk-in retail bookings share one calendar rather than two systems fighting over the same bay. And <code>rescheduledFrom</code> keeps the original slot as data instead of overwriting it, so you can answer "how often do members move their visit" later without having destroyed the evidence.</p>
<h2>The state table</h2>
<p>This is the transition matrix worth agreeing on before any UI gets built. Every row here is a bug we have seen shipped as an undefined case.</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>Subscription becomes</th>
<th>Existing scheduled appointments</th>
<th>Billing effect</th>
</tr>
</thead>
<tbody><tr>
<td>Checkout completes</td>
<td><code>pending</code> to <code>active</code></td>
<td>First occurrence created</td>
<td>Period starts</td>
</tr>
<tr>
<td>Member skips one visit</td>
<td>unchanged (<code>active</code>)</td>
<td>That row to <code>skipped</code>, <code>skipCreditCount + 1</code></td>
<td>None</td>
</tr>
<tr>
<td>Member reschedules one visit</td>
<td>unchanged (<code>active</code>)</td>
<td>New row, old row keeps <code>rescheduledFrom</code></td>
<td>None</td>
</tr>
<tr>
<td>Member pauses</td>
<td><code>paused</code>, <code>pausedUntil</code> set</td>
<td>Kept as scheduled, none generated after</td>
<td>Paused at period end</td>
</tr>
<tr>
<td>Payment fails</td>
<td><code>past_due</code></td>
<td>Kept, flagged</td>
<td>Retry window</td>
</tr>
<tr>
<td>Dunning exhausted</td>
<td><code>cancelled</code></td>
<td>Future rows to <code>voided</code></td>
<td>Stops</td>
</tr>
<tr>
<td>Member cancels</td>
<td><code>cancelled</code> at period end</td>
<td>Rows inside the paid period stay</td>
<td>Stops at period end</td>
</tr>
<tr>
<td>Shop cancels a visit</td>
<td>unchanged (<code>active</code>)</td>
<td>That row to <code>skipped</code>, credit owed</td>
<td>None</td>
</tr>
</tbody></table>
<p>The row that gets skipped in almost every first implementation is the last one. When the shop cancels, the member is owed something, and if that is not modelled the credit gets handled by a phone call and a discount code, which is fine at three members and unusable at forty.</p>
<h2>Generating occurrences: materialise or compute?</h2>
<p>Two options. Compute occurrences on the fly from <code>intervalDays</code>, or materialise appointment rows ahead of time.</p>
<p>Computing is tempting because it keeps the data small, but it fails on the first exception. A member who moves one visit two weeks later has permanently broken the arithmetic, so every read now needs an override table, which is just materialised rows with extra steps.</p>
<p>Materialise, but only a short horizon. We generate the next two occurrences per active subscription and top up on completion. That keeps the calendar honest, keeps capacity planning real, and keeps the cost of a cancellation bounded to voiding two rows rather than two years of them.</p>
<p>Run the top-up as an idempotent job keyed on <code>(subscriptionId, startsAt)</code>. Retries are guaranteed in this kind of system, and a duplicate booking is a worse failure than a missing one.</p>
<h2>What the UI has to expose</h2>
<p>Every subscription state needs a visible surface, or support absorbs it.</p>
<p>Pause, skip and reschedule all have to be self-serve. This is a retention feature rather than a convenience: industry subscription data from Recurly's 2026 report, across 2,200 businesses and 76 million subscribers, puts churn reduction from annual commitment at 51 percent, and the mechanism behind numbers like that is friction removal, not loyalty. If moving one visit requires a conversation, some share of members cancel instead of asking.</p>
<p>Source: <a href="https://recurly.com/content/state-of-subscriptions-report/">Recurly, State of Subscriptions 2026</a></p>
<p><code>past_due</code> deserves a real state in the interface too. A silent failed payment reads as a cancelled service to the customer and as active revenue to the shop, and the two only reconcile when someone arrives to an unbooked bay.</p>
<p>We build Framer sites and booking systems for automotive shops, so this comes up constantly with detailers moving from one-off jobs to maintenance plans. The build always ends up spending more time on these four states than on the checkout itself.</p>
<h2>Making the tiers machine readable</h2>
<p>The pricing page for a plan is usually three columns of prose, which is invisible to anything parsing structured data. Emit the tiers as offers with a recurring price specification instead.</p>
<pre><code class="language-json">{
  "@context": "https://schema.org",
  "@type": "Service",
  "serviceType": "Auto detailing maintenance membership",
  "provider": { "@type": "AutoDetailing", "name": "Example Detailing" },
  "areaServed": { "@type": "City", "name": "Houston" },
  "offers": [
    {
      "@type": "Offer",
      "name": "Mid-size Monthly Maintenance",
      "priceSpecification": {
        "@type": "UnitPriceSpecification",
        "price": "125.00",
        "priceCurrency": "USD",
        "billingIncrement": 1,
        "unitCode": "MON",
        "billingDuration": 1
      },
      "eligibleCustomerType": "https://schema.org/Consumer"
    }
  ]
}
</code></pre>
<p><code>UnitPriceSpecification</code> with <code>unitCode: "MON"</code> is the part that communicates "per month" rather than "one hundred and twenty five dollars, once". Without it a crawler reads a plan as a one-time product, which is exactly the wrong summary for an assistant to hand a user asking what a monthly plan costs.</p>
<p>Published market tiers sit around 100, 125 and 150 dollars by vehicle size for US consumer plans, which is a useful sanity check when modelling test data.</p>
<p>Source: <a href="https://cardetox-us.com/membership/">Car Detox membership plans</a></p>
<h2>Implementation order</h2>
<p>If you are building this from nothing, this sequence avoids the most rework:</p>
<ol>
<li><p>Model <code>Subscription</code> and <code>Appointment</code> as separate entities from the first commit. Retrofitting the split after launch means migrating live billing data, which is the one migration nobody wants.</p>
</li>
<li><p>Write the state table for your own business rules before the UI. Fill in every cell, including the awkward ones about credits.</p>
</li>
<li><p>Build the recurring checkout and the <code>pending</code> to <code>active</code> transition. Nothing else can be tested until a subscription can exist.</p>
</li>
<li><p>Materialise a two-occurrence horizon with an idempotent top-up job.</p>
</li>
<li><p>Ship pause, skip and reschedule in the same release as the checkout. Launching without them means every exception arrives as a support message.</p>
</li>
<li><p>Add the offer schema last, once prices are stable.</p>
</li>
</ol>
<p>The interesting engineering here is not the payment integration. It is accepting that a booking and a billing relationship are two objects with two lifecycles, and that every ambiguity you leave between them eventually shows up as a customer who was charged for a visit that no longer exists.</p>
<hr />
<blockquote>
<p>Written by the team at <a href="https://xenonstudio.net">Xenon Builds</a>. We build Framer sites and booking systems for automotive shops across the US, which means we spend a lot of time on conversion, page speed and form UX for a very specific kind of small business.</p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[Progressive Disclosure in a Booking Form: Filter Without Losing Users"
subtitle: "How to model a multi-step qualification form as a state machine, where to validate, and which fields to cut.]]></title><description><![CDATA[Every lead form carries the same unresolved tension: each field you add increases the information value of a submission and decreases the number of submissions you get. Most teams resolve it by guessi]]></description><link>https://xenon-builds.hashnode.dev/progressive-disclosure-booking-form-qualification</link><guid isPermaLink="true">https://xenon-builds.hashnode.dev/progressive-disclosure-booking-form-qualification</guid><category><![CDATA[webdev]]></category><category><![CDATA[UX]]></category><category><![CDATA[frontend]]></category><category><![CDATA[framer]]></category><category><![CDATA[Web Design]]></category><dc:creator><![CDATA[Xenon Studio]]></dc:creator><pubDate>Sat, 05 Sep 2026 12:06:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a99475c5d0d96c0db675991/0dee05db-79d4-477e-8b2c-2d05e846a2ad.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every lead form carries the same unresolved tension: each field you add increases the information value of a submission and decreases the number of submissions you get. Most teams resolve it by guessing, then argue about it in review.</p>
<p>This is a write up of the pattern we settled on for small business service booking, where the form is not a checkout but a qualification step. The shape is a four step flow modelled as a finite state machine, one question per step, contact details last. Below is the state model, the validation timing, the field selection method, and the instrumentation that tells you which step is actually leaking.</p>
<h2>The constraint you are designing against</h2>
<p>Two industry benchmarks set the boundaries, and both are worth having in front of you before anyone picks field count.</p>
<p>Baymard Institute's meta analysis of fifty studies puts average checkout and form abandonment at 70.22%, rising to 85.2% on mobile, with a process that felt too long or too complicated cited in 18% of cases (<a href="https://baymard.com/lists/cart-abandonment-rate">Baymard Institute</a>).</p>
<p>These are industry figures, not our own measurements, so treat them as a prior rather than a target. The useful part is the mobile delta. A 15 point gap between desktop and mobile abandonment means a form that tests fine on a desktop mockup can be functionally broken in the context where it is actually completed. For service businesses that context is a phone, one handed, often outdoors.</p>
<p>The second constraint is the opposite pressure. A submission with a name, an email and a free text message is unroutable. You cannot branch on it, score it, or send it anywhere useful without a human reading it first. So the goal is not a shorter form. It is a form whose fields are structured and sequenced well enough to stay short while producing routable data.</p>
<h2>Model the flow as a state machine, not a page with conditionals</h2>
<p>The failure mode we kept hitting was step logic implemented as a pile of booleans inside a component. <code>showStep2</code>, <code>hasSelectedService</code>, <code>isMobileJob</code>. It works for three steps and becomes unmaintainable at five, because the valid states are implicit and nothing stops you reaching an impossible one.</p>
<p>Declaring the steps as data and driving transitions through a reducer fixes that. The valid states become enumerable, the branching lives in one place, and the back button stops being a special case.</p>
<pre><code class="language-ts">type StepId = "service" | "vehicle" | "timeline" | "location" | "contact";

type Answers = Partial&lt;{
  service: "coating" | "film" | "correction" | "maintenance";
  vehicle: string;
  timeline: "this-week" | "this-month" | "researching";
  location: string;
  name: string;
  phone: string;
}&gt;;

type Step = {
  id: StepId;
  validate: (a: Answers) =&gt; string | null;
  skipIf?: (a: Answers) =&gt; boolean;
};

const STEPS: Step[] = [
  {
    id: "service",
    validate: (a) =&gt; (a.service ? null : "Pick a service to continue"),
  },
  {
    id: "vehicle",
    validate: (a) =&gt;
      a.vehicle &amp;&amp; a.vehicle.trim().length &gt; 1 ? null : "Add your vehicle",
  },
  {
    id: "timeline",
    validate: (a) =&gt; (a.timeline ? null : "Choose a rough timeline"),
  },
  {
    id: "location",
    // only mobile-serviced jobs need a service radius check
    skipIf: (a) =&gt; a.service === "correction",
    validate: (a) =&gt; (a.location ? null : "Add a ZIP code"),
  },
  {
    id: "contact",
    validate: (a) =&gt;
      a.phone &amp;&amp; /^[\d\s()+-]{7,}$/.test(a.phone) ? null : "Add a phone number",
  },
];

type State = { index: number; answers: Answers; error: string | null };

type Action =
  | { type: "answer"; patch: Answers }
  | { type: "next" }
  | { type: "back" };

function nextIndex(from: number, answers: Answers, dir: 1 | -1) {
  let i = from + dir;
  while (STEPS[i]?.skipIf?.(answers)) i += dir;
  return Math.min(Math.max(i, 0), STEPS.length - 1);
}

export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "answer":
      // clear the error on input, never re-validate mid-typing
      return {
        ...state,
        answers: { ...state.answers, ...action.patch },
        error: null,
      };
    case "next": {
      const error = STEPS[state.index].validate(state.answers);
      if (error) return { ...state, error };
      return {
        ...state,
        index: nextIndex(state.index, state.answers, 1),
        error: null,
      };
    }
    case "back":
      return {
        ...state,
        index: nextIndex(state.index, state.answers, -1),
        error: null,
      };
  }
}
</code></pre>
<p>Three things this buys you. <code>skipIf</code> handles conditional steps without the component knowing anything about business rules. Going back skips the same steps going forward skipped, which is where hand rolled implementations usually break. And because validation is colocated with each step definition, the submit handler never needs a second copy of the rules.</p>
<h2>Validation timing is the part that gets rushed</h2>
<p>Validate on step advance, not on change and not on blur.</p>
<p>On change punishes people mid typing, which is the single most common complaint we see in session recordings of forms like this. On blur is subtler but still wrong for a one question step, because tabbing out of the only field on screen fires an error before the user has done anything wrong. Advance is the natural commitment point, and it is the only one where the user has signalled they consider the answer finished.</p>
<p>Two details that matter on mobile more than desktop:</p>
<ul>
<li><p>Move focus to the new step's first input on transition, and announce the change with <code>aria-live="polite"</code>. Without it, a screen reader user gets a silent DOM swap and keyboard users get dropped back to the top of the document.</p>
</li>
<li><p>Set <code>inputMode</code> and <code>autoComplete</code> per field. <code>inputMode="numeric"</code> with <code>autoComplete="tel"</code> on the phone step, <code>autoComplete="postal-code"</code> on the ZIP step. It is two attributes and it removes an entire class of typing friction on the step where abandonment is highest.</p>
</li>
</ul>
<h2>Choosing which fields survive</h2>
<p>The selection rule we use: a field earns a place only if it changes how the inquiry is handled. If two different answers produce the same next action, the field is decoration.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Signal it produces</th>
<th>Verdict</th>
</tr>
</thead>
<tbody><tr>
<td>Service (structured select)</td>
<td>Routing, price band, which bay</td>
<td>Keep, step 1</td>
</tr>
<tr>
<td>Vehicle</td>
<td>Surface area, condition, effort estimate</td>
<td>Keep, step 2</td>
</tr>
<tr>
<td>Timeline</td>
<td>Active buyer vs researcher</td>
<td>Keep, step 3</td>
</tr>
<tr>
<td>ZIP or location</td>
<td>Serviceability, travel time</td>
<td>Keep, conditional</td>
</tr>
<tr>
<td>Phone</td>
<td>The only reliable reply channel</td>
<td>Keep, last step</td>
</tr>
<tr>
<td>Email</td>
<td>Duplicates phone for this use case</td>
<td>Drop or make optional</td>
</tr>
<tr>
<td>Free text message</td>
<td>Unroutable, invites an essay</td>
<td>Drop</td>
</tr>
<tr>
<td>"How did you hear about us"</td>
<td>Attribution, better from analytics</td>
<td>Drop</td>
</tr>
<tr>
<td>Company name</td>
<td>Irrelevant for consumer work</td>
<td>Drop</td>
</tr>
</tbody></table>
<p>Structured beats free text every time, because structured answers are filterable, routable and countable without a human in the loop. A select with your real package names also forces a self assessment against your own service structure, which does more filtering than any wording change to a message box.</p>
<p>We build Framer sites and booking flows for automotive shops, so this pattern comes up constantly, and the drop list above is consistently the argument. Owners want the message box. The message box is where routable data goes to die.</p>
<h2>Implementation order</h2>
<ol>
<li><p>Export the last sixty submissions and tag each one actionable or not, with the reason. This is your disqualification profile, and it is usually not what the owner assumes.</p>
</li>
<li><p>Promote the top two disqualification reasons to steps one and two. Build the filter that matches the observed leak.</p>
</li>
<li><p>Declare the steps as data, as above. Resist putting business rules in the component.</p>
</li>
<li><p>Set validation to fire on advance only, with the message rendered adjacent to the input rather than at the top of the form.</p>
</li>
<li><p>Add focus management and <code>aria-live</code> on transition, plus <code>inputMode</code> and <code>autoComplete</code> per field.</p>
</li>
<li><p>Emit an analytics event on every step entry and every validation failure, with the step id as a property. Total submissions is a useless metric here, per step drop off is the whole point.</p>
</li>
<li><p>Route on the answers. High intent selections go to a calendar or deposit step, low intent selections go to a pricing or FAQ page rather than into an inbox.</p>
</li>
</ol>
<h2>What the instrumentation actually tells you</h2>
<p>Once per step events are flowing, the diagnosis becomes mechanical. A cliff at one step means that question is worded badly or asked too early. Repeated validation failures on a single field mean the input type is wrong, not that users are careless. Users going backwards repeatedly usually means the step order does not match how they think about the job.</p>
<p>The instinct when submissions drop is to remove a step. Usually the right move is to move a step, because the offending question was fine and its position was not.</p>
<hr />
<blockquote>
<p>Written by the team at <a href="https://xenonstudio.net">Xenon Builds</a>. We build Framer sites and booking systems for automotive shops across the US, which means we spend a lot of time on conversion, page speed and form UX for a very specific kind of small business.</p>
</blockquote>
]]></content:encoded></item></channel></rss>