export function pergolaPatioCoverCalculator(form: FormTs) {
// Structure price per square foot of cover (width x projection), fabricated and delivered
const structureRates: Record<string, number> = {
'louvered': 95, // motorized louvered pergola: extruded aluminum, motor, gutters in the beams
'solid': 42, // solid aluminum patio cover with W-pan roof panels
'lattice': 30, // open lattice cover: rafters and 2x2 lattice tubes
'canopy': 55 // pergola with a motorized retractable fabric canopy
};
// Surcharges on the structure, in percent
const insulatedPanelsPct = 30; // 3-inch insulated panels instead of W-pan (solid cover only)
const freestandingPct = 20; // four posts, a back beam and heavier footings
const projectionLimitFt = 14; // beyond this projection the heavier beams are charged
const longProjectionPct = 12;
const darkColorPct = 6; // bronze or black powder coat
const woodgrainPct = 15; // wood-look powder coat
const zonePct: Record<string, number> = { 'standard': 0, 'wind': 10, 'snow': 15 };
// Options
const ledPerFt = 28; // dimmable LED strip in the beams, per linear foot of perimeter
const sidesPerSqft: Record<string, number> = { 'none': 0, 'screens': 32, 'glass': 110 };
const sensorPrice = 650; // rain and wind sensor
const heaterEach = 1400; // electric infrared heater with the circuit
const fanEach = 850; // fan beam, outdoor-rated fan and wiring
// Installation and delivery
const footingEach = 350; // poured concrete footing under one post
const anchorEach = 95; // anchoring a post to an existing slab
const installPct = 18; // crew on site, percent of structure and options
const permitPrice = 650; // city permit with engineered drawings and inspection
const hoaWeeks = 3; // HOA architectural review, on the schedule only
const freeDeliveryMiles = 40;
const deliveryPerMile = 3;
// Discounts, minimum and the estimate range
const prepayPct = 3; // payment in full before the installation
const promoPct = 0; // promotional discount on the whole order; 0 hides the banner
const promoText = 'LED lighting included';
const minimumJob = 6500;
const rangePct = 10; // the estimate is shown as a range of +/- this percent
const leadWeeks = 6; // from the signed contract to the installation
const siteVisitDays = 5; // free site visit within this many business days
const validityDays = 30;
const kindNames: Record<string, string> = {
'louvered': 'Louvered pergola',
'solid': 'Solid patio cover',
'lattice': 'Lattice cover',
'canopy': 'Retractable canopy pergola'
};
const goalToKind: Record<string, string> = { 'heat': 'solid', 'light': 'lattice', 'year': 'louvered', 'flex': 'canopy' };
const surfaceNames: Record<string, string> = {
'slab': 'Concrete patio',
'pavers': 'Pavers or flagstone',
'deck': 'Wood or composite deck',
'dirt': 'Lawn or dirt'
};
const colorNames: Record<string, string> = { 'white': 'White', 'adobe': 'Adobe', 'bronze': 'Bronze', 'black': 'Black', 'woodgrain': 'Wood-look' };
const zoneNames: Record<string, string> = { 'standard': 'Standard', 'wind': 'High-wind zone', 'snow': 'Snow country' };
const sideNames: Record<string, string> = { 'none': 'Open sides', 'screens': 'Motorized screens', 'glass': 'Sliding glass panels' };
const sideCounts: Record<string, number> = { 'front': 1, 'front_sides': 3, 'all': 4 };
const money = (amount: number): string => {
const whole = Math.round(Math.abs(amount));
let digits = '' + whole;
let grouped = '';
while (digits.length > 3) {
grouped = ',' + digits.slice(-3) + grouped;
digits = digits.slice(0, -3);
}
return (amount < 0 ? '-$' : '$') + digits + grouped;
};
const pick = (table: Record<string, unknown>, value: unknown, fallback: string): string =>
typeof value === 'string' && value in table ? value : fallback;
const feetInches = (ft: number): string => {
const whole = Math.floor(ft);
const inches = Math.round((ft - whole) * 12);
return inches === 0 ? whole + ' ft' : whole + ' ft ' + inches + ' in';
};
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Patio Cover & Pergola Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Compare a louvered pergola, a solid or lattice patio cover and a retractable canopy pergola, size it in feet and see a price range with installation before the free site visit.',
customStyles: { 'font-size': '0.95rem', 'color': '#64748b' }
});
});
form.addSpacer({ height: 16 });
const steps = form.addPages('steps', { heightMode: 'current-page' });
const patioPage = steps.addPage('patio');
const structurePage = steps.addPage('structure');
const optionsPage = steps.addPage('options');
const estimatePage = steps.addPage('estimate');
// Step 1: the patio, what for and on what, then the structure
const patioSection = patioPage.addSubform('patioDetails', { title: '🏡 Your Patio' });
patioSection.addRow(row => {
row.addRadioButton('goal', {
label: 'What do you want from the cover?',
tooltip: 'We suggest the structure to start from; you can pick another one below',
options: [
{ id: 'heat', name: 'Beat the heat - a cool patio at 3 p.m. in August' },
{ id: 'light', name: 'Filtered light - some sun through, the sky still there' },
{ id: 'year', name: 'An outdoor living room - sun, rain and cool evenings, all year' },
{ id: 'flex', name: 'Shade on demand - covered on hot days, open for the stars' }
],
defaultValue: 'year',
orientation: 'vertical',
isRequired: true,
// The goal picks the structure to start from; the visitor can still choose another one.
onValueChange: value => {
const suggested = goalToKind[pick(goalToKind, value, 'year')];
structureSection.radioButton('kind')?.setValue(suggested);
}
}, '1fr');
row.addRadioButton('surface', {
label: 'What will it stand on?',
tooltip: 'Decides whether the posts get concrete footings or are anchored to the slab',
options: [
{ id: 'slab', name: 'Concrete patio - posts anchored to the slab' },
{ id: 'pavers', name: 'Pavers or flagstone - footings under the pavers' },
{ id: 'deck', name: 'Wood or composite deck - footings under the deck' },
{ id: 'dirt', name: 'Lawn or dirt, or a patio still to be poured' }
],
defaultValue: 'slab',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const goal = form.computedValue(() => pick(goalToKind, patioSection.radioButton('goal')?.value(), 'year'));
const surface = form.computedValue(() => pick(surfaceNames, patioSection.radioButton('surface')?.value(), 'slab'));
const anchorsToSlab = form.computedValue(() => surface() === 'slab');
patioSection.addRow(row => {
row.addTextPanel('deckNote', {
computedValue: () => 'A deck needs support underneath: we do not bolt posts to the deck boards, but to concrete or to footings under the deck. On the site visit we check where they can go.',
isVisible: () => surface() === 'deck',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
const structureSection = patioPage.addSubform('structureDetails', { title: '⛱️ Structure' });
structureSection.addRow(row => {
row.addRadioButton('kind', {
label: 'Type of cover',
options: () => [
{ id: 'louvered', name: 'Louvered pergola - motorized louvers: a roof when closed, stripes of shade when tilted (from $' + structureRates['louvered'] + '/sq ft)' },
{ id: 'solid', name: 'Solid patio cover - insulated or W-pan aluminum roof, sloped away from the house (from $' + structureRates['solid'] + '/sq ft)' },
{ id: 'lattice', name: 'Lattice cover - open 2x2 lattice on rafters: filtered light (from $' + structureRates['lattice'] + '/sq ft)' },
{ id: 'canopy', name: 'Retractable canopy pergola - fabric roof on guide rails (from $' + structureRates['canopy'] + '/sq ft)' }
],
defaultValue: 'louvered',
orientation: 'vertical',
isRequired: true
});
});
const kind = form.computedValue(() => pick(kindNames, structureSection.radioButton('kind')?.value(), 'louvered'));
structureSection.addRow(row => {
row.addTextPanel('suggestion', {
computedValue: () => goal() === kind()
? '✓ ' + kindNames[kind()] + ' is our pick for what you want from the cover.'
: 'For what you want from the cover we would start from the ' + (kindNames[goal()] || '').toLowerCase() + ', but every structure here can be built on your patio.',
customStyles: { 'font-size': '0.9rem', 'color': '#059669' }
});
});
structureSection.addRow(row => {
row.addDropdown('color', {
label: 'Frame color',
options: [
{ id: 'white', name: 'White (included)' },
{ id: 'adobe', name: 'Adobe (included)' },
{ id: 'bronze', name: 'Bronze (+' + darkColorPct + '%)' },
{ id: 'black', name: 'Black (+' + darkColorPct + '%)' },
{ id: 'woodgrain', name: 'Wood-look (+' + woodgrainPct + '%)' }
],
defaultValue: 'bronze',
isRequired: true
}, '1fr');
row.addDropdown('panels', {
label: 'Roof panels',
options: [
{ id: 'insulated', name: 'Insulated 3-inch panels (+' + insulatedPanelsPct + '%)' },
{ id: 'pan', name: 'W-pan roof (included)' }
],
defaultValue: 'insulated',
isVisible: () => kind() === 'solid'
}, '1fr');
row.addDropdown('fabric', {
label: 'Canopy fabric',
options: [
{ id: 'sand', name: 'Sand' },
{ id: 'gray', name: 'Gray' },
{ id: 'charcoal', name: 'Charcoal' },
{ id: 'navy', name: 'Navy' }
],
defaultValue: 'sand',
isVisible: () => kind() === 'canopy'
}, '1fr');
});
const color = form.computedValue(() => pick(colorNames, structureSection.dropdown('color')?.value(), 'bronze'));
const insulated = form.computedValue(() => kind() === 'solid' && structureSection.dropdown('panels')?.value() !== 'pan');
const fabric = form.computedValue(() => structureSection.dropdown('fabric')?.value() || 'sand');
// Step 2: size and mounting
const sizeSection = structurePage.addSubform('sizeDetails', { title: '📐 Size & Mounting' });
sizeSection.addRow(row => {
row.addSlider('width', {
label: 'Width along the house wall',
unit: 'ft',
min: 8,
max: 24,
step: 1,
defaultValue: 14
}, '1fr');
row.addSlider('projection', {
label: 'Projection from the wall',
unit: 'ft',
min: 8,
max: 20,
step: 1,
defaultValue: 12,
tooltip: 'Over ' + projectionLimitFt + ' ft the heavier beams add ' + longProjectionPct + '%'
}, '1fr');
});
sizeSection.addRow(row => {
row.addSlider('height', {
label: 'Height under the beam',
unit: 'ft',
min: 8,
max: 12,
step: 0.5,
defaultValue: 9
}, '1fr');
row.addRadioButton('mount', {
label: 'Mounting',
options: [
{ id: 'attached', name: 'Attached to the house - two posts in front, the back on a ledger (included)' },
{ id: 'freestanding', name: 'Freestanding - four posts, anywhere in the yard (+' + freestandingPct + '%)' }
],
defaultValue: 'attached',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
sizeSection.addRow(row => {
row.addRadioButton('zone', {
label: 'Where does it stand?',
tooltip: 'The wind and snow ratings the local code asks for decide the beams and the footings',
options: [
{ id: 'standard', name: 'Standard - up to 115 mph wind, no snow load (included)' },
{ id: 'wind', name: 'High-wind zone - coastal or exposed, 130 mph and up (+' + zonePct['wind'] + '%)' },
{ id: 'snow', name: 'Snow country - mountain towns with a snow load (+' + zonePct['snow'] + '%)' }
],
defaultValue: 'standard',
orientation: 'vertical',
isRequired: true
});
});
const width = form.computedValue(() => sizeSection.slider('width')?.value() || 14);
const projection = form.computedValue(() => sizeSection.slider('projection')?.value() || 12);
const height = form.computedValue(() => sizeSection.slider('height')?.value() || 9);
const freestanding = form.computedValue(() => sizeSection.radioButton('mount')?.value() === 'freestanding');
const zone = form.computedValue(() => pick(zoneNames, sizeSection.radioButton('zone')?.value(), 'standard'));
const area = form.computedValue(() => width() * projection());
const posts = form.computedValue(() => freestanding() ? 4 : 2);
// The perimeter the LED runs along: the front, the sides and the back when there is no wall
const perimeter = form.computedValue(() => width() * (freestanding() ? 2 : 1) + 2 * projection());
sizeSection.addRow(row => {
row.addTextPanel('sizeSummary', {
computedValue: () => kindNames[kind()] + ' ' + width() + ' ft × ' + projection() + ' ft = ' + area() + ' sq ft, ' + feetInches(height()) + ' under the beam, ' + posts() + ' posts.',
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' }
});
});
// Step 3: options, then installation and delivery
const comfortSection = optionsPage.addSubform('comfortOptions', { title: '✨ Lights, Sides & Comfort' });
comfortSection.addRow(row => {
row.addCheckbox('led', {
label: 'LED strips in the beams ($' + ledPerFt + '/ft of beams)',
defaultValue: false
}, '1fr');
row.addRadioButton('sides', {
label: 'Closing the sides',
options: [
{ id: 'none', name: 'Open sides - just the roof' },
{ id: 'screens', name: 'Motorized zip screens ($' + sidesPerSqft['screens'] + '/sq ft of side)' },
{ id: 'glass', name: 'Sliding glass panels ($' + sidesPerSqft['glass'] + '/sq ft of side)' }
],
defaultValue: 'none',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const led = form.computedValue(() => comfortSection.checkbox('led')?.value() === true);
const sides = form.computedValue(() => pick(sideNames, comfortSection.radioButton('sides')?.value(), 'none'));
comfortSection.addRow(row => {
row.addRadioButton('whichSides', {
label: 'Which sides?',
tooltip: 'Against the house the wall takes the back; only a freestanding structure can be closed all around',
options: () => [
{ id: 'front', name: 'Front' },
{ id: 'front_sides', name: 'Front and both sides' },
...(freestanding() ? [{ id: 'all', name: 'All around' }] : [])
],
defaultValue: 'front_sides',
orientation: 'horizontal',
isVisible: () => sides() !== 'none'
});
});
const sideCount = form.computedValue(() => {
if (sides() === 'none') return 0;
const chosen = pick(sideCounts, comfortSection.radioButton('whichSides')?.value(), 'front_sides');
return Math.min(sideCounts[chosen] || 0, freestanding() ? 4 : 3);
});
// A sensor has something to do only when something can close or roll in
const sensorMakesSense = form.computedValue(() => kind() === 'louvered' || kind() === 'canopy' || sides() === 'screens');
const heaterCount = form.computedValue(() => width() > 14 ? 2 : 1);
// A canopy has nothing to hang a fan from
const fanFits = form.computedValue(() => kind() !== 'canopy');
const fanCount = form.computedValue(() => width() > 18 ? 2 : 1);
comfortSection.addRow(row => {
row.addCheckboxList('comfort', {
label: 'Comfort',
options: () => [
...(sensorMakesSense() ? [{ id: 'sensor', name: 'Rain and wind sensor - closes the louvers in rain, retracts the canopy and screens in a gust (+' + money(sensorPrice) + ')' }] : []),
{ id: 'heaters', name: (heaterCount() > 1 ? 'Two infrared heaters' : 'Infrared heater') + ' under the beam at the house (+' + money(heaterCount() * heaterEach) + ')' },
...(fanFits() ? [{ id: 'fan', name: (fanCount() > 1 ? 'Two ceiling fans' : 'Ceiling fan') + ' on a fan beam across the middle (+' + money(fanCount() * fanEach) + ')' }] : [])
],
orientation: 'vertical'
});
});
const comfort = () => comfortSection.checkboxList('comfort')?.value() || [];
const sensor = form.computedValue(() => sensorMakesSense() && comfort().includes('sensor'));
const heaters = form.computedValue(() => comfort().includes('heaters'));
const fan = form.computedValue(() => fanFits() && comfort().includes('fan'));
const installSection = optionsPage.addSubform('installation', { title: '🔧 Installation & Delivery' });
installSection.addRow(row => {
row.addCheckbox('installed', {
label: 'Installed by our crew (+' + installPct + '% of the structure, footings and the city permit)',
tooltip: 'Unchecked: delivered as a kit, you install and pull the permit yourself',
defaultValue: true
}, '1fr');
row.addCheckbox('hoa', {
label: 'My neighborhood has an HOA (about ' + hoaWeeks + ' weeks for the architectural review)',
defaultValue: false
}, '1fr');
});
installSection.addRow(row => {
row.addSlider('distance', {
label: 'Distance from our shop',
unit: 'mi',
min: 0,
max: 200,
step: 5,
defaultValue: 20,
tooltip: 'Up to ' + freeDeliveryMiles + ' mi we deliver free, beyond that $' + deliveryPerMile + ' per mile'
}, '1fr');
row.addCheckbox('prepay', {
label: 'Pay in full before the installation (' + prepayPct + '% discount)',
defaultValue: false
}, '1fr');
});
const installed = form.computedValue(() => installSection.checkbox('installed')?.value() === true);
const hoa = form.computedValue(() => installSection.checkbox('hoa')?.value() === true);
const distance = form.computedValue(() => installSection.slider('distance')?.value() || 0);
const prepay = form.computedValue(() => installSection.checkbox('prepay')?.value() === true);
// Pricing
const structureAmount = form.computedValue(() => {
const base = area() * (structureRates[kind()] || 0);
const forPanels = insulated() ? 1 + insulatedPanelsPct / 100 : 1;
const forMount = freestanding() ? 1 + freestandingPct / 100 : 1;
const forProjection = projection() > projectionLimitFt ? 1 + longProjectionPct / 100 : 1;
const forColor = color() === 'white' || color() === 'adobe' ? 1 : 1 + (color() === 'woodgrain' ? woodgrainPct : darkColorPct) / 100;
const forZone = 1 + (zonePct[zone()] || 0) / 100;
return base * forPanels * forMount * forProjection * forColor * forZone;
});
const ledAmount = form.computedValue(() => led() ? perimeter() * ledPerFt : 0);
// The sides go in order: the front by the width, the sides by the projection, the back of a freestanding one by the width again
const sidesArea = form.computedValue(() => {
const count = sideCount();
let total = 0;
if (count >= 1) total += width() * height();
if (count >= 3) total += 2 * projection() * height();
if (count >= 4) total += width() * height();
return total;
});
const sidesAmount = form.computedValue(() => sidesArea() * (sidesPerSqft[sides()] || 0));
const sensorAmount = form.computedValue(() => sensor() ? sensorPrice : 0);
const heatersAmount = form.computedValue(() => heaters() ? heaterCount() * heaterEach : 0);
const fanAmount = form.computedValue(() => fan() ? fanCount() * fanEach : 0);
const optionsAmount = form.computedValue(() => ledAmount() + sidesAmount() + sensorAmount() + heatersAmount() + fanAmount());
const footingsAmount = form.computedValue(() => installed() ? posts() * (anchorsToSlab() ? anchorEach : footingEach) : 0);
const permitAmount = form.computedValue(() => installed() ? permitPrice : 0);
const installAmount = form.computedValue(() => installed() ? (structureAmount() + optionsAmount()) * installPct / 100 : 0);
const deliveryAmount = form.computedValue(() => Math.max(0, distance() - freeDeliveryMiles) * deliveryPerMile);
const netAmount = form.computedValue(() => structureAmount() + optionsAmount() + footingsAmount() + permitAmount() + installAmount() + deliveryAmount());
const prepayDiscount = form.computedValue(() => prepay() ? netAmount() * prepayPct / 100 : 0);
const promoDiscount = form.computedValue(() => (netAmount() - prepayDiscount()) * promoPct / 100);
// The minimum holds last: a promotion may never take a job below what the crew costs
const total = form.computedValue(() => Math.max(Math.round(netAmount() - prepayDiscount() - promoDiscount()), minimumJob));
const priceFrom = form.computedValue(() => Math.round(total() * (1 - rangePct / 100) / 100) * 100);
const priceTo = form.computedValue(() => Math.round(total() * (1 + rangePct / 100) / 100) * 100);
const footingsText = () => anchorsToSlab()
? posts() + ' posts anchored to the slab'
: posts() + ' concrete footings' + (surface() === 'deck' ? ' (checked on the site visit)' : '');
const sidesText = () => sides() === 'none'
? 'open sides'
: (sideNames[sides()] || '').toLowerCase() + ': ' + (sideCount() === 1 ? 'front' : sideCount() === 3 ? 'front and both sides' : 'all around');
const structureText = () => kindNames[kind()] + ', ' + (colorNames[color()] || '').toLowerCase()
+ (kind() === 'solid' ? (insulated() ? ', insulated panels' : ', W-pan roof') : '')
+ (kind() === 'canopy' ? ', ' + fabric() + ' fabric' : '');
// Step 4: the estimate and the contact details
const summarySection = estimatePage.addSubform('summary', { title: '💰 Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addTextPanel('promo', {
computedValue: () => '🎉 ' + promoText + ': the ' + promoPct + '% discount is already in the amount below.',
isVisible: () => promoDiscount() > 0,
customStyles: { 'font-size': '0.9rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' }
});
});
summarySection.addRow(row => {
row.addPriceDisplay('structurePrice', {
label: () => kindNames[kind()] + ', ' + area() + ' sq ft',
computedValue: () => Math.round(structureAmount()),
decimals: 0
}, '1fr');
row.addPriceDisplay('optionsPrice', {
label: 'Lights, sides and comfort',
computedValue: () => Math.round(optionsAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('installPrice', {
label: () => installed() ? 'Installation, footings and permit' : 'Delivered as a kit, you install',
computedValue: () => Math.round(installAmount() + footingsAmount() + permitAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
row.addPriceDisplay('deliveryPrice', {
label: () => 'Delivery, ' + distance() + ' mi from our shop',
computedValue: () => Math.round(deliveryAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('discounts', {
label: () => 'Discounts' + (prepay() ? ' (prepayment ' + prepayPct + '%)' : ''),
computedValue: () => -Math.round(prepayDiscount() + promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => prepayDiscount() + promoDiscount() > 0
});
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('total', {
label: 'Cover with installation, about',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: 'before sales tax'
});
});
summarySection.addRow(row => {
row.addTextPanel('range', {
computedValue: () => 'Expect ' + money(priceFrom()) + ' – ' + money(priceTo()) + ' (±' + rangePct + '%); the final price is confirmed after the free site visit.'
+ (total() > Math.round(netAmount() - prepayDiscount() - promoDiscount()) ? ' The estimate was raised to our minimum job of ' + money(minimumJob) + '.' : ''),
customStyles: { 'font-size': '0.9rem', 'color': '#475569' }
});
});
summarySection.addRow(row => {
row.addTextPanel('schedule', {
computedValue: () => '📅 Site visit within ' + siteVisitDays + ' business days'
+ (hoa() ? ' → HOA review, about ' + hoaWeeks + ' weeks' : '')
+ (installed() ? ' → city permit with engineered drawings' : ' → permit on your side, we supply the drawings')
+ ' → installation ' + leadWeeks + ' weeks from the signed contract.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
const contactSection = estimatePage.addSubform('contact', { title: '📋 Book Your Free Site Visit', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('name', { label: 'Full name', placeholder: 'John Smith', isRequired: true }, '1fr');
row.addTextbox('phone', { label: 'Phone', placeholder: '(555) 123-4567', isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addEmail('email', { label: 'Email', placeholder: 'john@example.com', isRequired: true }, '1fr');
row.addTextbox('city', { label: 'City, state', placeholder: 'Mesa, AZ', isRequired: true }, '1fr');
row.addTextbox('zip', { label: 'ZIP code', placeholder: '85202', maxLength: 10, isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else we should know?',
placeholder: 'E.g. a gutter or a window above the patio, an outlet on the wall, a second-story balcony, a pool nearby',
rows: 3
});
});
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'patio-cover-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download your estimate (PDF)',
header: {
title: 'Patio cover estimate and configuration',
subtitle: kindNames[kind()] + ' ' + width() + ' ft × ' + projection() + ' ft'
},
footer: {
text: 'Estimate based on the configuration from the form, valid for ' + validityDays + ' days. A binding quote follows the free site visit within ' + siteVisitDays + ' business days.',
showPageNumbers: true
}
});
pdf.addSection('Customer', section => {
section.addRow(row => {
row.addField('Name', contactSection.textbox('name')?.value() || '');
row.addField('Phone', contactSection.textbox('phone')?.value() || '');
row.addField('Email', contactSection.email('email')?.value() || '');
row.addField('Location', (contactSection.textbox('city')?.value() || '') + ' ' + (contactSection.textbox('zip')?.value() || ''));
});
});
pdf.addSection('Your cover', section => {
section.addRow(row => {
row.addField('Structure', structureText());
row.addField('Size', width() + ' ft × ' + projection() + ' ft (' + area() + ' sq ft)');
row.addField('Height under the beam', feetInches(height()));
row.addField('Mounting', freestanding() ? 'Freestanding' : 'Attached to the house');
});
section.addRow(row => {
row.addField('Surface', surfaceNames[surface()] || '');
row.addField('Zone', zoneNames[zone()] || '');
row.addField('LED lighting', led() ? 'yes, ' + Math.round(perimeter()) + ' ft in the beams' : 'no');
row.addField('Sides', sidesText());
});
section.addRow(row => {
row.addField('Rain and wind sensor', sensor() ? 'yes' : 'no');
row.addField('Heaters', heaters() ? heaterCount() + ' pcs' : 'no');
row.addField('Ceiling fan', fan() ? fanCount() + ' pcs' : 'no');
row.addField('Paperwork', (installed() ? 'city permit by us' : 'permit by the owner') + (hoa() ? ', HOA approval' : ''));
});
});
pdf.addSection('Estimate', section => {
const rows: string[][] = [
[kindNames[kind()] + ', ' + area() + ' sq ft', money(structureAmount())]
];
if (ledAmount() > 0) rows.push(['LED lighting, ' + Math.round(perimeter()) + ' ft of beams', money(ledAmount())]);
if (sidesAmount() > 0) rows.push([sideNames[sides()] + ', ' + Math.round(sidesArea()) + ' sq ft', money(sidesAmount())]);
if (sensorAmount() > 0) rows.push(['Rain and wind sensor', money(sensorAmount())]);
if (heatersAmount() > 0) rows.push(['Infrared heaters, ' + heaterCount() + ' × ' + money(heaterEach), money(heatersAmount())]);
if (fanAmount() > 0) rows.push(['Ceiling fan, ' + fanCount() + ' × ' + money(fanEach), money(fanAmount())]);
rows.push([installed() ? 'Installation (' + installPct + '%), ' + footingsText() + ', permit' : 'Delivered as a kit, you install', money(installAmount() + footingsAmount() + permitAmount())]);
rows.push(['Delivery, ' + distance() + ' mi (free up to ' + freeDeliveryMiles + ' mi)', money(deliveryAmount())]);
if (prepayDiscount() > 0) rows.push(['Prepayment discount ' + prepayPct + '%', '-' + money(prepayDiscount())]);
if (promoDiscount() > 0) rows.push([promoText + ' ' + promoPct + '%', '-' + money(promoDiscount())]);
section.addTable(['Item', 'Amount'], rows);
section.addDivider();
section.addText('Estimate: ' + money(priceFrom()) + ' – ' + money(priceTo()) + ' before sales tax, with installation and delivery. Range ±' + rangePct + '%, price confirmed after the site visit.');
});
pdf.addSection('What happens next', section => {
section.addText('1. Free site visit within ' + siteVisitDays + ' business days: we confirm the size, the surface and the wall.');
section.addText('2. ' + (hoa() ? 'HOA architectural review, about ' + hoaWeeks + ' weeks, with our drawings. ' : '') + (installed() ? 'The city permit is ours, with the engineered drawings and the final inspection.' : 'The permit is on your side; we supply the engineered drawings.'));
section.addText('3. After the site visit you receive the final quote and the contract; the color and the options can change until it is signed.');
section.addText('4. Installation ' + leadWeeks + ' weeks from the signed contract.');
const notes = contactSection.textarea('notes')?.value();
if (notes) {
section.addSpacer(8);
section.addText('Notes: ' + notes);
}
});
});
form.configureSubmitButton({ label: 'Send and book my free site visit' });
form.configureCompletionScreen({
type: 'text',
title: 'We have your request',
message: 'We will call to schedule the free site visit within ' + siteVisitDays + ' business days. The size, the structure and the options you chose are the starting point for your quote.'
});
}