export function hurricaneShutterCalculator(form: FormTs) {
// Price list in USD: what a Sunbelt shade and shutter company charges before the measure.
const prices = {
measureDays: 3, // free measure within this many business days
leadWeeks: 6, // from the measure to the installed job
rushDays: 10, // rush on in-stock fabric
rushPct: 15, // rush surcharge
promoPct: 0, // current promotion on the whole job (0 = no banner)
promoText: 'Pre-season shutter sale',
financingNote: 'Financing available: 0% APR for 12 months on approved credit.',
solar90PerSqft: 1, // 90% fabric instead of 80%
privacy95PerSqft: 1.5, // near-opaque privacy fabric
zipPerSqft: 6, // zip side tracks on a roller shade
blackoutPerSqft: 4, // blackout fabric on a roller shade
minSqft: 8, // minimum area charged per piece
panelsClearPerSqft: 6, // clear polycarbonate storm panels
customColorPerSqft: 3, // powder coat outside the standard colors
archPct: 35, // arched or shaped opening
retractMin: 450, // the least charged for one retractable screen
noseeumPerSqft: 0.6, // fine 20/20 mesh
petPerSqft: 1.1, // pet-resistant mesh
motorEach: 550, // tubular motor instead of a hand crank
switchEach: 90, // keyed wall switch for a hardwired motor
remoteEach: 120, // one remote per order
windSensorEach: 180, // per motorized roller shade
hubEach: 350, // smart home hub, once per order
installEach: 85, // fitting one piece on the house
secondFloorEach: 45, // ladder or lift work above the ground floor
removalEach: 30, // taking down one old screen or shutter
permitFee: 350, // permit and engineering for rated shutters
measureFee: 0, // zero when the measure is free
tripPerMile: 2.5, // per mile one way beyond the free radius
freeMiles: 25,
minJob: 750, // the smallest job taken
disc6Pct: 5, // discount from 6 openings
disc12Pct: 8, // discount from 12 openings
rangePct: 12 // the estimate is shown as a range this wide
};
type Family = 'fabric' | 'shutter' | 'screen' | 'mesh';
type Product = { name: string; rate: number; family: Family; zip: boolean; motor: boolean; rated: boolean; diy: boolean };
// Everything the company puts on the outside of a house, priced per square foot.
const PRODUCTS: Record<string, Product> = {
'solar-screen': { name: 'Solar screens', rate: 11, family: 'fabric', zip: false, motor: false, rated: false, diy: true },
'roller-shade': { name: 'Exterior roller shade', rate: 28, family: 'fabric', zip: true, motor: true, rated: false, diy: false },
'accordion': { name: 'Accordion shutters', rate: 26, family: 'shutter', zip: false, motor: false, rated: true, diy: false },
'rolldown': { name: 'Roll-down shutters', rate: 52, family: 'shutter', zip: false, motor: true, rated: true, diy: false },
'bahama': { name: 'Bahama shutters', rate: 45, family: 'shutter', zip: false, motor: false, rated: true, diy: false },
'colonial': { name: 'Colonial shutters', rate: 48, family: 'shutter', zip: false, motor: false, rated: true, diy: false },
'panels': { name: 'Storm panels', rate: 12, family: 'shutter', zip: false, motor: false, rated: true, diy: true },
'retractable': { name: 'Retractable screen', rate: 16, family: 'screen', zip: false, motor: false, rated: false, diy: false },
'rescreen': { name: 'Re-screening', rate: 1.85, family: 'mesh', zip: false, motor: false, rated: false, diy: false }
};
type Opening = { name: string; width: number; height: number; products: string[] };
// Typical sizes stand in for an opening nobody has measured yet.
const OPENINGS: Record<string, Opening> = {
'window': { name: 'Window', width: 36, height: 60, products: ['solar-screen', 'roller-shade', 'accordion', 'rolldown', 'bahama', 'colonial', 'panels'] },
'arch': { name: 'Arched or shaped window', width: 48, height: 72, products: ['solar-screen', 'panels'] },
'slider': { name: 'Sliding glass door', width: 72, height: 80, products: ['roller-shade', 'accordion', 'rolldown', 'panels', 'retractable'] },
'patio': { name: 'Patio or lanai opening', width: 120, height: 96, products: ['roller-shade', 'rolldown', 'accordion', 'retractable'] },
'entry': { name: 'Entry door', width: 36, height: 80, products: ['retractable', 'panels', 'rolldown', 'accordion'] },
'enclosure': { name: 'Screen enclosure panel', width: 36, height: 96, products: ['rescreen'] }
};
const SIDES: Record<string, { name: string; front: boolean }> = {
'front': { name: 'Front, street side', front: true },
'back': { name: 'Back or pool side', front: false },
'left': { name: 'Left side', front: false },
'right': { name: 'Right side', front: false }
};
const NEEDS: Record<string, string> = {
'heat': 'Cut heat and glare',
'storm': 'Hurricane protection',
'privacy': 'Daytime privacy',
'bugs': 'Keep the bugs out',
'fade': 'Stop the fading',
'looks': 'Curb appeal'
};
const FABRICS: Record<string, { name: string; extra: number; rollerOnly: boolean }> = {
'open80': { name: '80% (best view)', extra: 0, rollerOnly: false },
'open90': { name: '90% (everyday choice for heat)', extra: prices.solar90PerSqft, rollerOnly: false },
'open95': { name: '95% privacy', extra: prices.privacy95PerSqft, rollerOnly: false },
'blackout': { name: 'Blackout', extra: prices.blackoutPerSqft, rollerOnly: true }
};
const MESHES: Record<string, { name: string; extra: number }> = {
'standard': { name: 'Standard 18/14', extra: 0 },
'noseeum': { name: 'No-see-um 20/20', extra: prices.noseeumPerSqft },
'pet': { name: 'Pet-resistant', extra: prices.petPerSqft }
};
const PANEL_MATERIALS: Record<string, { name: string; extra: number }> = {
'aluminum': { name: 'Aluminum', extra: 0 },
'clear': { name: 'Clear polycarbonate', extra: prices.panelsClearPerSqft }
};
// Colors by family; a custom powder coat on a shutter costs extra per square foot.
const COLORS: Record<Family, Record<string, { name: string; custom: boolean }>> = {
fabric: {
'charcoal': { name: 'Charcoal', custom: false }, 'black': { name: 'Black', custom: false }, 'bronze': { name: 'Bronze', custom: false },
'beige': { name: 'Stucco beige', custom: false }, 'gray': { name: 'Gray', custom: false }, 'white': { name: 'White', custom: false }
},
shutter: {
'white': { name: 'White', custom: false }, 'ivory': { name: 'Ivory', custom: false }, 'beige': { name: 'Beige', custom: false },
'bronze': { name: 'Bronze', custom: false }, 'green': { name: 'Forest green', custom: true }, 'navy': { name: 'Navy', custom: true }, 'black': { name: 'Black', custom: true }
},
screen: {
'white': { name: 'White', custom: false }, 'bronze': { name: 'Bronze', custom: false }, 'black': { name: 'Black', custom: false }, 'beige': { name: 'Beige', custom: false }
},
mesh: {
'charcoal': { name: 'Charcoal', custom: false }
}
};
const CONTROLS: Record<string, { name: string; motor: boolean; wired: boolean }> = {
'crank': { name: 'Hand crank', motor: false, wired: false },
'switch': { name: 'Motor with wall switch', motor: true, wired: true },
'remote': { name: 'Motor with remote', motor: true, wired: false }
};
const HOMES: Record<string, string> = { 'house': 'Single-family home', 'condo': 'Condo or townhome', 'business': 'Business' };
const STAGES: Record<string, string> = { 'bare': 'Nothing on the house yet', 'replace': 'Replacing old screens or shutters', 'add': 'Adding to what is there' };
const TIMINGS: Record<string, string> = { 'standard': 'Standard', 'rush': 'Rush', 'no-hurry': 'No hurry' };
const CALL_TIMES: Record<string, string> = { 'any': 'Any time', 'morning': 'Before noon', 'afternoon': 'After noon', 'saturday': 'Saturday' };
const MAX_OPENINGS = 6;
const money = (amount: number): string => {
const rounded = Math.round(amount);
const digits = String(Math.abs(rounded));
let out = '';
for (let i = 0; i < digits.length; i++) {
const fromEnd = digits.length - i;
out += digits.charAt(i);
if (fromEnd > 1 && (fromEnd - 1) % 3 === 0) out += ',';
}
return (rounded < 0 ? '-$' : '$') + out;
};
const perSqft = (rate: number): string => '$' + (rate % 1 === 0 ? String(rate) : rate.toFixed(2)) + '/sq ft';
const pick = (table: Record<string, unknown>, value: unknown, fallback: string): string => typeof value === 'string' && value in table ? value : fallback;
const plural = (n: number, one: string, many: string): string => n + ' ' + (n === 1 ? one : many);
const toOptions = (table: Record<string, { name: string }>): ControlOption[] => Object.keys(table).map(id => ({ id, name: table[id]!.name }));
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Exterior Shading & Hurricane Shutter Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Walk your home opening by opening. Tell us where each one is and what it should do, and we name the product and say why. '
+ 'At the end you see a price range and book a free measure: we come out within ' + prices.measureDays + ' business days.',
customStyles: { 'font-size': '0.95rem', 'color': '#475569' }
});
});
form.addSpacer({ height: 16 });
const steps = form.addPages('steps', { heightMode: 'current-page' });
// ---------------------------------------------------------------- Step 1: the home
const homePage = steps.addPage('home');
homePage.addRow(row => {
row.addTextPanel('homeTitle', {
label: 'Step 1 of 4: Your home',
computedValue: () => 'Where the sun hits, how many stories and whether an HOA has a say.'
});
});
const homeSection = homePage.addSubform('homeDetails', { title: '🏠 Your Home' });
homeSection.addRow(row => {
row.addRadioButton('homeType', {
label: 'What are we shading?',
options: toOptions({ 'house': { name: 'Single-family home' }, 'condo': { name: 'Condo or townhome (HOA approval usually needed)' }, 'business': { name: 'Business (storefront, office, patio)' } }),
defaultValue: 'house',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('stage', {
label: 'Where are you starting from?',
options: toOptions({ 'bare': { name: 'Nothing on the house yet' }, 'replace': { name: 'Replacing old screens or shutters' }, 'add': { name: 'Adding to what I have' } }),
defaultValue: 'bare',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
homeSection.addRow(row => {
row.addDropdown('sunSide', {
label: 'Which side gets the worst of the sun?',
options: [
{ id: 'west', name: 'West' }, { id: 'south', name: 'South' }, { id: 'east', name: 'East' }, { id: 'north', name: 'North' }, { id: 'mixed', name: 'Mixed' }
],
defaultValue: 'mixed',
tooltip: 'West and south glass takes the heat; that is where solar screens pay for themselves first'
}, '1fr');
row.addDropdown('hoa', {
label: 'Do you have an HOA?',
options: [{ id: 'yes', name: 'Yes' }, { id: 'no', name: 'No' }, { id: 'unsure', name: 'Not sure' }],
defaultValue: 'unsure'
}, '1fr');
row.addDropdown('stories', {
label: 'How many stories?',
options: [{ id: 'one', name: 'One story' }, { id: 'two', name: 'Two or more (+' + money(prices.secondFloorEach) + '/opening upstairs)' }],
defaultValue: 'one'
}, '1fr');
});
const homeType = form.computedValue(() => pick(HOMES, homeSection.radioButton('homeType')?.value(), 'house'));
const stage = form.computedValue(() => pick(STAGES, homeSection.radioButton('stage')?.value(), 'bare'));
const sunSide = form.computedValue(() => homeSection.dropdown('sunSide')?.value() || 'mixed');
const hoa = form.computedValue(() => homeSection.dropdown('hoa')?.value() || 'unsure');
const replacing = form.computedValue(() => stage() === 'replace');
const hotSide = form.computedValue(() => sunSide() === 'west' || sunSide() === 'south');
const twoStory = form.computedValue(() => homeSection.dropdown('stories')?.value() === 'two');
// ---------------------------------------------------------------- Step 2: the openings
const openingsPage = steps.addPage('openings');
openingsPage.addRow(row => {
row.addTextPanel('openingsTitle', {
label: 'Step 2 of 4: Openings',
computedValue: () => 'One line is one size. Two identical windows on the same side go on one line with a quantity of 2.'
});
});
const listSection = openingsPage.addSubform('openingList', { title: '📐 The List', isCollapsible: false });
listSection.addRow(row => {
row.addInteger('count', {
label: 'How many different openings do you want covered?',
min: 1,
max: MAX_OPENINGS,
defaultValue: 3,
isRequired: true
}, '1fr');
row.addCheckbox('hub', {
label: 'Smart home hub: every motor in one app and on voice (+' + money(prices.hubEach) + ')',
defaultValue: false
}, '1fr');
});
const kindCount = form.computedValue(() => {
const value = listSection.integer('count')?.value();
return typeof value === 'number' && value >= 1 ? Math.min(value, MAX_OPENINGS) : 3;
});
type Line = {
number: number; side: string; kind: string; sized: boolean; width: number; height: number; quantity: number;
floor: string; needs: string[]; product: string; fabric: string; mesh: string; panelMaterial: string; color: string;
zip: boolean; control: string; windSensor: boolean;
};
// What belongs on this opening, and why: the trade knowledge in one list of rules.
const recommend = (kind: string, needs: string[]): { product: string; reason: string } => {
const wants = (id: string) => needs.indexOf(id) >= 0;
const wide = kind === 'slider' || kind === 'patio';
if (kind === 'enclosure') return { product: 'rescreen', reason: 'A pool cage takes new mesh in its own frames: we re-screen it panel by panel, with new spline.' };
if (kind === 'arch') {
return wants('storm')
? { product: 'panels', reason: 'Accordion and roll-down shutters are square; an arch takes storm panels cut to its shape.' }
: { product: 'solar-screen', reason: 'A solar screen is built to the shape of the arch and cuts the heat without hiding the window.' };
}
if (kind === 'entry') {
return wants('storm')
? { product: 'panels', reason: 'For a door, storm panels on tracks are the simplest rated protection and stay in the garage the rest of the year.' }
: { product: 'retractable', reason: 'A retractable screen hides in a housing beside the door and pulls across when you want the breeze.' };
}
if (wants('storm') && wants('looks')) {
return wide
? { product: 'rolldown', reason: 'A roll-down hides in a housing above the opening and closes from inside with a crank or a button.' }
: { product: 'colonial', reason: 'Colonial shutters are the ones the neighbors take for decoration, until they close them for a storm.' };
}
if (wants('storm') && (wants('heat') || wants('fade') || wants('privacy'))) {
return wide
? { product: 'rolldown', reason: 'A roll-down is the one rated shutter you can also lower half way for shade on a wide opening.' }
: { product: 'bahama', reason: 'Bahama shutters do both jobs: propped open they shade the glass all summer, latched down they are rated for the storm.' };
}
if (wants('storm')) {
if (kind === 'patio') return { product: 'rolldown', reason: 'A wide lanai opening takes a roll-down: one housing, one motor, closed in seconds before the storm.' };
return { product: 'accordion', reason: 'The most affordable shutter you can close yourself in a minute; it folds to the sides the rest of the year.' };
}
if (wants('bugs') && wide) {
return wants('heat') || wants('fade') || wants('privacy')
? { product: 'roller-shade', reason: 'A zip screen shade runs in side tracks: it cuts the sun and closes bug-tight over the whole opening.' }
: { product: 'retractable', reason: 'A retractable screen disappears into its housing when you do not need it and keeps the mosquitoes out when you do.' };
}
if (wants('bugs')) return { product: 'solar-screen', reason: 'A solar screen is also a bug screen: one mesh stops the sun and the insects.' };
if (wants('heat') || wants('fade')) {
return wide
? { product: 'roller-shade', reason: 'A roller shade drops over the whole opening and rolls out of sight; motorized, it is one button.' }
: { product: 'solar-screen', reason: 'Solar screens stop the heat before the glass, where it counts, and cost the least per window.' };
}
if (wants('privacy')) {
return wide
? { product: 'roller-shade', reason: 'A roller shade in privacy fabric closes the lanai to the neighbors and still lets the breeze through.' }
: { product: 'solar-screen', reason: 'A 95% solar screen makes the glass a dark mirror from the street while you look out as before.' };
}
if (wants('looks')) {
return wide
? { product: 'roller-shade', reason: 'A roller shade in a color-matched housing is the cleanest thing you can put over a lanai.' }
: { product: 'bahama', reason: 'Bahama shutters are what a Florida house wears best, and they are rated for the storm on top of it.' };
}
return { product: '', reason: 'Tick what this opening should do for you and we will point to the right product.' };
};
const productsFor = (kind: string): string[] => OPENINGS[kind]?.products ?? OPENINGS['window']!.products;
const cards: SubformTs[] = [];
for (let i = 1; i <= MAX_OPENINGS; i++) {
const card = listSection.addSubform('opening' + i, {
title: () => 'Opening ' + i + ': ' + describeLine(line(i)),
isCollapsible: true,
isVisible: () => kindCount() >= i
});
cards.push(card);
const kindOf = () => pick(OPENINGS, card.dropdown('kind')?.value(), 'window');
const needsOf = () => (card.checkboxList('needs')?.value() || []).filter(id => id in NEEDS);
const productOf = () => {
const chosen = pick(PRODUCTS, card.dropdown('product')?.value(), '');
return chosen && productsFor(kindOf()).indexOf(chosen) >= 0 ? chosen : productsFor(kindOf())[0]!;
};
const familyOf = () => PRODUCTS[productOf()]!.family;
const measured = () => card.radioButton('size')?.value() !== 'known';
const motorized = () => PRODUCTS[productOf()]!.motor && CONTROLS[pick(CONTROLS, card.radioButton('control')?.value(), 'crank')]!.motor;
card.addRow(row => {
row.addDropdown('side', {
label: 'Where on the house',
options: toOptions(SIDES),
defaultValue: 'front',
isRequired: true
}, '1fr');
row.addDropdown('kind', {
label: 'What kind of opening?',
options: toOptions(OPENINGS),
defaultValue: 'window',
isRequired: true
}, '1fr');
row.addInteger('quantity', {
label: 'How many just like it?',
min: 1,
max: 40,
defaultValue: 1
}, '1fr');
});
card.addRow(row => {
row.addCheckboxList('needs', {
label: 'What should this opening do for you? (pick up to two)',
options: Object.keys(NEEDS).map(id => ({ id, name: NEEDS[id]! })),
max: 2,
orientation: 'horizontal'
});
});
card.addRow(row => {
row.addTextPanel('recommend', {
computedValue: () => {
const r = recommend(kindOf(), needsOf());
return (r.product ? '💡 We recommend: ' + PRODUCTS[r.product]!.name + '. ' : '💡 ') + r.reason;
},
customStyles: { 'font-size': '0.9rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' }
});
});
card.addRow(row => {
row.addDropdown('product', {
label: 'What goes on this opening?',
options: () => productsFor(kindOf()).map(id => ({ id, name: PRODUCTS[id]!.name + ' - ' + perSqft(PRODUCTS[id]!.rate) })),
defaultValue: 'solar-screen',
isRequired: true
}, '2fr');
row.addRadioButton('floor', {
label: 'Floor',
options: [{ id: 'first', name: 'Ground' }, { id: 'second', name: 'Upstairs' }],
defaultValue: 'first',
orientation: 'horizontal',
isVisible: () => twoStory()
}, '1fr');
});
card.addRow(row => {
row.addRadioButton('size', {
label: 'Size',
options: [{ id: 'measure', name: 'Measure for me' }, { id: 'known', name: 'I know the size' }],
defaultValue: 'measure',
orientation: 'horizontal',
tooltip: 'Without a size we assume a typical opening of that kind; the measure sets the real one'
}, '1fr');
row.addInteger('width', {
label: 'Width (inches)',
min: 12,
max: 240,
placeholder: 'e.g. 36',
isVisible: () => !measured()
}, '1fr');
row.addInteger('height', {
label: 'Height (inches)',
min: 12,
max: 144,
placeholder: 'e.g. 60',
isVisible: () => !measured()
}, '1fr');
});
card.addRow(row => {
row.addRadioButton('fabric', {
label: 'Fabric openness',
options: () => Object.keys(FABRICS)
.filter(id => !FABRICS[id]!.rollerOnly || productOf() === 'roller-shade')
.map(id => ({ id, name: FABRICS[id]!.name + (FABRICS[id]!.extra > 0 ? ' (+' + perSqft(FABRICS[id]!.extra) + ')' : '') })),
defaultValue: 'open90',
orientation: 'vertical',
isVisible: () => familyOf() === 'fabric'
}, '1fr');
row.addRadioButton('mesh', {
label: 'Mesh',
options: Object.keys(MESHES).map(id => ({ id, name: MESHES[id]!.name + (MESHES[id]!.extra > 0 ? ' (+' + perSqft(MESHES[id]!.extra) + ')' : '') })),
defaultValue: 'standard',
orientation: 'vertical',
isVisible: () => productOf() === 'rescreen'
}, '1fr');
row.addRadioButton('panelMaterial', {
label: 'Panel material',
options: Object.keys(PANEL_MATERIALS).map(id => ({ id, name: PANEL_MATERIALS[id]!.name + (PANEL_MATERIALS[id]!.extra > 0 ? ' (+' + perSqft(PANEL_MATERIALS[id]!.extra) + ', lets daylight in)' : '') })),
defaultValue: 'aluminum',
orientation: 'vertical',
isVisible: () => productOf() === 'panels'
}, '1fr');
row.addDropdown('color', {
label: 'Color',
options: () => Object.keys(COLORS[familyOf()]).map(id => {
const c = COLORS[familyOf()][id]!;
return { id, name: c.name + (c.custom ? ' (custom powder coat, +' + perSqft(prices.customColorPerSqft) + ')' : '') };
}),
defaultValue: 'white',
isVisible: () => familyOf() !== 'mesh'
}, '1fr');
});
card.addRow(row => {
row.addCheckbox('zip', {
label: 'Zip side tracks: holds in wind, bug-tight, no gap (+' + perSqft(prices.zipPerSqft) + ')',
defaultValue: false,
isVisible: () => PRODUCTS[productOf()]!.zip
}, '1fr');
row.addRadioButton('control', {
label: 'Control',
options: Object.keys(CONTROLS).map(id => {
const c = CONTROLS[id]!;
return { id, name: c.name + (c.motor ? ' (+' + money(prices.motorEach + (c.wired ? prices.switchEach : 0)) + ')' : '') };
}),
defaultValue: 'crank',
orientation: 'vertical',
isVisible: () => PRODUCTS[productOf()]!.motor
}, '1fr');
row.addCheckbox('windSensor', {
label: 'Wind sensor: raises the shade on its own in gusts (+' + money(prices.windSensorEach) + ')',
defaultValue: false,
isVisible: () => productOf() === 'roller-shade' && motorized()
}, '1fr');
});
}
// One line of the list, every field read through a default, so the range never waits for input.
const line = (i: number): Line => {
const card = cards[i - 1]!;
const kind = pick(OPENINGS, card.dropdown('kind')?.value(), 'window');
const chosen = pick(PRODUCTS, card.dropdown('product')?.value(), '');
const product = chosen && productsFor(kind).indexOf(chosen) >= 0 ? chosen : productsFor(kind)[0]!;
const def = PRODUCTS[product]!;
const typical = OPENINGS[kind]!;
const known = card.radioButton('size')?.value() === 'known';
const width = card.integer('width')?.value() || 0;
const height = card.integer('height')?.value() || 0;
const fabric = pick(FABRICS, card.radioButton('fabric')?.value(), 'open90');
const control = def.motor ? pick(CONTROLS, card.radioButton('control')?.value(), 'crank') : '';
const quantity = card.integer('quantity')?.value() || 1;
return {
number: i,
side: pick(SIDES, card.dropdown('side')?.value(), 'front'),
kind,
sized: known && width > 0 && height > 0,
width: known && width > 0 ? width : typical.width,
height: known && height > 0 ? height : typical.height,
quantity: Math.max(1, quantity),
floor: twoStory() && card.radioButton('floor')?.value() === 'second' ? 'second' : 'first',
needs: (card.checkboxList('needs')?.value() || []).filter(id => id in NEEDS),
product,
fabric: def.family === 'fabric' ? (FABRICS[fabric]!.rollerOnly && product !== 'roller-shade' ? 'open90' : fabric) : '',
mesh: product === 'rescreen' ? pick(MESHES, card.radioButton('mesh')?.value(), 'standard') : '',
panelMaterial: product === 'panels' ? pick(PANEL_MATERIALS, card.radioButton('panelMaterial')?.value(), 'aluminum') : '',
color: def.family === 'mesh' ? '' : pick(COLORS[def.family], card.dropdown('color')?.value(), 'white'),
zip: def.zip && card.checkbox('zip')?.value() === true,
control,
windSensor: product === 'roller-shade' && control !== '' && CONTROLS[control]!.motor && card.checkbox('windSensor')?.value() === true
};
};
const lines = (): Line[] => {
const out: Line[] = [];
for (let i = 1; i <= kindCount(); i++) out.push(line(i));
return out;
};
const sizeText = (l: Line): string => l.sized ? l.width + ' x ' + l.height + ' in' : 'to be measured';
const describeLine = (l: Line): string => SIDES[l.side]!.name + ' - ' + OPENINGS[l.kind]!.name + ' - ' + PRODUCTS[l.product]!.name
+ ' - ' + sizeText(l) + (l.quantity > 1 ? ' - ' + l.quantity + ' pcs' : '');
const pieceCount = form.computedValue(() => lines().reduce((sum, l) => sum + l.quantity, 0));
const hub = form.computedValue(() => listSection.checkbox('hub')?.value() === true);
const anyMotor = form.computedValue(() => lines().some(l => l.control !== '' && CONTROLS[l.control]!.motor));
const anyRemote = form.computedValue(() => lines().some(l => l.control === 'remote'));
const anyRated = form.computedValue(() => lines().some(l => PRODUCTS[l.product]!.rated));
const ratedCount = form.computedValue(() => lines().filter(l => PRODUCTS[l.product]!.rated).reduce((sum, l) => sum + l.quantity, 0));
const anyFront = form.computedValue(() => lines().some(l => SIDES[l.side]!.front));
const upstairsCount = form.computedValue(() => lines().filter(l => l.floor === 'second').reduce((sum, l) => sum + l.quantity, 0));
const discountPct = form.computedValue(() => pieceCount() >= 12 ? prices.disc12Pct : pieceCount() >= 6 ? prices.disc6Pct : 0);
openingsPage.addRow(row => {
row.addTextPanel('discountNote', {
computedValue: () => {
if (pieceCount() >= 12) return '🏷️ You have ' + prices.disc12Pct + '% off: from twelve openings on one order. It is already in the estimate.';
if (pieceCount() >= 6) return '🏷️ You have ' + prices.disc6Pct + '% off from six openings on one order, and from twelve it is ' + prices.disc12Pct + '%.';
if (pieceCount() >= 3) return 'ℹ️ ' + plural(6 - pieceCount(), 'more opening', 'more openings') + ' and you get ' + prices.disc6Pct + '% off the whole order. One trip costs less than two.';
return '';
},
isVisible: () => pieceCount() >= 3,
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
openingsPage.addRow(row => {
row.addTextPanel('sunNote', {
computedValue: () => '🌡️ West and south glass heats the house all afternoon. An interior blind stops the light after it is already inside; a solar screen or an exterior shade stops it before the glass, which is why the AC notices.',
isVisible: () => hotSide(),
customStyles: { 'font-size': '0.9rem', 'color': '#9a3412', 'background': '#ffedd5', 'padding': '10px', 'border-radius': '6px' }
});
});
openingsPage.addRow(row => {
row.addTextPanel('insuranceNote', {
computedValue: () => '🛡️ Rated protection on every opening can lower the windstorm part of your homeowners premium. Ask your agent; we hand you the product approval numbers.'
+ (ratedCount() > 0 && ratedCount() < pieceCount() ? ' Right now ' + plural(ratedCount(), 'opening is', 'openings are') + ' rated out of ' + pieceCount() + '.' : ''),
isVisible: () => anyRated(),
customStyles: { 'font-size': '0.9rem', 'color': '#1e3a8a', 'background': '#dbeafe', 'padding': '10px', 'border-radius': '6px' }
});
});
openingsPage.addRow(row => {
row.addTextPanel('hoaNote', {
computedValue: () => '📋 HOA approval: front-facing openings usually need the association sign-off on color and style. We put the samples, drawings and product approvals together for your submittal.',
isVisible: () => hoa() === 'yes' && anyFront(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// ---------------------------------------------------------------- Step 3: measure and installation
const servicePage = steps.addPage('service');
servicePage.addRow(row => {
row.addTextPanel('serviceTitle', {
label: 'Step 3 of 4: Installation',
computedValue: () => 'Every screen and shutter is built to the opening. If you do not have sizes, we take them at the free measure within '
+ prices.measureDays + ' business days; from the measure to the installed job it usually takes ' + prices.leadWeeks + ' weeks.'
});
});
const serviceSection = servicePage.addSubform('serviceDetails', { title: '🔧 Who Installs' });
serviceSection.addRow(row => {
row.addRadioButton('install', {
label: 'Who installs?',
options: [
{ id: 'crew', name: 'Your crew: measure, build and install everything (' + money(prices.installEach) + '/opening)' },
{ id: 'self', name: 'I will hang it myself: solar screens and storm panels ship ready to hang; shades and shutters are still installed by us' }
],
defaultValue: 'crew',
orientation: 'vertical',
isRequired: true
});
});
const crewInstalls = form.computedValue(() => serviceSection.radioButton('install')?.value() !== 'self');
// Fitted by the crew: always when they install, and otherwise for what does not ship as a kit.
const crewFits = (l: Line): boolean => crewInstalls() || !PRODUCTS[l.product]!.diy;
const crewLines = form.computedValue(() => lines().filter(l => crewFits(l)));
const crewOnSite = form.computedValue(() => crewLines().length > 0);
serviceSection.addRow(row => {
row.addTextPanel('crewNote', {
computedValue: () => {
const names: string[] = [];
lines().filter(l => !PRODUCTS[l.product]!.diy).forEach(l => {
const name = PRODUCTS[l.product]!.name;
if (names.indexOf(name) < 0) names.push(name);
});
return '👷 ' + names.join(', ') + ': built to the opening and anchored by us, so their installation stays in the estimate. Your solar screens and storm panels ship as a kit with instructions.';
},
isVisible: () => !crewInstalls() && crewOnSite(),
customStyles: { 'font-size': '0.9rem', 'color': '#9a3412', 'background': '#ffedd5', 'padding': '10px', 'border-radius': '6px' }
});
});
serviceSection.addRow(row => {
row.addInteger('removal', {
label: 'Old screens or shutters to take down (' + money(prices.removalEach) + ' each)',
min: 0,
max: 60,
defaultValue: 0,
isVisible: () => replacing() && crewOnSite()
}, '1fr');
row.addInteger('miles', {
label: 'Miles from our shop (no trip charge within ' + prices.freeMiles + ' miles)',
min: 0,
max: 300,
defaultValue: 10,
isVisible: () => crewOnSite()
}, '1fr');
});
serviceSection.addRow(row => {
row.addRadioButton('timing', {
label: 'When should we install?',
options: () => [
{ id: 'standard', name: 'Standard: about ' + plural(prices.leadWeeks, 'week', 'weeks') + ' from the measure' },
...(prices.rushDays > 0 ? [{ id: 'rush', name: 'Rush: in-stock fabric in ' + plural(prices.rushDays, 'day', 'days') + ' (+' + prices.rushPct + '%)' }] : []),
{ id: 'no-hurry', name: 'No hurry: I am comparing quotes, date to be set' }
],
defaultValue: 'standard',
orientation: 'vertical'
});
});
const removal = form.computedValue(() => replacing() && crewOnSite() ? (serviceSection.integer('removal')?.value() || 0) : 0);
const miles = form.computedValue(() => crewOnSite() ? (serviceSection.integer('miles')?.value() || 0) : 0);
const timing = form.computedValue(() => pick(TIMINGS, serviceSection.radioButton('timing')?.value(), 'standard'));
const rush = form.computedValue(() => timing() === 'rush' && prices.rushDays > 0);
const permitNeeded = form.computedValue(() => crewOnSite() && lines().some(l => PRODUCTS[l.product]!.rated && crewFits(l)));
serviceSection.addRow(row => {
row.addTextPanel('permitNote', {
computedValue: () => '📋 Hurricane protection needs a building permit. Rated shutters go on with a permit and the product approval paperwork; we pull it and schedule the final inspection. The permit and engineering line in the estimate is ' + money(prices.permitFee) + ' per job.',
isVisible: () => permitNeeded(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
serviceSection.addRow(row => {
row.addTextPanel('powerNote', {
computedValue: () => '🔌 Power for the motors: at the measure we check where a hardwired motor can pick up power; where it cannot, a battery or solar-charged motor does the job.',
isVisible: () => anyMotor(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// ---------------------------------------------------------------- Pricing
const areaOf = (l: Line): number => {
const area = (l.width / 12) * (l.height / 12);
return l.product === 'rescreen' ? area : Math.max(prices.minSqft, area);
};
// One opening, everything it carries.
const lineEach = (l: Line): number => {
const def = PRODUCTS[l.product]!;
let rate = def.rate;
if (def.family === 'fabric') {
rate += FABRICS[l.fabric]?.extra ?? 0;
if (l.zip) rate += prices.zipPerSqft;
}
if (l.product === 'panels') rate += PANEL_MATERIALS[l.panelMaterial]?.extra ?? 0;
if (l.product === 'rescreen') rate += MESHES[l.mesh]?.extra ?? 0;
if (l.color && COLORS[def.family][l.color]?.custom) rate += prices.customColorPerSqft;
let each = areaOf(l) * rate;
if (l.product === 'retractable') each = Math.max(prices.retractMin, each);
if (l.kind === 'arch') each = each * (1 + prices.archPct / 100);
const control = l.control ? CONTROLS[l.control] : undefined;
if (def.motor && control?.motor) each += prices.motorEach + (control.wired ? prices.switchEach : 0);
if (l.windSensor) each += prices.windSensorEach;
return each;
};
const lineTotal = (l: Line): number => lineEach(l) * l.quantity;
const lineInstall = (l: Line): number => crewFits(l) ? (prices.installEach + (l.floor === 'second' ? prices.secondFloorEach : 0)) * l.quantity : 0;
const productsCost = form.computedValue(() => lines().reduce((sum, l) => sum + lineTotal(l), 0));
const controlCost = form.computedValue(() => (anyRemote() ? prices.remoteEach : 0) + (hub() ? prices.hubEach : 0));
const installCost = form.computedValue(() => lines().reduce((sum, l) => sum + lineInstall(l), 0) + removal() * prices.removalEach);
const permitCost = form.computedValue(() => permitNeeded() ? prices.permitFee : 0);
const tripCost = form.computedValue(() => Math.max(0, miles() - prices.freeMiles) * prices.tripPerMile);
const beforeDiscount = form.computedValue(() => productsCost() + controlCost() + installCost() + permitCost() + tripCost() + prices.measureFee);
const discount = form.computedValue(() => beforeDiscount() * discountPct() / 100);
const rushCost = form.computedValue(() => rush() ? (beforeDiscount() - discount()) * prices.rushPct / 100 : 0);
const promo = form.computedValue(() => (beforeDiscount() - discount() + rushCost()) * prices.promoPct / 100);
const afterDiscounts = form.computedValue(() => beforeDiscount() - discount() + rushCost() - promo());
// The minimum job applies last, after every discount.
const total = form.computedValue(() => Math.max(prices.minJob, afterDiscounts()));
const minimumApplied = form.computedValue(() => afterDiscounts() < prices.minJob);
const roundTo10 = (amount: number) => Math.round(amount / 10) * 10;
const totalLow = form.computedValue(() => roundTo10(total() * (1 - prices.rangePct / 100)));
const totalHigh = form.computedValue(() => roundTo10(total() * (1 + prices.rangePct / 100)));
const estimateText = (): string => money(totalLow()) + ' - ' + money(totalHigh());
const lineOptions = (l: Line): string[] => {
const def = PRODUCTS[l.product]!;
const out: string[] = [];
if (def.family === 'fabric') out.push((FABRICS[l.fabric]?.name ?? '').split(' (')[0] + ' fabric');
if (l.product === 'rescreen') out.push((MESHES[l.mesh]?.name ?? '').toLowerCase() + ' mesh');
if (l.product === 'panels') out.push((PANEL_MATERIALS[l.panelMaterial]?.name ?? '').toLowerCase());
if (l.color) out.push((COLORS[def.family][l.color]?.name ?? '').toLowerCase());
if (l.zip) out.push('zip side tracks');
if (l.control) out.push(CONTROLS[l.control]!.name.toLowerCase());
if (l.windSensor) out.push('wind sensor');
return out;
};
const describeService = (): string => [
crewInstalls() ? 'installed by the crew' : (crewOnSite() ? 'kits self-installed, the rest by the crew' : 'self-installed kits'),
removal() > 0 ? 'removal of ' + plural(removal(), 'old piece', 'old pieces') : '',
crewOnSite() ? miles() + ' miles from the shop' : '',
permitNeeded() ? 'permit and engineering' : '',
'timing: ' + TIMINGS[timing()]!.toLowerCase(),
anyRemote() ? 'remote' : '',
hub() ? 'smart home hub' : ''
].filter(part => part !== '').join(', ');
// ---------------------------------------------------------------- Step 4: estimate and contact
const quotePage = steps.addPage('quote');
quotePage.addRow(row => {
row.addTextPanel('quoteTitle', {
label: 'Step 4 of 4: Your estimate',
computedValue: () => 'The range comes from your answers and the products you picked. It is an estimate, not a binding quote: we come out within '
+ prices.measureDays + ' business days, check every opening and tell you where it can be done for less.'
});
});
quotePage.addRow(row => {
row.addTextPanel('promoNote', {
computedValue: () => '🎉 ' + prices.promoText + ': ' + prices.promoPct + '% off is already in the estimate below.',
isVisible: () => prices.promoPct > 0,
customStyles: { 'font-size': '0.95rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px', 'font-weight': '500' }
});
});
const summarySection = quotePage.addSubform('summary', { title: '💰 What Makes Up the Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addPriceDisplay('productsCost', {
label: () => 'Screens, shades and shutters (' + plural(pieceCount(), 'opening', 'openings') + ')',
computedValue: () => Math.round(productsCost()),
decimals: 0,
variant: 'default'
}, '1fr');
row.addPriceDisplay('installCost', {
label: () => 'Installation' + (upstairsCount() > 0 ? ' (' + upstairsCount() + ' upstairs)' : '') + (removal() > 0 ? ' and removal' : ''),
computedValue: () => Math.round(installCost()),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('controlCost', {
label: () => 'Control: ' + [anyRemote() ? 'remote' : '', hub() ? 'smart home hub' : ''].filter(part => part !== '').join(' and '),
computedValue: () => controlCost(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => controlCost() > 0
}, '1fr');
row.addPriceDisplay('permitCost', {
label: 'Permit and engineering (hurricane protection)',
computedValue: () => permitCost(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => permitCost() > 0
}, '1fr');
row.addPriceDisplay('tripCost', {
label: () => 'Trip charge (' + miles() + ' miles)',
computedValue: () => Math.round(tripCost()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => tripCost() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('discount', {
label: () => 'Discount for ' + pieceCount() + ' openings (-' + discountPct() + '%)',
computedValue: () => -Math.round(discount()),
decimals: 0,
variant: 'success',
isVisible: () => discount() > 0
}, '1fr');
row.addPriceDisplay('rushCost', {
label: 'Rush (+' + prices.rushPct + '%)',
computedValue: () => Math.round(rushCost()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => rushCost() > 0
}, '1fr');
row.addPriceDisplay('promo', {
label: prices.promoText + ' (-' + prices.promoPct + '%)',
computedValue: () => -Math.round(promo()),
decimals: 0,
variant: 'success',
isVisible: () => promo() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('minimumNote', {
computedValue: () => 'ℹ️ The order is still under our minimum: we take jobs from ' + money(prices.minJob)
+ '. If other openings are still bare, put them on the same request: the price per piece drops and one trip costs less than two.',
isVisible: () => minimumApplied(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('estimateLow', {
label: 'Estimate for your home, from',
computedValue: () => totalLow(),
decimals: 0,
variant: 'large',
suffix: () => crewOnSite() ? 'installed' : 'as kits'
}, '1fr');
row.addPriceDisplay('estimateHigh', {
label: 'to',
computedValue: () => totalHigh(),
decimals: 0,
variant: 'large',
suffix: 'before sales tax'
}, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('financingNote', {
computedValue: () => '💳 ' + prices.financingNote,
isVisible: () => prices.financingNote.length > 0,
customStyles: { 'font-size': '0.9rem', 'color': '#475569' }
});
});
const contactSection = quotePage.addSubform('contact', { title: '📋 Book Your Free Measure', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('name', { label: 'Name', placeholder: 'John Smith', isRequired: true }, '1fr');
row.addTextbox('address', { label: 'Address of the home', placeholder: 'Street, city, ZIP code', isRequired: true }, '2fr');
});
contactSection.addRow(row => {
row.addTextbox('phone', { label: 'Phone', placeholder: '(555) 123-4567', isRequired: true }, '1fr');
row.addEmail('email', { label: 'Email (for the PDF with your list of openings)', placeholder: 'john@example.com', isRequired: true }, '1fr');
row.addDropdown('callTime', {
label: 'When does a measure suit you?',
options: Object.keys(CALL_TIMES).map(id => ({ id, name: CALL_TIMES[id]! })),
defaultValue: 'any'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else we should know?',
placeholder: 'E.g. gate code, a dog in the yard, HOA colors, the lanai has a kitchen, you want it done before June 1',
rows: 3,
maxLength: 1500
});
});
// ---------------------------------------------------------------- Sticky summary on every step
const finalSection = form.addSubform('final', { title: '🧾 Estimate', isCollapsible: false, sticky: 'bottom' });
finalSection.addRow(row => {
row.addPriceDisplay('totalLow', {
label: () => 'About, from (' + plural(pieceCount(), 'opening', 'openings') + ')',
computedValue: () => totalLow(),
decimals: 0,
variant: 'large'
}, '1fr');
row.addPriceDisplay('totalHigh', {
label: 'to',
computedValue: () => totalHigh(),
decimals: 0,
variant: 'large',
suffix: 'before sales tax'
}, '1fr');
});
finalSection.addRow(row => {
row.addTextPanel('disclaimer', {
computedValue: () => 'A firm quote follows the free measure.' + (discountPct() > 0 ? ' Includes ' + discountPct() + '% off for ' + pieceCount() + ' openings.' : ''),
customStyles: { 'font-size': '0.85rem', 'color': '#94a3b8', 'font-style': 'italic' }
});
});
form.configureSubmitButton({ label: 'Book My Free Measure' });
const whenWeCome = (): string => {
const when = contactSection.dropdown('callTime')?.value();
return when === 'morning' ? 'before noon' : when === 'afternoon' ? 'after noon' : when === 'saturday' ? 'on a Saturday' : 'at a time that suits you';
};
form.configureCompletionScreen({
type: 'text',
title: 'We have your list of openings',
message: () => 'We will call to set the day of the measure ' + whenWeCome() + '. Within ' + prices.measureDays
+ ' business days our measurer takes the sizes, and you get a firm quote in writing. Your estimate: ' + estimateText() + '.'
});
// ---------------------------------------------------------------- PDF: the list of openings the customer keeps
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'exterior-shading-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the PDF with your openings',
header: {
title: 'Exterior shading and hurricane protection estimate',
subtitle: plural(pieceCount(), 'opening', 'openings') + ' on ' + plural(kindCount(), 'line', 'lines')
+ (anyRated() ? ' - ' + plural(ratedCount(), 'opening', 'openings') + ' code rated' : '')
},
footer: { text: 'Estimate from the sizes given by the customer. A firm quote follows the free measure.', showPageNumbers: true }
});
pdf.addSection('Customer', section => {
section.addRow(row => row
.addField('Name', contactSection.textbox('name')?.value() || '')
.addField('Phone', contactSection.textbox('phone')?.value() || '')
.addField('Email', contactSection.email('email')?.value() || ''));
section.addRow(row => row
.addField('Address', contactSection.textbox('address')?.value() || '')
.addField('Measure time', CALL_TIMES[pick(CALL_TIMES, contactSection.dropdown('callTime')?.value(), 'any')]!));
});
pdf.addSection('Home', section => {
section.addRow(row => row
.addField('Property', HOMES[homeType()]!)
.addField('Starting from', STAGES[stage()]!)
.addField('Worst sun', sunSide())
.addField('HOA', hoa())
.addField('Stories', twoStory() ? 'two or more' : 'one'));
});
pdf.addSection('Openings', section => {
section.addTable(
['No.', 'Where', 'Opening', 'Product', 'Qty', 'Amount'],
lines().map(l => [
String(l.number),
SIDES[l.side]!.name + (twoStory() && l.floor === 'second' ? ', upstairs' : ''),
sizeText(l) + ', ' + OPENINGS[l.kind]!.name.toLowerCase()
+ (l.needs.length > 0 ? ' (wants: ' + l.needs.map(id => NEEDS[id]!.toLowerCase()).join(', ') + ')' : ''),
PRODUCTS[l.product]!.name + ', ' + lineOptions(l).join(', '),
String(l.quantity),
money(lineTotal(l))
])
);
});
pdf.addSection('Service and discounts', section => {
const rows: string[][] = [];
if (controlCost() > 0) rows.push(['Control: ' + [anyRemote() ? 'remote' : '', hub() ? 'smart home hub' : ''].filter(part => part !== '').join(' and '), money(controlCost())]);
if (installCost() > 0) rows.push(['Installation of ' + plural(crewLines().reduce((sum, l) => sum + l.quantity, 0), 'opening', 'openings') + (removal() > 0 ? ' with removal of ' + plural(removal(), 'old piece', 'old pieces') : '') + (upstairsCount() > 0 ? ', ' + upstairsCount() + ' upstairs' : ''), money(installCost())]);
if (permitCost() > 0) rows.push(['Permit and engineering for hurricane protection', money(permitCost())]);
if (tripCost() > 0) rows.push(['Trip charge, ' + miles() + ' miles', money(tripCost())]);
if (prices.measureFee > 0) rows.push(['Measure', money(prices.measureFee)]);
if (discount() > 0) rows.push(['Discount for ' + pieceCount() + ' openings (-' + discountPct() + '%)', '-' + money(discount())]);
if (rushCost() > 0) rows.push(['Rush (+' + prices.rushPct + '%)', money(rushCost())]);
if (promo() > 0) rows.push([prices.promoText + ' (-' + prices.promoPct + '%)', '-' + money(promo())]);
if (minimumApplied()) rows.push(['Raised to our minimum job', money(total() - afterDiscounts())]);
section.addTable(['Item', 'Amount'], rows);
section.addSpacer(8);
section.addRow(row => row
.addField('Estimate', estimateText())
.addField('Range', (minimumApplied() ? 'raised to the minimum job of ' + money(prices.minJob) + ', ' : '+/-' + prices.rangePct + '%, ') + (crewOnSite() ? 'installed, before sales tax' : 'kits, before sales tax')));
section.addRow(row => row.addField('Service', describeService()));
});
pdf.addSection('What happens next', section => {
section.addText('1. We call to set the day of the free measure; our measurer comes out within ' + prices.measureDays + ' business days.');
section.addText('2. At the measure we check every opening, show fabric and color samples' + (anyRated() ? ' and go over the permit and the product approvals for your shutters' : '') + '.');
section.addText('3. After the measure you get a firm quote in writing; from your go-ahead to the installed job it usually takes ' + prices.leadWeeks + ' weeks.');
if (anyRated()) section.addText('4. Rated protection on every opening may qualify for a wind mitigation discount on your homeowners policy; we give you the product approval numbers for the inspection.');
});
const notes = contactSection.textarea('notes')?.value();
if (notes) pdf.addSection('Notes', section => section.addText(notes));
pdf.addSection('Terms', section => {
section.addText('This is an estimate, not a binding offer. Prices are before sales tax and include installation where our crew installs. '
+ 'The final price follows from the sizes taken at the measure and the fabrics and colors chosen. Hurricane protection is installed under a building permit; the permit and engineering line covers it.');
});
});
}