Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/FORM_FIELDS.md

Form V1 — Field Reference

Last updated: May 19, 2026 at 4:17 PM ET

The instant-quote form (the multi-step form mounted on every public landing page via the hero section) is the most important conversion surface in the product. Field labels, copy, and step structure are designed for CRO; internal field keys are stable so the API, analytics, and downstream consumers don't drift when copy changes.

This document is the source of truth for the V1 field schema.

Related docs:

Step structure

Step Title Copy
1 Property address "Where should we measure the roof?"
2 About your roof "We'll measure most of this for you."
3 Project details "A few quick details help us price it right."
4 Where should we send your estimate? "We'll text and email your price range right away."

Between steps 1 and 2 we may play a brief roof-measuring transition while the EagleView prefetch resolves — the form drops into a blueprint animation for 2.2s minimum (25s max safety cutoff) so the user can see what's happening behind the scenes.

The final submit button reads "Show My Instant Estimate" and is disabled until every required field on every step is filled.

Field schema

Legend:

  • Affects pricing?yes means the value flows into calculateEstimate(...) and changes the estimate band.
  • Safe default if "Not sure"? — what the pricing engine substitutes when the homeowner picks the "Not sure" option.
  • Admin visible? — whether the field is surfaced (today, or planned) in the admin submissions view at /admin/clients/[id]/leads.

Step 1 — Property address

Label Internal key Type Required Affects pricing? "Not sure" default Admin visible?
Street address street_address text + Google Places yes no (used by EagleView) yes
City city text yes no yes
State state text (2-letter) yes no yes
ZIP zip text (digits + dash) yes no yes
(mirror) Full property address full_property_address text (computed) computed no (used by EagleView) yes
(internal) Address components addressComponents object optional no (used by EagleView) no

The street address field is wired to Google Places autocomplete. When the user picks a suggestion, the four split fields populate together AND addressComponents is stashed (place_id, lat/lng, country) for the EagleView prefetch. If autocomplete is unavailable (API key missing, suggestion not picked), all four fields stay editable and manual entry still submits.

The composed full_property_address is sent on submit as both full_property_address and the legacy address field so the API fills quotes.address unchanged.

Step 2 — About your roof

Label Internal key Type Required Affects pricing? "Not sure" default Admin visible?
Estimated roof size roof_square_feet integer (200–30,000) yes yes (primary pricing input) — (EagleView fallback) yes
How many stories? stories enum yes yes 1 story (stories_mult["unknown"] = 1.0, saved as integer 1) yes

Roof size is auto-filled from the EagleView measurement when available. The field stays editable; if the user adjusts the number, roof_area_source flips from eagleviewfallback automatically.

Stories enum: "1" · "2" · "3" · "unknown" (Not sure).

When the homeowner picks "Not sure" for stories, the integer quotes.stories column requires a value ≥ 1, so the server coerces unknown1 before insert. The pricing multiplier is also 1.0, so the estimate is identical to picking "1 story" explicitly. This is the safest fallback — it doesn't inflate the estimate.

Step 3 — Project details

Pricing-critical only. Each card uses short labels (and a short helper line where the label is ambiguous) so the step stays scannable. The non-pricing qualifiers (timeline, financing interest) moved to Step 4.

Label Internal key Type Required Affects pricing? "Not sure" default Admin visible?
About how old is the roof? roof_age enum yes yes unknown adder of $1,000 (configurable via client_pricing_rules.age_flat_adder) yes
How complex is the roof? roof_complexity enum yes yes Simple (complexity_mult["unknown"] = 1.0) yes
Preferred material selected_material enum yes yes Client's default_material (fallback: asphalt_arch) yes

Enum values:

  • roof_age: lt_10 · 10_20 · gt_20 · unknown
  • roof_complexity: simple · moderate · complex · unknown
  • selected_material: asphalt_arch · asphalt_3tab · metal · tile (opt-in) · cedar_shake (opt-in) · unknown

Material visibility

By default the form shows three materials: Architectural, 3-tab shingles, and Metal. Tile and cedar shake are opt-in per client via client_pricing_rules.visible_materials (a text[] array). The form fetches the list at mount via GET /api/clients/:slug/quote-config and falls back to the default trio if the request fails.

The form always appends a "Not sure" option as the last card. On submit, the server calls resolveMaterialForPricing(selected_material, client.default_material) which substitutes the client's configured default (column client_pricing_rules.default_material, fallback asphalt_arch). The resolved material is what gets saved on quotes.material so the thank-you page renders a real material label, not "Not sure".

Step 4 — Contact + non-pricing qualifiers

Two compact qualifier rows live above the contact fields. They render as compact label-only OptionCards so the full step stays short.

Label Internal key Type Required Affects pricing? "Not sure" default Admin visible?
When are you looking to start? timeline enum optional no yes
Want monthly options? financing_interest enum optional no yes
First name first_name text yes no yes
Last name last_name text yes no yes
(composed) Full name full_name text (server) computed no yes
Phone phone tel yes no yes
Email email email yes no yes

Enum values:

  • timeline: asap · 30d · 1_3m · researching
  • financing_interest: yes · maybe · no

The form sends first_name + last_name separately and the server composes full_name = "${first} ${last}".trim() for the legacy leads.full_name column. Webhook payloads and the admin display continue to use full_name so consumers don't break.

Below the contact fields, a compact one-line reassurance reads:

"You'll see: ✓ Price range · ✓ Monthly payment option · ✓ Next step"

Followed by the short privacy line:

"No spam. No obligation. Your estimate appears on the next screen."

Data flow

Form → API → DB

QuoteForm (browser)
   │
   ▼  POST /api/quotes  (JSON)
{
  clientSlug,
  street_address, city, state, zip, full_property_address, address, addressComponents,
  roof_square_feet,  (also: roofAreaSqFt — legacy alias)
  stories,
  roof_age, roof_complexity, selected_material,
  timeline, financing_interest,
  first_name, last_name, full_name, phone, email,
  roofAreaSource
}
   │
   ▼  app/api/quotes/route.js
- resolves snake_case ⇄ camelCase
- coerces stories "unknown" → 1
- resolveMaterialForPricing(selected, client.default_material)
- calculateEstimate(...) (lib/estimate.js — formula unchanged)
- INSERT leads (+ first_name, last_name if migration 012 present)
- INSERT quotes (+ timeline, financing_interest if migration 012 present)
- forwardToIntegrations(...)
   │
   ▼  HTTP 200 { publicToken, quoteNumber, low, mid, high, monthlyPayment }
   │
   ▼  Browser navigates to /<slug>/thank-you?quote=<publicToken>

"Not sure" handling (summary)

Field Form value Pricing engine sees Saved on row
stories "unknown" stories_mult["unknown"] = 1.0 integer 1 (column constraint)
roof_complexity "unknown" complexity_mult["unknown"] = 1.0 text "unknown"
roof_age "unknown" age_flat_adder["unknown"] = $1,000 text "unknown"
selected_material "unknown" resolved to client.default_material (or asphalt_arch) resolved material

No "Not sure" value ever produces a null in the pricing path. Every fallback is well-defined.

Backwards compatibility

The API accepts both the new snake_case names and the legacy camelCase names a previous deploy used. Stale browser tabs on the old form will still submit and produce a quote:

New (Form V1) Legacy fallback
street_address / city / state / zip / full_property_address address (single line)
roof_square_feet roofAreaSqFt
roof_age roofAge
roof_complexity complexity
selected_material material
first_name + last_name fullName

Database schema

Form V1 persists across three tables. Migration 012_form_v1_fields.sql adds the new columns; running it is required for first_name, last_name, timeline, financing_interest, visible_materials, and default_material to be stored. Until then the API tolerates the missing columns and drops those fields with a log warning — every other field still persists normally.

leads

Column Source Notes
full_name ${first_name} ${last_name} Always set; backwards-compatible with existing consumers
first_name form NEW (migration 012)
last_name form NEW (migration 012)
phone form
email form (lowercased)

quotes

Column Source Notes
address full_property_address (composed) or legacy address Single-line for backwards compat
street_address, city, state, postal_code split fields (preferred) or Google Places components Form-typed values win over Google parse
country, place_id, latitude, longitude Google Places components
roof_area_sqft roof_square_feet (EagleView or manual)
roof_area_source "eagleview" or "fallback"
stories integer; unknown → 1
roof_age, complexity, material form (material saved as resolved value when selected_material === "unknown")
low_estimate, midpoint_estimate, high_estimate calculateEstimate(...)
estimated_monthly_payment monthlyPayment(midpoint)
timeline form NEW (migration 012)
financing_interest form NEW (migration 012)
quote_number, public_token DB defaults

client_pricing_rules

Column Purpose Notes
visible_materials text[] of material keys the form should show NEW (migration 012); NULL = default trio
default_material substitute for "Not sure" picks NEW (migration 012); NULL = asphalt_arch

Admin submission detail view (planned)

The /admin/clients/[id]/leads view will surface every field above so admins can see the full submission at a glance. Today it shows:

  • Submitted timestamp
  • Lead name + email + phone
  • Address
  • Roof options (material, complexity, stories, age, roof area + source)
  • Estimate band
  • Quote number + thank-you page link

Form V1 adds timeline and financing interest to the data set; the admin row layout will be extended in a follow-up to surface them in the table. They're persisted today on quotes.timeline / quotes.financing_interest — no rework needed.

The planned add-ons (out of scope for V1) listed on the empty state:

  • Lead source / attribution (UTM params)
  • Per-webhook delivery status
  • Contractor follow-up notes
  • Lead status (new, contacted, booked, won, lost)