docs/FORM_FIELDS.mdForm 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:
- README — project overview + file map
- ARCHITECTURE — request flow + database tables
- PRICING_LOGIC — the estimate formula
- INTEGRATIONS — webhook + EagleView flow
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? —
yesmeans the value flows intocalculateEstimate(...)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 eagleview → fallback 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
unknown → 1 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·unknownroof_complexity:simple·moderate·complex·unknownselected_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 |
yes | no | — | yes |
Enum values:
timeline:asap·30d·1_3m·researchingfinancing_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)