export function rollingShutterCalculator(form: FormTs) {
// Shutter price per square foot by slat profile (housing, rails and bottom bar included; no motor or installation)
const slatRates: Record<string, number> = {
'foam40': 32, // 40 mm foam-filled aluminum slat
'foam55': 38, // 55 mm foam-filled slat, wind-rated profile
'extruded': 58, // solid extruded security slat, impact-rated
'perforated': 64, // perforated storefront slat
'grille': 46 // open rod-and-link grille
};
const slatNames: Record<string, string> = {
'foam40': '40 mm foam-filled',
'foam55': '55 mm foam-filled',
'extruded': 'extruded security',
'perforated': 'perforated',
'grille': 'open grille'
};
// A small shutter takes almost as long to build as a medium one, so it is billed from this area
const minSqft = 12;
// Percent surcharges on the shutter
const housingPcts: Record<string, number> = { 'recessed': 18, 'surface': 0, 'inset': 6 };
const ratingPcts: Record<string, number> = { 'none': 0, 'wind': 25, 'hvhz': 45 };
const colorPcts: Record<string, number> = { 'white': 0, 'beige': 0, 'bronze': 0, 'woodgrain': 15 };
const permitFee = 350; // once per job for a storm-rated shutter
// Operation, per shutter
const drivePrices: Record<string, number> = { 'crank': 0, 'switch': 420, 'remote': 520, 'solar': 780 };
const hookupPerShutter = 185; // electrician runs the line when there is no power at the opening
const smartHubPrice = 350; // once per order
const batteryPerShutter = 240;
const locksPerShutter = 45;
// Installation, per shutter
const installPerShutter = 165; // on stucco over block
const wallSurcharges: Record<string, number> = { 'stucco': 0, 'brick': 40, 'siding': 75, 'not_sure': 0 };
const secondFloorPerShutter = 55;
const removalPerShutter = 60;
const minJob = 900;
// Promotion, range and lead times
const promoPct = 0;
const promoText = 'Pre-season storm sale';
const financingNote = 'Financing available: 0% APR for 12 months on approved credit.';
const rangePct = 12;
const measureDays = 3;
const leadWeeks = 5;
// Typical openings stand in for one the visitor has not measured (width × height in inches)
const openingKinds: Record<string, { name: string; width: number; height: number; storefront: boolean }> = {
'window': { name: 'Window', width: 36, height: 60, storefront: false },
'slider': { name: 'Sliding glass door', width: 96, height: 80, storefront: false },
'lanai': { name: 'Lanai or patio opening', width: 144, height: 96, storefront: false },
'entry': { name: 'Entry door', width: 36, height: 80, storefront: false },
'store_window': { name: 'Storefront window', width: 96, height: 96, storefront: true },
'store_door': { name: 'Entrance door', width: 72, height: 84, storefront: true },
'bay': { name: 'Service or bay opening', width: 120, height: 120, storefront: true }
};
const maxOpenings = 4;
const housingNames: Record<string, string> = { 'recessed': 'built-in (concealed)', 'surface': 'surface-mounted', 'inset': 'in the opening' };
const ratingNames: Record<string, string> = { 'none': 'not storm-rated', 'wind': 'wind-rated', 'hvhz': 'HVHZ impact-rated' };
const driveNames: Record<string, string> = { 'crank': 'manual crank', 'switch': 'motor with wall switch', 'remote': 'motor with remote', 'solar': 'solar motor' };
const wallNames: Record<string, string> = { 'stucco': 'stucco over block', 'brick': 'brick', 'siding': 'siding or wood frame', 'not_sure': 'not sure' };
const money = (amount: number) => '$' + Math.round(amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Rolling Shutter Quote',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addSpacer({ height: 10 });
const steps = form.addPages('steps', { heightMode: 'current-page' });
// ---------- Step 1: the project ----------
const projectStep = steps.addPage('project');
const projectSection = projectStep.addSubform('project', { title: '🏠 Project', isCollapsible: false });
projectSection.addRow(row => {
row.addRadioButton('building', {
label: 'Where do the shutters go?',
options: [
{ id: 'home', name: 'Existing home' },
{ id: 'new_build', name: 'New construction or major remodel' },
{ id: 'condo', name: 'Condo or townhome (association rules apply)' },
{ id: 'storefront', name: 'Storefront or business (security shutters over the glass)' }
],
defaultValue: 'home',
orientation: 'vertical',
isRequired: true
});
});
const building = () => projectSection.radioButton('building')?.value() || 'home';
const isStorefront = () => building() === 'storefront';
projectSection.addRow(row => {
row.addDropdown('refinish', {
label: 'Re-stuccoing or re-siding soon?',
options: [
{ id: 'yes', name: 'Yes' },
{ id: 'no', name: 'No' },
{ id: 'not_sure', name: 'Not sure yet' }
],
defaultValue: 'not_sure',
isVisible: () => building() === 'home',
tooltip: 'Under new stucco or siding the housing can disappear into the wall'
}, '1fr');
row.addDropdown('stories', {
label: 'How many stories?',
options: [
{ id: 'one', name: 'One story' },
{ id: 'two', name: 'Two or more (+$55 per upstairs shutter)' }
],
defaultValue: 'one',
isVisible: () => !isStorefront()
}, '1fr');
});
const twoStory = () => !isStorefront() && projectSection.dropdown('stories')?.value() === 'two';
projectSection.addRow(row => {
row.addCheckboxList('purposes', {
label: 'What are the shutters for? (we recommend the slat, the storm rating and the extras from this)',
options: [
{ id: 'storm', name: 'Storm protection - hurricane season, insurance discount' },
{ id: 'security', name: 'Security - break-ins, months away' },
{ id: 'heat', name: 'Sun and heat - cooling bills, west-facing glass' },
{ id: 'privacy', name: 'Privacy - blackout, rooms facing the street' }
],
orientation: 'vertical'
}, '1fr');
row.addDropdown('timing', {
label: 'When would you like them installed?',
options: [
{ id: 'asap', name: 'As soon as possible' },
{ id: 'season', name: 'Before hurricane season' },
{ id: 'quarter', name: 'Within 3 months' },
{ id: 'comparing', name: 'Just comparing quotes' }
],
defaultValue: 'quarter',
isRequired: true
}, '1fr');
});
const purposes = () => projectSection.checkboxList('purposes')?.value() || [];
const wantsSecurity = () => purposes().includes('security');
const wantsStorm = () => purposes().includes('storm');
// ---------- Step 2: the openings ----------
const openingsStep = steps.addPage('openings');
const openingsSection = openingsStep.addSubform('openings', { title: '🪟 Openings', isCollapsible: false });
openingsSection.addRow(row => {
row.addInteger('kinds', {
label: 'How many different opening sizes?',
min: 1,
max: maxOpenings,
defaultValue: 1,
isRequired: true,
tooltip: 'Openings of the same kind and size are one line: set the quantity on it'
}, '1fr');
row.addEmpty('1fr');
});
const kindCount = () => Math.min(Math.max(openingsSection.integer('kinds')?.value() || 1, 1), maxOpenings);
const residentialKinds: ControlOption[] = Object.keys(openingKinds).filter(id => !openingKinds[id]?.storefront).map(id => ({ id, name: openingKinds[id]?.name || id }));
const storefrontKinds: ControlOption[] = Object.keys(openingKinds).filter(id => openingKinds[id]?.storefront).map(id => ({ id, name: openingKinds[id]?.name || id }));
type Opening = { number: number; kind: string; sized: boolean; width: number; height: number; quantity: number; upstairs: boolean };
const cards: SubformTs[] = [];
for (let i = 1; i <= maxOpenings; i++) {
const card = openingsSection.addSubform('opening' + i, {
title: 'Opening ' + i,
isCollapsible: false,
isVisible: () => kindCount() >= i
});
const measure = () => card.radioButton('size')?.value() !== 'known';
card.addRow(row => {
row.addDropdown('kind', {
label: 'What is the shutter for?',
options: () => isStorefront() ? storefrontKinds : residentialKinds,
defaultValue: 'window'
}, '1fr');
row.addInteger('quantity', {
label: 'Quantity',
min: 1,
max: 40,
defaultValue: 1,
isRequired: true
}, '1fr');
});
card.addRow(row => {
row.addRadioButton('size', {
label: 'Size',
options: [
{ id: 'measure', name: 'Measure for me (typical size assumed for the estimate)' },
{ id: 'known', name: 'I know the size (inches, outside of the frame)' }
],
defaultValue: 'measure',
orientation: 'vertical'
});
});
card.addRow(row => {
row.addInteger('width', {
label: 'Width (in)',
min: 12,
max: 240,
placeholder: 'e.g. 36',
isRequired: true,
isVisible: () => !measure()
}, '1fr');
row.addInteger('height', {
label: 'Height (in)',
min: 12,
max: 144,
placeholder: 'e.g. 60',
isRequired: true,
isVisible: () => !measure()
}, '1fr');
row.addDropdown('floor', {
label: 'Floor',
options: [
{ id: 'first', name: 'Ground' },
{ id: 'second', name: 'Upstairs' }
],
defaultValue: 'first',
isVisible: () => twoStory()
}, '1fr');
});
cards.push(card);
}
// One opening, every field read through its default, so the estimate never waits for input
const opening = (i: number): Opening => {
const card = cards[i - 1];
const chosen = card?.dropdown('kind')?.value() || '';
const allowed = isStorefront() ? storefrontKinds : residentialKinds;
const kind = allowed.some(o => o.id === chosen) ? chosen : (allowed[0]?.id || 'window');
const typical = openingKinds[kind] || { name: 'Window', width: 36, height: 60, storefront: false };
const known = card?.radioButton('size')?.value() === 'known';
const width = card?.integer('width')?.value() || 0;
const height = card?.integer('height')?.value() || 0;
return {
number: i,
kind,
sized: known && width > 0 && height > 0,
width: known && width > 0 ? width : typical.width,
height: known && height > 0 ? height : typical.height,
quantity: card?.integer('quantity')?.value() || 1,
upstairs: twoStory() && card?.dropdown('floor')?.value() === 'second'
};
};
const openingList = (): Opening[] => {
const out: Opening[] = [];
for (let i = 1; i <= kindCount(); i++) out.push(opening(i));
return out;
};
const openingSize = (o: Opening) => o.sized ? o.width + ' × ' + o.height + ' in' : 'to be measured';
const squareFeet = (o: Opening) => Math.max((o.width * o.height) / 144, minSqft);
const describeOpening = (o: Opening) => (openingKinds[o.kind]?.name || 'Window') + ', ' + openingSize(o) + ', ' + o.quantity + (o.quantity === 1 ? ' pc' : ' pcs') + (o.upstairs ? ', upstairs' : '');
const shutterCount = () => openingList().reduce((sum, o) => sum + o.quantity, 0);
const upstairsCount = () => openingList().filter(o => o.upstairs).reduce((sum, o) => sum + o.quantity, 0);
// ---------- Step 3: the shutter ----------
const shutterStep = steps.addPage('shutter');
const shutterSection = shutterStep.addSubform('shutter', { title: '🛡️ Shutter', isCollapsible: false });
// New construction or new stucco to come: concealed. A house that stays as it is, or a storefront: on the wall. A condo: in the opening.
const recommendedHousing = () => {
if (building() === 'new_build') return 'recessed';
if (building() === 'condo') return 'inset';
if (building() === 'storefront') return 'surface';
return projectSection.dropdown('refinish')?.value() === 'yes' ? 'recessed' : 'surface';
};
shutterSection.addRow(row => {
row.addRadioButton('housing', {
label: () => 'Housing (we recommend: ' + (housingNames[recommendedHousing()] || '') + ')',
options: [
{ id: 'recessed', name: 'Built-in (concealed) - hidden behind the stucco or soffit (+18%)' },
{ id: 'surface', name: 'Surface-mounted - housing on the wall above the opening (included)' },
{ id: 'inset', name: 'In the opening - tucked under the header inside the recess (+6%)' }
],
defaultValue: 'surface',
orientation: 'vertical',
isRequired: true
});
});
const housing = () => shutterSection.radioButton('housing')?.value() || 'surface';
shutterSection.addRow(row => {
row.addRadioButton('slat', {
label: () => {
const tip = wantsSecurity() ? 'extruded security' : wantsStorm() ? '55 mm foam-filled' : purposes().length > 0 ? '40 mm foam-filled' : '';
return 'Slat profile' + (tip ? ' (we recommend: ' + tip + ')' : '');
},
options: [
{ id: 'foam40', name: '40 mm foam-filled - slim housing; sun, privacy and everyday security ($32/sq ft)' },
{ id: 'foam55', name: '55 mm foam-filled - wider spans and wind-rated; sliding doors and lanais ($38/sq ft)' },
{ id: 'extruded', name: 'Extruded security slat - solid aluminum, impact-rated ($58/sq ft)' }
],
defaultValue: 'foam55',
orientation: 'vertical',
isRequired: true,
isVisible: () => !isStorefront()
});
});
shutterSection.addRow(row => {
row.addRadioButton('storeSlat', {
label: 'Storefront shutter',
options: [
{ id: 'extruded', name: 'Solid security shutter - impact-rated, nothing to see after hours ($58/sq ft)' },
{ id: 'perforated', name: 'Perforated shutter - the display stays on show ($64/sq ft)' },
{ id: 'grille', name: 'Open grille - air and light pass; covered walkways and malls ($46/sq ft)' }
],
defaultValue: 'extruded',
orientation: 'vertical',
isRequired: true,
isVisible: () => isStorefront()
});
});
const slat = () => isStorefront()
? (shutterSection.radioButton('storeSlat')?.value() || 'extruded')
: (shutterSection.radioButton('slat')?.value() || 'foam55');
shutterSection.addRow(row => {
row.addRadioButton('rating', {
label: () => 'Storm rating' + (wantsStorm() ? ' (we recommend: wind-rated)' : ''),
options: [
{ id: 'none', name: 'Not needed - inland, or sun and privacy only (included)' },
{ id: 'wind', name: 'Wind-rated - Florida Building Code approved outside the HVHZ (+25%)' },
{ id: 'hvhz', name: 'HVHZ impact-rated - Miami-Dade NOA, large-missile tested (+45%)' }
],
defaultValue: 'none',
orientation: 'vertical',
isRequired: true,
tooltip: 'A rated shutter can earn a wind mitigation discount on your property insurance; ask your agent'
}, '1fr');
row.addRadioButton('color', {
label: 'Color',
options: [
{ id: 'white', name: 'White (included)' },
{ id: 'beige', name: 'Beige (included)' },
{ id: 'bronze', name: 'Bronze (included)' },
{ id: 'woodgrain', name: 'Wood grain (+15%)' }
],
defaultValue: 'white',
orientation: 'vertical',
isRequired: true,
tooltip: 'Housing, rails and slats in the same color; custom powder-coat colors on request'
}, '1fr');
});
const rating = () => shutterSection.radioButton('rating')?.value() || 'none';
const stormRated = () => rating() !== 'none';
const color = () => shutterSection.radioButton('color')?.value() || 'white';
shutterSection.addRow(row => {
row.addTextPanel('shutterNotes', {
computedValue: () => {
const notes: string[] = [];
if (building() === 'condo') notes.push('🏢 Association approval: rolling shutters change the exterior, so most condo and homeowner associations must approve them. A housing in the opening changes the least, and many associations have a pre-approved color.');
if (!isStorefront() && stormRated() && slat() === 'foam40') notes.push('⚠️ The 40 mm slat is not storm-rated. For a rated shutter we quote the 55 mm or the extruded slat.');
if (isStorefront() && stormRated() && slat() === 'grille') notes.push('⚠️ An open grille stops people, not wind and debris. For a rated storefront shutter we quote the solid security shutter.');
if (stormRated()) notes.push('📋 A storm-rated shutter needs a building permit and a product approval (Florida FL# or Miami-Dade NOA). We pull the permit, install to the approval and schedule the final inspection (' + money(permitFee) + ' once per job).');
return notes.join('\n');
},
isVisible: () => building() === 'condo' || stormRated(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px', 'white-space': 'pre-line' }
});
});
// ---------- Step 4: operation ----------
const operationStep = steps.addPage('operation');
const operationSection = operationStep.addSubform('operation', { title: '⚙️ Operation', isCollapsible: false });
operationSection.addRow(row => {
row.addRadioButton('drive', {
label: 'How do the shutters move?',
options: [
{ id: 'crank', name: 'Manual crank - gear and crank handle inside, no power needed (included)' },
{ id: 'switch', name: 'Motor with wall switch (+$420/shutter)' },
{ id: 'remote', name: 'Motor with remote - every shutter from one remote, in groups (+$520/shutter)' },
{ id: 'solar', name: 'Solar motor - panel and battery, no wiring or electrician (+$780/shutter)' }
],
defaultValue: 'remote',
orientation: 'vertical',
isRequired: true
});
});
const drive = () => operationSection.radioButton('drive')?.value() || 'remote';
const motorized = () => drive() !== 'crank';
const wiredMotor = () => drive() === 'switch' || drive() === 'remote';
operationSection.addRow(row => {
row.addDropdown('power', {
label: 'Is there power at the openings?',
options: [
{ id: 'yes', name: 'Yes' },
{ id: 'no', name: 'No (+$185/shutter electrician hookup, or pick a solar motor)' },
{ id: 'not_sure', name: 'Not sure' }
],
defaultValue: 'not_sure',
isVisible: () => building() !== 'new_build' && wiredMotor(),
tooltip: 'A 110 V line or junction box by the header, where the motor connects. Every wired motor is hooked up by a licensed electrician; it is included when the line is there'
}, '1fr');
row.addEmpty('1fr');
});
const noPower = () => building() !== 'new_build' && wiredMotor() && operationSection.dropdown('power')?.value() === 'no';
operationSection.addRow(row => {
row.addTextPanel('noPowerNote', {
computedValue: () => '🔌 No power at the opening: we recommend a solar motor, with no wiring and no electrician. For a wired motor a licensed electrician runs the line and makes the connection (' + money(hookupPerShutter) + ' per shutter).',
isVisible: () => noPower(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
operationSection.addRow(row => {
row.addCheckboxList('extras', {
label: 'Extras',
options: () => {
const options: CheckboxListOption[] = [];
if (motorized()) options.push({ id: 'smart', name: 'Smart home hub - app, schedules and voice control, once per order (+$350)' });
if (wiredMotor()) options.push({ id: 'battery', name: 'Battery backup - closes and opens when the grid is down before a storm (+$240/shutter)' + (wantsStorm() ? ' - recommended' : '') });
options.push({ id: 'locks', name: 'Interior locking pins - the curtain cannot be lifted from outside (+$45/shutter)' + (wantsSecurity() ? ' - recommended' : '') });
return options;
},
orientation: 'vertical'
});
});
const extras = () => operationSection.checkboxList('extras')?.value() || [];
const smartHub = () => motorized() && extras().includes('smart');
const battery = () => wiredMotor() && extras().includes('battery');
const locks = () => extras().includes('locks');
// ---------- Step 5: installation ----------
const installStep = steps.addPage('install');
const installSection = installStep.addSubform('installation', { title: '🔧 Installation', isCollapsible: false });
installSection.addRow(row => {
row.addRadioButton('wall', {
label: 'What are the walls around the openings?',
options: [
{ id: 'stucco', name: 'Stucco over block (included)' },
{ id: 'brick', name: 'Brick (+$40/shutter)' },
{ id: 'siding', name: 'Siding or wood frame - blocking behind the siding (+$75/shutter)' },
{ id: 'not_sure', name: 'Not sure' }
],
defaultValue: 'stucco',
orientation: 'vertical',
isRequired: true
});
});
const wall = () => installSection.radioButton('wall')?.value() || 'stucco';
installSection.addRow(row => {
row.addCheckbox('removal', {
label: 'Remove old accordion, panel or roll-down shutters and haul them away (+$60/shutter)',
defaultValue: false,
isVisible: () => building() !== 'new_build'
}, '1fr');
row.addCheckbox('hoa', {
label: 'My HOA or association must approve (we prepare the drawings, product approval and color sample)',
defaultValue: false,
isVisible: () => !isStorefront()
}, '1fr');
});
const removal = () => building() !== 'new_build' && !!installSection.checkbox('removal')?.value();
const hoa = () => !isStorefront() && !!installSection.checkbox('hoa')?.value();
installSection.addRow(row => {
row.addTextPanel('hoaNote', {
computedValue: () => '📅 Most associations answer within two to four weeks. We send the application package right after the measure, so the approval and the factory run side by side.',
isVisible: () => hoa(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// ---------- Pricing ----------
const surchargePct = () => (housingPcts[housing()] || 0) + (ratingPcts[rating()] || 0) + (colorPcts[color()] || 0);
const shutterEach = (o: Opening) => squareFeet(o) * (slatRates[slat()] || 38) * (1 + surchargePct() / 100);
const driveEach = () => (drivePrices[drive()] || 0) + (wiredMotor() && noPower() ? hookupPerShutter : 0);
const extrasEach = () => (battery() ? batteryPerShutter : 0) + (locks() ? locksPerShutter : 0);
const installEach = (o: Opening) => installPerShutter + (wallSurcharges[wall()] || 0) + (o.upstairs ? secondFloorPerShutter : 0) + (removal() ? removalPerShutter : 0);
const shuttersCost = () => openingList().reduce((sum, o) => sum + (shutterEach(o) + driveEach() + extrasEach()) * o.quantity, 0);
const installCost = () => openingList().reduce((sum, o) => sum + installEach(o) * o.quantity, 0);
const smartHubCost = () => smartHub() ? smartHubPrice : 0;
const permitCost = () => stormRated() ? permitFee : 0;
const beforePromo = () => shuttersCost() + installCost() + smartHubCost() + permitCost();
const promoDiscount = () => beforePromo() * promoPct / 100;
const afterPromo = () => beforePromo() - promoDiscount();
// The minimum job applies last, after every discount
const minimumApplied = () => afterPromo() < minJob;
const total = () => Math.round(Math.max(afterPromo(), minJob));
const roundTo50 = (amount: number) => Math.round(amount / 50) * 50;
const totalLow = () => roundTo50(total() * (1 - rangePct / 100));
const totalHigh = () => roundTo50(total() * (1 + rangePct / 100));
const describeShutter = () => (housingNames[housing()] || '') + ' housing, ' + (slatNames[slat()] || '') + ' slat, ' + color() + ', ' + (ratingNames[rating()] || '')
+ (housing() === recommendedHousing() ? '' : ' (we recommended ' + (housingNames[recommendedHousing()] || '') + ')');
const describeOperation = () => [
driveNames[drive()] || '',
noPower() ? 'no power at the openings' : '',
smartHub() ? 'smart home hub' : '',
battery() ? 'battery backup' : '',
locks() ? 'interior locking pins' : ''
].filter(part => part).join(', ');
const describeInstall = () => [
'measure and install on ' + (wallNames[wall()] || wall()),
removal() ? 'removal of old shutters' : '',
upstairsCount() > 0 ? upstairsCount() + ' upstairs' : '',
stormRated() ? 'permit and inspection' : '',
hoa() ? 'HOA application' : ''
].filter(part => part).join(', ');
// ---------- Step 6: estimate and contact ----------
const quoteStep = steps.addPage('quote');
const summarySection = quoteStep.addSubform('summary', { title: '💰 Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addTextPanel('promo', {
computedValue: () => '🎉 ' + promoText + ': ' + promoPct + '% off is already included in the estimate below.',
isVisible: () => promoPct > 0,
customStyles: { 'font-size': '0.95rem', 'color': '#065f46', 'background': '#d1fae5', 'padding': '10px', 'border-radius': '6px' }
});
});
summarySection.addRow(row => {
row.addTextPanel('openingsSummary', {
computedValue: () => openingList().map(o => 'Opening ' + o.number + ': ' + describeOpening(o) + ' - ' + money((shutterEach(o) + driveEach() + extrasEach()) * o.quantity)).join('\n')
+ '\nShutter: ' + describeShutter() + '\nOperation: ' + describeOperation(),
customStyles: { 'font-size': '0.9rem', 'color': '#334155', 'white-space': 'pre-line' }
});
});
summarySection.addRow(row => {
row.addPriceDisplay('shuttersCost', {
label: () => 'Shutters, motors and extras (' + shutterCount() + (shutterCount() === 1 ? ' shutter' : ' shutters') + ')',
computedValue: () => Math.round(shuttersCost()),
decimals: 0
}, '1fr');
row.addPriceDisplay('installCost', {
label: () => 'Installation - ' + describeInstall(),
computedValue: () => Math.round(installCost()),
decimals: 0,
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('smartHubCost', {
label: 'Smart home hub (once per order)',
computedValue: () => smartHubCost(),
decimals: 0,
prefix: '+',
isVisible: () => smartHub()
}, '1fr');
row.addPriceDisplay('permitCost', {
label: 'Permit and inspection (once per job)',
computedValue: () => permitCost(),
decimals: 0,
prefix: '+',
isVisible: () => stormRated()
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('promoDiscount', {
label: promoText + ' (-' + promoPct + '%)',
computedValue: () => -Math.round(promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => promoDiscount() > 0
}, '1fr');
row.addPriceDisplay('minimumJob', {
label: 'Minimum job adjustment (small jobs are billed at our minimum of ' + money(minJob) + ')',
computedValue: () => Math.round(total() - afterPromo()),
decimals: 0,
prefix: '+',
isVisible: () => minimumApplied()
}, '1fr');
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('totalEstimate', {
label: 'Estimated cost',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: 'installed, before sales tax'
});
});
summarySection.addRow(row => {
row.addTextPanel('rangeNote', {
computedValue: () => 'Expect ' + money(totalLow()) + ' – ' + money(totalHigh()) + ' (±' + rangePct + '%): the range covers what a measure usually finds.'
+ (financingNote ? ' ' + financingNote : ''),
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' }
});
});
summarySection.addRow(row => {
row.addTextPanel('nextSteps', {
computedValue: () => 'A consultant calls to go over your request and books a free on-site measure within ' + measureDays
+ ' business days. After the measure you get a firm quote, and the shutters are installed about ' + leadWeeks + ' weeks from your order.',
customStyles: { 'font-size': '0.85rem', 'color': '#64748b', 'font-style': 'italic' }
});
});
const contactSection = quoteStep.addSubform('contact', { title: '📋 Contact Details', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Name',
placeholder: 'John Smith',
isRequired: true
}, '1fr');
row.addTextbox('phone', {
label: 'Phone',
placeholder: '(555) 123-4567',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('cityZip', {
label: 'City and ZIP code',
placeholder: 'Fort Myers, 33966',
isRequired: true,
tooltip: 'Where we come out to measure; the county sets the wind zone'
}, '1fr');
row.addEmail('email', {
label: 'Email',
placeholder: 'john@example.com',
tooltip: 'For the PDF estimate and the written quote'
}, '1fr');
});
contactSection.addRow(row => {
row.addDropdown('callTime', {
label: 'Best time to call',
options: [
{ id: 'any', name: 'Any time' },
{ id: 'morning', name: 'Before noon' },
{ id: 'afternoon', name: 'After noon' }
],
defaultValue: 'any'
}, '1fr');
row.addEmpty('1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else?',
placeholder: 'E.g. an arched opening, a gate code, HOA color rules, days that work for the measure',
rows: 3
});
});
const whenWeCall = () => {
const when = contactSection.dropdown('callTime')?.value();
return when === 'morning' ? 'before noon' : when === 'afternoon' ? 'after noon' : 'shortly';
};
form.configureSubmitButton({ label: 'Request My Quote' });
form.configureCompletionScreen({
type: 'text',
title: 'Your request is with a consultant',
message: () => 'We will call you ' + whenWeCall() + ' with a shutter recommendation and book your free on-site measure within ' + measureDays + ' business days.'
});
// ---------- PDF estimate ----------
form.configurePdf('estimate', pdf => {
const items = openingList();
const yesNo = (value: boolean) => value ? 'yes' : 'no';
pdf.configure({
filename: 'rolling-shutter-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the PDF estimate',
header: {
title: 'Rolling shutter estimate',
subtitle: shutterCount() + (shutterCount() === 1 ? ' shutter' : ' shutters') + ' · ' + (housingNames[housing()] || '') + ' · ' + color() + ' · ' + (driveNames[drive()] || '')
},
footer: {
text: 'Estimate only. A firm quote follows the free on-site measure.',
showPageNumbers: true
}
});
pdf.addSection('Prepared for', section => {
section.addRow(row => row
.addField('Name', contactSection.textbox('name')?.value() || '')
.addField('Phone', contactSection.textbox('phone')?.value() || '')
.addField('Email', contactSection.email('email')?.value() || '')
.addField('City and ZIP', contactSection.textbox('cityZip')?.value() || ''));
});
pdf.addSection('Openings', section => {
section.addTable(
['No.', 'Opening', 'Size', 'Floor', 'Qty'],
items.map(o => [String(o.number), openingKinds[o.kind]?.name || 'Window', openingSize(o), o.upstairs ? 'upstairs' : 'ground', String(o.quantity)])
);
if (items.some(o => !o.sized)) {
section.addText('Openings without a size are measured on site; the estimate assumes typical sizes for them.');
}
});
pdf.addSection('Shutter', section => {
section.addRow(row => row
.addField('Housing', housingNames[housing()] || '')
.addField('Slat profile', slatNames[slat()] || '')
.addField('Storm rating', ratingNames[rating()] || '')
.addField('Color', color()));
if (housing() !== recommendedHousing()) {
section.addText('For this building we recommended a ' + (housingNames[recommendedHousing()] || '') + ' housing; your consultant goes over both.');
}
if (stormRated()) {
section.addText('A storm-rated shutter is installed to its product approval (Florida FL# or Miami-Dade NOA) under a building permit with a final inspection.');
}
});
pdf.addSection('Operation and extras', section => {
section.addRow(row => row
.addField('Operation', driveNames[drive()] || '')
.addField('Power at the openings', wiredMotor() && building() !== 'new_build' ? (operationSection.dropdown('power')?.value() || 'not sure').replace('_', ' ') : 'not applicable')
.addField('Smart home hub', yesNo(smartHub()))
.addField('Battery backup', yesNo(battery()))
.addField('Interior locking pins', yesNo(locks())));
});
pdf.addSection('Building, installation and timing', section => {
section.addRow(row => row
.addField('Building', building().replace('_', ' '))
.addField('Shutters for', purposes().join(', ') || 'not marked')
.addField('Walls', wallNames[wall()] || wall())
.addField('Remove old shutters', yesNo(removal()))
.addField('HOA approval', hoa() ? 'needed, we prepare the package' : 'not needed')
.addField('Timing', projectSection.dropdown('timing')?.value() || ''));
});
pdf.addSection('Estimated cost', section => {
const rows: string[][] = items.map(o => [
'Opening ' + o.number + ': ' + (openingKinds[o.kind]?.name || 'Window').toLowerCase() + ', ' + openingSize(o) + ', ' + (slatNames[slat()] || '') + ' slat, ' + (driveNames[drive()] || ''),
String(o.quantity),
money((shutterEach(o) + driveEach() + extrasEach()) * o.quantity)
]);
rows.push(['Installation: ' + describeInstall(), String(shutterCount()), money(installCost())]);
if (smartHub()) rows.push(['Smart home hub', '1', money(smartHubCost())]);
if (stormRated()) rows.push(['Permit and inspection', '1', money(permitCost())]);
if (promoDiscount() > 0) rows.push([promoText + ' (-' + promoPct + '%)', '', '-' + money(promoDiscount())]);
if (minimumApplied()) rows.push(['Minimum job', '', money(total() - afterPromo())]);
rows.push(['Estimated total (installed, before sales tax)', '', money(total())]);
section.addTable(['Item', 'Qty', 'Amount'], rows);
section.addText('Expect ' + money(totalLow()) + ' – ' + money(totalHigh()) + ' (range ±' + rangePct + '%); the firm quote follows the measure.');
});
const notes = contactSection.textarea('notes')?.value();
if (notes) {
pdf.addSection('Notes', section => section.addText(notes));
}
pdf.addSection('Terms', section => {
section.addText('We call you ' + whenWeCall() + ' with a shutter recommendation and book your free on-site measure within ' + measureDays + ' business days; the shutters are installed about ' + leadWeeks + ' weeks from your order.' + (stormRated() ? ' We pull the permit and schedule the final inspection.' : ''));
section.addText('This is an estimate, not a binding offer. Prices are before sales tax and may change after the on-site measure, which confirms sizes, wall construction, power at the openings and the wind zone. A firm quote in writing follows the measure.');
});
});
}