export function commercialDoorCalculator(form: FormTs) {
// Door rates in dollars per square foot of opening (door only, without operator or installation)
const doorRates: Record<string, number> = {
'sectional-pan': 16, // non-insulated ribbed steel pan
'sectional-r17': 24, // 2 in polyurethane, about R-17
'sectional-r26': 30, // 3 in polyurethane, about R-26
'rolling': 28, // rolling steel service door
'highspeed': 55, // high-speed fabric door, operator included
'fire': 45, // UL-listed fire-rated rolling door, chain hoist
'fullview': 40 // full-view anodized aluminum with glass
};
// Percent surcharges on the door itself
const doorSurcharges: Record<string, number> = {
'customColor': 12,
'wide': 10, // over 16 ft wide: struts, heavier springs and hardware
'wind110': 12,
'wind140': 22,
'lowHeadroom': 10,
'highLift': 12,
'verticalLift': 18
};
// Extras built into a sectional door, per door
const extraPrices: Record<string, number> = {
'lites': 650,
'passDoor': 2800
};
// Operators, installed, per door
const operatorPrices: Record<string, number> = {
'manual': 0, // chain hoist
'trolley': 1450,
'jackshaft': 1850,
'heavy': 3200 // heavy-duty three-phase
};
// Controls and safety devices, per door
const controlPrices: Record<string, number> = {
'remotes': 180,
'loop': 950,
'motion': 650,
'timer': 220,
'light': 600,
'edge': 450,
'curtain': 1600
};
// Dock equipment, per dock position
const levelerPrices: Record<string, number> = {
'hydraulic': 9500,
'mechanical': 6500,
'edge-of-dock': 2900,
'none': 0
};
const sealPrices: Record<string, number> = {
'seal': 2200,
'shelter': 3600,
'none': 0
};
const bumpersPrice = 260;
const restraintPrice = 6800;
// Hollow metal man doors
const manDoorPrice = 1650;
const manDoorHardwarePrices: Record<string, number> = {
'panic': 650,
'closer': 220
};
const manDoorInstallPrice = 550;
// Installation
const installPerDoor = 1200;
const tearoutPerDoor = 450;
const circuitPrice = 900;
const liftRentalPerDay = 650;
const permitFee = 350;
const afterHoursPct = 20;
const freightPrice = 450;
// Volume discount on doors and operators, promotion on the whole order
const volumeFrom = 3;
const volumePct = 5;
const promoPct = 0;
const promoText = 'First year of maintenance included';
// Planned maintenance, per door per year (separate agreement, not part of the order)
const maintenancePerDoorYear = 325;
// Range width and lead times
const rangePct = 12;
const surveyDays = 3;
const leadTimeWeeks = 6;
// A typical drive-in opening stands in until somebody measures
const typicalWidthFt = 10;
const typicalHeightFt = 10;
const typicalHeadroomIn = 24;
const typicalSideroomIn = 12;
const headroomStandardIn = 18;
const headroomLowIn = 9;
const headroomHighLiftIn = 36;
const verticalExtraIn = 12;
const sideroomMinIn = 6;
const sideroomJackshaftIn = 16;
const wideFromFt = 16;
const tallFromFt = 14;
const kindNames: Record<string, string> = {
'sectional': 'Insulated sectional',
'rolling': 'Rolling steel',
'highspeed': 'High-speed fabric',
'fire': 'Fire-rated rolling',
'fullview': 'Full-view aluminum'
};
const sectionalKinds = ['sectional', 'fullview'];
const exteriorKinds = ['sectional', 'rolling', 'highspeed', 'fullview'];
const operatorsByKind: Record<string, string[]> = {
'sectional': ['manual', 'trolley', 'jackshaft', 'heavy'],
'rolling': ['manual', 'jackshaft', 'heavy'],
'fullview': ['manual', 'trolley', 'jackshaft', 'heavy'],
'highspeed': [],
'fire': []
};
const operatorNames: Record<string, string> = {
'manual': 'Chain hoist (manual)',
'trolley': 'Trolley operator (light duty)',
'jackshaft': 'Jackshaft operator (medium duty)',
'heavy': 'Heavy-duty three-phase operator'
};
const colorsByKind: Record<string, ControlOption[]> = {
'sectional': [
{ id: 'white', name: 'White (standard)' },
{ id: 'gray', name: 'Gray (standard)' },
{ id: 'brown', name: 'Brown (standard)' },
{ id: 'custom', name: 'Custom powder coat (+12%)' }
],
'rolling': [
{ id: 'galvanized', name: 'Galvanized (standard)' },
{ id: 'custom', name: 'Custom powder coat (+12%)' }
],
'fire': [
{ id: 'galvanized', name: 'Galvanized (standard)' },
{ id: 'custom', name: 'Custom powder coat (+12%)' }
],
'fullview': [
{ id: 'clear', name: 'Clear anodized (standard)' },
{ id: 'bronze', name: 'Dark bronze (standard)' },
{ id: 'black', name: 'Black (standard)' }
],
'highspeed': [
{ id: 'blue', name: 'Blue curtain (standard)' },
{ id: 'yellow', name: 'Yellow curtain (standard)' },
{ id: 'orange', name: 'Orange curtain (standard)' }
]
};
const money = (amount: number) => '$' + Math.round(amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const hasOption = (options: ControlOption[], id: string | null | undefined) => options.some(o => o.id === id);
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Commercial Door Quote Request',
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 building ----------
const buildingStep = steps.addPage('building');
const buildingSection = buildingStep.addSubform('building', { title: '🏭 Building', isCollapsible: false });
buildingSection.addRow(row => {
row.addDropdown('facility', {
label: 'What kind of building is it?',
options: [
{ id: 'warehouse', name: 'Warehouse or distribution center' },
{ id: 'manufacturing', name: 'Manufacturing plant' },
{ id: 'fleet', name: 'Fleet or service shop' },
{ id: 'cold', name: 'Cold storage or food plant' },
{ id: 'showroom', name: 'Showroom or dealership' }
],
defaultValue: 'warehouse',
isRequired: true
}, '1fr');
row.addRadioButton('situation', {
label: 'New opening or a replacement?',
options: [
{ id: 'replace', name: 'Replacing an old door' },
{ id: 'new', name: 'New construction' }
],
defaultValue: 'replace',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
buildingSection.addRow(row => {
row.addRadioButton('traffic', {
label: 'How often will the door cycle?',
options: [
{ id: 'light', name: 'A few times a day' },
{ id: 'medium', name: 'Dozens of times a day' },
{ id: 'continuous', name: 'Continuous traffic' }
],
defaultValue: 'medium',
orientation: 'horizontal',
isRequired: true
});
});
const replacing = () => buildingSection.radioButton('situation')?.value() !== 'new';
const traffic = () => buildingSection.radioButton('traffic')?.value() || 'medium';
// ---------- Step 2: the door ----------
const doorStep = steps.addPage('door');
const doorSection = doorStep.addSubform('door', { title: '🚪 Door', isCollapsible: false });
doorSection.addRow(row => {
row.addRadioButton('kind', {
label: 'Door type',
options: [
{ id: 'sectional', name: 'Insulated sectional - from $24/sq ft (steel sections on track)' },
{ id: 'rolling', name: 'Rolling steel - $28/sq ft (slat curtain coils into a hood)' },
{ id: 'highspeed', name: 'High-speed fabric - $55/sq ft (hundreds of cycles a day, operator included)' },
{ id: 'fire', name: 'Fire-rated rolling - $45/sq ft (UL-listed, closes on the fusible link or alarm)' },
{ id: 'fullview', name: 'Full-view aluminum - $40/sq ft (glass in every section)' }
],
defaultValue: 'sectional',
orientation: 'vertical',
isRequired: true
});
});
const kind = () => doorSection.radioButton('kind')?.value() || 'sectional';
const isSectional = () => sectionalKinds.includes(kind());
const isExterior = () => exteriorKinds.includes(kind());
const operatorMode = () => kind() === 'highspeed' ? 'included' : kind() === 'fire' ? 'none' : 'choice';
doorSection.addRow(row => {
row.addRadioButton('insulation', {
label: 'Construction',
options: [
{ id: 'sectional-pan', name: 'Non-insulated steel pan - $16/sq ft (unheated buildings)' },
{ id: 'sectional-r17', name: '2 in polyurethane, R-17 - $24/sq ft (most warehouses)' },
{ id: 'sectional-r26', name: '3 in polyurethane, R-26 - $30/sq ft (cold storage)' }
],
defaultValue: 'sectional-r17',
orientation: 'vertical',
isVisible: () => kind() === 'sectional'
}, '1fr');
row.addDropdown('color', {
label: 'Finish',
options: () => colorsByKind[kind()] || [],
defaultValue: 'white',
isRequired: true
}, '1fr');
});
doorSection.addRow(row => {
row.addDropdown('wind', {
label: 'Wind load',
options: [
{ id: 'standard', name: 'Standard' },
{ id: 'wind110', name: 'Wind-rated 110 mph (+12%)' },
{ id: 'wind140', name: 'Wind-rated 140 mph, coastal (+22%)' }
],
defaultValue: 'standard',
isVisible: () => isExterior(),
tooltip: 'Coastal counties require a labeled wind-rated door; the plans or the building department say which'
}, '1fr');
row.addInteger('count', {
label: 'How many openings like this?',
min: 1,
max: 30,
defaultValue: 1,
isRequired: true,
tooltip: 'From ' + volumeFrom + ' doors on one order: ' + volumePct + '% off the doors and operators'
}, '1fr');
});
doorSection.addRow(row => {
row.addCheckboxList('extras', {
label: 'Built into the door',
options: [
{ id: 'lites', name: 'Row of vision lites (+$650/door)' },
{ id: 'passDoor', name: 'Pass door - walk through without opening the whole door (+$2,800/door)' }
],
orientation: 'vertical',
isVisible: () => kind() === 'sectional'
});
});
doorSection.addRow(row => {
row.addTextPanel('kindAdvice', {
computedValue: () => {
if (kind() === 'fire') return '🔥 A fire door starts with the plans: we size it to the rating on the life-safety plans, hang it with a chain hoist, and it closes by itself on the fusible link or the alarm.';
if (traffic() === 'continuous') return '⚡ With continuous traffic it is worth pricing a high-speed door too. The number of cycles a day tells us whether it pays off in this building.';
return '';
},
isVisible: () => kind() === 'fire' || (traffic() === 'continuous' && kind() !== 'highspeed'),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
const insulation = () => doorSection.radioButton('insulation')?.value() || 'sectional-r17';
const color = () => {
const options = colorsByKind[kind()] || [];
const chosen = doorSection.dropdown('color')?.value();
return hasOption(options, chosen) ? (chosen || '') : (options[0]?.id || '');
};
const wind = () => isExterior() ? (doorSection.dropdown('wind')?.value() || 'standard') : 'standard';
const count = () => doorSection.integer('count')?.value() || 1;
const extras = () => kind() === 'sectional' ? (doorSection.checkboxList('extras')?.value() || []) : [];
// ---------- Step 3: the opening ----------
const openingStep = steps.addPage('opening');
const openingSection = openingStep.addSubform('opening', { title: '📐 Opening', isCollapsible: false });
openingSection.addRow(row => {
row.addRadioButton('measure', {
label: 'Opening size',
options: [
{ id: 'survey', name: 'Measure it for us (we assume a typical ' + typicalWidthFt + ' × ' + typicalHeightFt + ' ft opening)' },
{ id: 'known', name: 'I know the opening' }
],
defaultValue: 'survey',
orientation: 'vertical'
});
});
const measureForUs = () => openingSection.radioButton('measure')?.value() !== 'known';
openingSection.addRow(row => {
row.addInteger('width', {
label: 'Opening width (ft)',
min: 6,
max: 30,
placeholder: 'e.g. 10',
isRequired: true,
isVisible: () => !measureForUs()
}, '1fr');
row.addInteger('height', {
label: 'Opening height (ft)',
min: 6,
max: 24,
placeholder: 'e.g. 10',
isRequired: true,
isVisible: () => !measureForUs()
}, '1fr');
});
openingSection.addRow(row => {
row.addInteger('headroom', {
label: 'Headroom above the opening (in)',
min: 0,
max: 300,
placeholder: 'e.g. 24',
isRequired: true,
isVisible: () => !measureForUs(),
tooltip: 'From the top of the opening to the lowest thing above it: joist, duct, sprinkler line'
}, '1fr');
row.addInteger('sideroom', {
label: 'Side room (in)',
min: 0,
max: 120,
placeholder: 'e.g. 12',
isRequired: true,
isVisible: () => !measureForUs(),
tooltip: 'Wall beside the opening, on the narrower side'
}, '1fr');
});
const widthFt = () => measureForUs() ? typicalWidthFt : (openingSection.integer('width')?.value() || typicalWidthFt);
const heightFt = () => measureForUs() ? typicalHeightFt : (openingSection.integer('height')?.value() || typicalHeightFt);
const headroomIn = () => {
if (measureForUs()) return typicalHeadroomIn;
const value = openingSection.integer('headroom')?.value();
return value == null ? typicalHeadroomIn : value;
};
const sideroomIn = () => {
if (measureForUs()) return typicalSideroomIn;
const value = openingSection.integer('sideroom')?.value();
return value == null ? typicalSideroomIn : value;
};
const isWide = () => widthFt() > wideFromFt;
const isTall = () => heightFt() >= tallFromFt;
const area = () => widthFt() * heightFt();
// Only a door on track has a lift to choose; the measured headroom decides which
const highLiftPossible = () => isSectional() && !measureForUs() && headroomIn() >= headroomHighLiftIn;
const verticalPossible = () => isSectional() && !measureForUs() && headroomIn() >= heightFt() * 12 + verticalExtraIn;
openingSection.addRow(row => {
row.addRadioButton('lift', {
label: 'Track',
options: () => {
const options: ControlOption[] = [{ id: 'standard', name: 'Standard lift (included)' }];
if (highLiftPossible()) options.push({ id: 'high', name: 'High lift - clears forklifts and lights (+12%)' });
if (verticalPossible()) options.push({ id: 'vertical', name: 'Vertical lift - straight up the wall (+18%)' });
return options;
},
defaultValue: 'standard',
orientation: 'vertical',
isVisible: () => highLiftPossible() || verticalPossible()
});
});
const lift = () => {
if (!isSectional()) return 'standard';
const chosen = openingSection.radioButton('lift')?.value();
if (chosen === 'vertical' && verticalPossible()) return 'vertical';
if (chosen === 'high' && highLiftPossible()) return 'high';
if (headroomIn() >= headroomStandardIn) return 'standard';
if (headroomIn() >= headroomLowIn) return 'low';
return 'special';
};
const liftNames: Record<string, string> = {
'standard': 'standard lift',
'low': 'low-headroom track',
'high': 'high-lift track',
'vertical': 'vertical lift',
'special': 'to be resolved at the survey'
};
openingSection.addRow(row => {
row.addTextPanel('liftInfo', {
computedValue: () => {
const notes: string[] = [];
if (isSectional()) {
if (lift() === 'special') notes.push('⚠️ Less than ' + headroomLowIn + ' in of headroom: no standard track fits. The technician checks at the survey whether the header can be raised or special hardware is needed.');
else if (lift() === 'vertical') notes.push('📐 Track: vertical lift. The door goes straight up the wall, with no horizontal track under the ceiling (+' + doorSurcharges['verticalLift'] + '% on the door).');
else if (lift() === 'high') notes.push('📐 Track: high lift. The door lifts well above the opening before turning horizontal (+' + doorSurcharges['highLift'] + '% on the door).');
else if (lift() === 'low') notes.push('📐 Track: low-headroom. Less than ' + headroomStandardIn + ' in above the opening (+' + doorSurcharges['lowHeadroom'] + '% on the door).');
else notes.push('📐 Track: standard lift. At least ' + headroomStandardIn + ' in above the opening, no extra charge.');
if (!measureForUs() && sideroomIn() < sideroomMinIn) notes.push('⚠️ Tight side room: the track needs about ' + sideroomMinIn + ' in of wall on each side and you have ' + sideroomIn() + ' in. We check at the survey whether the jamb has to be built out.');
}
if (isWide()) notes.push('📏 Over ' + wideFromFt + ' ft wide: the sections get reinforcing struts and heavier springs and hardware (+' + doorSurcharges['wide'] + '% on the door).');
return notes.join('\n');
},
isVisible: () => isSectional() || isWide(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px', 'white-space': 'pre-line' }
});
});
// ---------- Step 4: operator and controls ----------
const operatorStep = steps.addPage('operator');
const operatorSection = operatorStep.addSubform('operator', { title: '⚡ Operator & Controls', isCollapsible: false });
operatorSection.addRow(row => {
row.addRadioButton('operator', {
label: 'Operator',
options: () => (operatorsByKind[kind()] || []).map(id => ({
id,
name: (operatorNames[id] || id) + (operatorPrices[id] ? ' (+' + money(operatorPrices[id] || 0) + '/door)' : ' (included)')
})),
defaultValue: 'jackshaft',
orientation: 'vertical',
isRequired: true,
isVisible: () => operatorMode() === 'choice',
tooltip: 'A three-button open-close-stop station at the door comes with every operator'
});
});
const operator = () => {
if (operatorMode() !== 'choice') return 'manual';
const allowed = operatorsByKind[kind()] || [];
const chosen = operatorSection.radioButton('operator')?.value();
if (chosen && allowed.includes(chosen)) return chosen;
return allowed.includes('jackshaft') ? 'jackshaft' : (allowed[0] || 'manual');
};
const powered = () => operatorMode() === 'included' || (operatorMode() === 'choice' && operator() !== 'manual');
const threePhase = () => operatorMode() === 'choice' && operator() === 'heavy';
operatorSection.addRow(row => {
row.addTextPanel('operatorInfo', {
computedValue: () => {
if (operatorMode() === 'none') return '🔥 A fire-rated rolling door runs on a chain hoist and closes on the fusible link or the alarm; there is no powered operator to choose.';
if (operatorMode() === 'included') return '⚡ The high-speed door comes with its own variable-frequency operator, monitored photo eyes, a breakaway bottom bar and a three-button station. Pick the controls below.';
const notes: string[] = [];
if (traffic() !== 'light' && (operator() === 'trolley' || operator() === 'manual')) notes.push('⚡ At dozens of cycles a day a light-duty operator wears out early. We size the operator to the traffic you expect, not just to the door.');
if (isSectional() && !measureForUs() && sideroomIn() < sideroomJackshaftIn && (operator() === 'jackshaft' || operator() === 'heavy')) notes.push('⚠️ A jackshaft operator wants about ' + sideroomJackshaftIn + ' in of wall on one side; you have ' + sideroomIn() + ' in. We check at the survey whether it fits or a trolley operator is the better fit.');
return notes.join('\n');
},
isVisible: () => operatorMode() !== 'choice'
|| (traffic() !== 'light' && (operator() === 'trolley' || operator() === 'manual'))
|| (isSectional() && !measureForUs() && sideroomIn() < sideroomJackshaftIn && (operator() === 'jackshaft' || operator() === 'heavy')),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px', 'white-space': 'pre-line' }
});
});
operatorSection.addRow(row => {
row.addCheckboxList('controls', {
label: 'Controls and safety (per door)',
options: [
{ id: 'remotes', name: 'Remote transmitters, two per door (+$180)' },
{ id: 'loop', name: 'Loop detector - opens for an approaching forklift (+$950)' },
{ id: 'motion', name: 'Motion sensor - hands-free opening (+$650)' },
{ id: 'timer', name: 'Timer to close - never left open (+$220)' },
{ id: 'light', name: 'Traffic light, red and green at the door (+$600)' },
{ id: 'edge', name: 'Monitored edge sensor - reverses on contact (+$450)' },
{ id: 'curtain', name: 'Light curtain across the full height (+$1,600)' }
],
orientation: 'vertical',
isVisible: () => powered(),
tooltip: 'A three-button station, monitored photo eyes and a spring-break device come with every powered door, as UL 325 requires'
});
});
const controls = () => powered() ? (operatorSection.checkboxList('controls')?.value() || []) : [];
// ---------- Step 5: installation, dock, man doors and service ----------
const installStep = steps.addPage('install');
const installSection = installStep.addSubform('installation', { title: '👷 Installation', isCollapsible: false });
installSection.addRow(row => {
row.addRadioButton('scope', {
label: 'What do we do?',
options: [
{ id: 'install', name: 'Furnish and install' },
{ id: 'freight', name: 'Freight only, you install (+$450 delivery)' }
],
defaultValue: 'install',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addDropdown('timing', {
label: 'When do you need the door?',
options: [
{ id: 'asap', name: 'As soon as possible' },
{ id: 'quarter', name: 'Within 3 months' },
{ id: 'year', name: 'This year' },
{ id: 'comparing', name: 'Just comparing quotes' }
],
defaultValue: 'quarter'
}, '1fr');
});
const installing = () => installSection.radioButton('scope')?.value() !== 'freight';
installSection.addRow(row => {
row.addCheckbox('tearout', {
label: 'Tear out the old door(s) and haul them away (+$450/door)',
defaultValue: true,
isVisible: () => installing() && replacing()
}, '1fr');
row.addCheckbox('liftRental', {
label: 'Scissor or boom lift for a door ' + tallFromFt + ' ft and taller (+$650/day)',
defaultValue: true,
isVisible: () => installing() && isTall()
}, '1fr');
});
installSection.addRow(row => {
row.addDropdown('power', {
label: 'Power at the door',
options: [
{ id: 'yes', name: 'At the door' },
{ id: 'circuit', name: 'Needs a new circuit (+$900)' },
{ id: 'unsure', name: 'Not sure' }
],
defaultValue: 'unsure',
isVisible: () => installing() && powered(),
tooltip: () => threePhase()
? 'The heavy-duty operator runs on three-phase 208 to 480 V. If the building has only single-phase service, tell us in the notes'
: 'The operator runs on 115 or 230 V single-phase. When there is no power at the door, an electrician runs a circuit from the panel'
}, '1fr');
row.addDropdown('permit', {
label: 'Building permit',
options: [
{ id: 'us', name: 'You pull it (+$350)' },
{ id: 'owner', name: 'Owner or GC pulls it' },
{ id: 'none', name: 'Not needed or not sure' }
],
defaultValue: 'none',
isVisible: () => installing(),
tooltip: 'New openings, fire doors and wind-rated doors usually need one; a like-for-like replacement often does not'
}, '1fr');
});
installSection.addRow(row => {
row.addCheckbox('afterHours', {
label: 'Install outside business hours - evenings, nights or weekends (+' + afterHoursPct + '% on labor)',
defaultValue: false,
isVisible: () => installing()
});
});
const power = () => installSection.dropdown('power')?.value() || 'unsure';
const permit = () => installing() ? (installSection.dropdown('permit')?.value() || 'none') : 'none';
const tearout = () => installing() && replacing() && !!installSection.checkbox('tearout')?.value();
const liftRental = () => installing() && isTall() && !!installSection.checkbox('liftRental')?.value();
const afterHours = () => installing() && !!installSection.checkbox('afterHours')?.value();
const dockSection = installStep.addSubform('dock', { title: '🚛 Loading Dock', isCollapsible: false });
dockSection.addRow(row => {
row.addCheckbox('isDock', {
label: 'This opening is a loading dock (equipment priced per dock position, at every door)',
defaultValue: false
});
});
const isDock = () => !!dockSection.checkbox('isDock')?.value();
dockSection.addRow(row => {
row.addDropdown('leveler', {
label: 'Dock leveler',
options: [
{ id: 'hydraulic', name: 'Hydraulic leveler, 30,000 lb (+$9,500)' },
{ id: 'mechanical', name: 'Mechanical leveler, pull chain (+$6,500)' },
{ id: 'edge-of-dock', name: 'Edge-of-dock, no pit (+$2,900)' },
{ id: 'none', name: 'No leveler' }
],
defaultValue: 'hydraulic',
isVisible: () => isDock()
}, '1fr');
row.addDropdown('seal', {
label: 'Seal or shelter',
options: [
{ id: 'seal', name: 'Foam dock seal (+$2,200)' },
{ id: 'shelter', name: 'Rigid dock shelter (+$3,600)' },
{ id: 'none', name: 'No seal' }
],
defaultValue: 'seal',
isVisible: () => isDock()
}, '1fr');
});
dockSection.addRow(row => {
row.addCheckbox('bumpers', {
label: 'Dock bumpers, a pair per position (+$260)',
defaultValue: true,
isVisible: () => isDock()
}, '1fr');
row.addCheckbox('restraint', {
label: 'Vehicle restraint with signal lights (+$6,800)',
defaultValue: false,
isVisible: () => isDock()
}, '1fr');
});
const leveler = () => isDock() ? (dockSection.dropdown('leveler')?.value() || 'hydraulic') : 'none';
const seal = () => isDock() ? (dockSection.dropdown('seal')?.value() || 'seal') : 'none';
const bumpers = () => isDock() && !!dockSection.checkbox('bumpers')?.value();
const restraint = () => isDock() && !!dockSection.checkbox('restraint')?.value();
const manDoorSection = installStep.addSubform('manDoors', { title: '🚶 Man Doors & Service', isCollapsible: false });
manDoorSection.addRow(row => {
row.addInteger('manDoorCount', {
label: 'Hollow metal man doors beside the openings ($1,650 each)',
min: 0,
max: 10,
defaultValue: 0,
tooltip: '3-0 × 7-0 insulated hollow metal door and frame with a lever lockset'
}, '1fr');
row.addCheckboxList('manDoorHardware', {
label: 'Hardware on each man door',
options: [
{ id: 'panic', name: 'Panic bar for a required exit (+$650)' },
{ id: 'closer', name: 'Surface-mounted door closer (+$220)' }
],
defaultValue: ['closer'],
orientation: 'vertical',
isVisible: () => (manDoorSection.integer('manDoorCount')?.value() || 0) > 0
}, '1fr');
});
manDoorSection.addRow(row => {
row.addCheckbox('maintenance', {
label: () => kind() === 'fire'
? 'Planned maintenance agreement - annual drop test under NFPA 80 with a written record ($325/door/yr, separate agreement)'
: 'Planned maintenance agreement - yearly inspection and adjustment with a written report ($325/door/yr, separate agreement)',
defaultValue: true,
isVisible: () => powered() || kind() === 'fire'
});
});
const manDoorCount = () => manDoorSection.integer('manDoorCount')?.value() || 0;
const manDoorHardware = () => manDoorCount() > 0 ? (manDoorSection.checkboxList('manDoorHardware')?.value() || []) : [];
const maintenance = () => (powered() || kind() === 'fire') && !!manDoorSection.checkbox('maintenance')?.value();
// ---------- Pricing ----------
const rateOf = () => kind() === 'sectional' ? (doorRates[insulation()] || 24) : (doorRates[kind()] || 24);
const doorPercent = () => (color() === 'custom' ? doorSurcharges['customColor'] || 0 : 0)
+ (isWide() ? doorSurcharges['wide'] || 0 : 0)
+ (wind() === 'wind110' ? doorSurcharges['wind110'] || 0 : 0)
+ (wind() === 'wind140' ? doorSurcharges['wind140'] || 0 : 0)
+ (lift() === 'low' ? doorSurcharges['lowHeadroom'] || 0 : 0)
+ (lift() === 'high' ? doorSurcharges['highLift'] || 0 : 0)
+ (lift() === 'vertical' ? doorSurcharges['verticalLift'] || 0 : 0);
const sumPrices = (ids: string[], prices: Record<string, number>) => ids.reduce((sum, id) => sum + (prices[id] || 0), 0);
// One door: rate by the square foot, percent surcharges, extras, the operator and its controls
const doorEach = () => area() * rateOf() * (1 + doorPercent() / 100)
+ sumPrices(extras(), extraPrices)
+ (operatorMode() === 'choice' ? (operatorPrices[operator()] || 0) : 0)
+ sumPrices(controls(), controlPrices);
const doorsTotal = () => doorEach() * count();
const volumeDiscount = () => count() >= volumeFrom ? doorsTotal() * volumePct / 100 : 0;
const dockEach = () => !isDock() ? 0
: (levelerPrices[leveler()] || 0) + (sealPrices[seal()] || 0) + (bumpers() ? bumpersPrice : 0) + (restraint() ? restraintPrice : 0);
const dockTotal = () => dockEach() * count();
const manDoorEach = () => manDoorPrice + sumPrices(manDoorHardware(), manDoorHardwarePrices);
const manDoorsTotal = () => manDoorEach() * manDoorCount();
// Crew hours: doors, tear-out and man doors; after-hours adds its percent to these alone
const labor = () => !installing() ? 0
: installPerDoor * count() + (tearout() ? tearoutPerDoor * count() : 0) + manDoorInstallPrice * manDoorCount();
const installationTotal = () => !installing() ? 0
: labor() * (1 + (afterHours() ? afterHoursPct / 100 : 0))
+ (powered() && power() === 'circuit' ? circuitPrice : 0)
+ (liftRental() ? liftRentalPerDay : 0)
+ (permit() === 'us' ? permitFee : 0);
const freight = () => installing() ? 0 : freightPrice;
const beforePromo = () => doorsTotal() - volumeDiscount() + dockTotal() + manDoorsTotal() + installationTotal() + freight();
const promoDiscount = () => beforePromo() * promoPct / 100;
const total = () => Math.round(beforePromo() - promoDiscount());
const roundTo100 = (amount: number) => Math.round(amount / 100) * 100;
const totalLow = () => roundTo100(total() * (1 - rangePct / 100));
const totalHigh = () => roundTo100(total() * (1 + rangePct / 100));
const maintenanceYearly = () => maintenance() ? maintenancePerDoorYear * count() : 0;
const describeOpening = () => measureForUs()
? 'to be measured at the survey'
: widthFt() + ' × ' + heightFt() + ' ft, headroom ' + headroomIn() + ' in, side room ' + sideroomIn() + ' in';
const describeDoor = () => (kindNames[kind()] || 'Door') + ', ' + color()
+ (kind() === 'sectional' ? ', ' + insulation().replace('sectional-', '') : '')
+ (wind() !== 'standard' ? ', ' + wind().replace('wind', 'wind-rated ') + ' mph' : '')
+ ', ' + describeOpening() + (count() > 1 ? ', ' + count() + ' doors' : '');
const describeOperator = () => {
if (operatorMode() === 'none') return 'chain hoist, closes on the fusible link or the fire alarm';
const gear = controls().join(', ');
const head = operatorMode() === 'included' ? 'operator included with the door' : (operatorNames[operator()] || operator());
return head + (gear ? ' with ' + gear : '');
};
const describeDock = () => isDock()
? [leveler() + ' leveler', seal() === 'none' ? 'no seal' : seal(), bumpers() ? 'bumpers' : '', restraint() ? 'vehicle restraint' : ''].filter(part => part).join(', ')
: 'no dock equipment';
const describeManDoors = () => manDoorCount() > 0
? manDoorCount() + ' × hollow metal man door' + (manDoorHardware().length > 0 ? ' with ' + manDoorHardware().join(' and ') : '')
: 'none';
const describeInstallation = () => !installing() ? 'freight only'
: ['survey and installation',
tearout() ? 'tear-out of the old door' : '',
powered() ? 'power: ' + power() : '',
liftRental() ? 'lift rental' : '',
permit() === 'us' ? 'we pull the permit' : permit() === 'owner' ? 'owner or GC pulls the permit' : '',
afterHours() ? 'outside business hours' : ''
].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 in the range below.',
isVisible: () => promoPct > 0,
customStyles: { 'font-size': '0.95rem', 'color': '#065f46', 'background': '#d1fae5', 'padding': '10px', 'border-radius': '6px' }
});
});
summarySection.addRow(row => {
row.addPriceDisplay('doorsCost', {
label: () => 'Doors & operators (' + count() + (count() === 1 ? ' door' : ' doors') + ')',
computedValue: () => Math.round(doorsTotal()),
decimals: 0
}, '1fr');
row.addPriceDisplay('volumeDiscount', {
label: 'Volume discount (-' + volumePct + '%)',
computedValue: () => -Math.round(volumeDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => volumeDiscount() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('dockCost', {
label: 'Dock equipment',
computedValue: () => Math.round(dockTotal()),
decimals: 0,
prefix: '+',
isVisible: () => isDock()
}, '1fr');
row.addPriceDisplay('manDoorsCost', {
label: 'Hollow metal man doors',
computedValue: () => Math.round(manDoorsTotal()),
decimals: 0,
prefix: '+',
isVisible: () => manDoorCount() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('installationCost', {
label: () => installing() ? 'Installation' : 'Freight',
computedValue: () => Math.round(installationTotal() + freight()),
decimals: 0,
prefix: '+'
}, '1fr');
row.addPriceDisplay('promoDiscount', {
label: promoText + ' (-' + promoPct + '%)',
computedValue: () => -Math.round(promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => promoDiscount() > 0
}, '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: () => installing() ? 'installed, before sales tax' : 'delivered, before sales tax'
});
});
summarySection.addRow(row => {
row.addTextPanel('rangeNote', {
computedValue: () => 'Expect ' + money(totalLow()) + ' – ' + money(totalHigh()) + ' (±' + rangePct + '%): the range covers what the site survey usually turns up.'
+ (maintenanceYearly() > 0 ? ' Planned maintenance: ' + money(maintenanceYearly()) + '/yr under a separate agreement, not part of the order.' : ''),
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' }
});
});
summarySection.addRow(row => {
row.addTextPanel('nextSteps', {
computedValue: () => 'An estimator calls with a door proposal and sets up a free site survey within ' + surveyDays
+ ' business days. After the survey you get a firm quote, and the door is installed about ' + leadTimeWeeks + ' weeks later.',
customStyles: { 'font-size': '0.85rem', 'color': '#64748b', 'font-style': 'italic' }
});
});
const contactSection = quoteStep.addSubform('contact', { title: '🏢 Who to Quote', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('company', {
label: 'Company',
placeholder: 'The name on the quote and the purchase order',
isRequired: true
}, '2fr');
row.addTextbox('jobTitle', {
label: 'Your title',
placeholder: 'e.g. facilities manager'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Your 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@company.com',
isRequired: true
}, '1fr');
row.addTextbox('address', {
label: 'Facility address',
placeholder: 'Street, city, state ZIP',
isRequired: true,
tooltip: 'Where the survey takes place and which wind zone it is in'
}, '1fr');
});
contactSection.addRow(row => {
row.addDropdown('callTime', {
label: 'Best time to call',
options: [
{ id: 'anytime', name: 'Anytime' },
{ id: 'morning', name: 'Mornings' },
{ id: 'afternoon', name: 'Afternoons' }
],
defaultValue: 'anytime'
}, '1fr');
row.addEmpty('1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else?',
placeholder: 'E.g. only one of the doors is a dock, shift work, gate access for the crew, project or bid number, single-phase service only',
rows: 3
});
});
const whenWeCall = () => {
const when = contactSection.dropdown('callTime')?.value();
return when === 'morning' ? 'in the morning' : when === 'afternoon' ? 'in the afternoon' : 'shortly';
};
form.configureSubmitButton({ label: 'Request My Quote' });
form.configureCompletionScreen({
type: 'text',
title: 'Your request is with our estimator',
message: () => 'We will call ' + whenWeCall() + ' with a door proposal and set up a free site survey within ' + surveyDays + ' business days.'
});
// ---------- PDF estimate ----------
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'commercial-door-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the estimate (PDF)',
header: {
title: 'Commercial door estimate',
subtitle: (kindNames[kind()] || '') + ' · ' + color() + ' · ' + count() + (count() === 1 ? ' door' : ' doors')
},
footer: {
text: 'Estimated cost before sales tax. The firm quote follows the free site survey.',
showPageNumbers: true
}
});
pdf.addSection('Prepared for', section => {
section.addRow(row => row
.addField('Company', contactSection.textbox('company')?.value() || '')
.addField('Contact', [contactSection.textbox('name')?.value(), contactSection.textbox('jobTitle')?.value()].filter(part => part).join(', '))
.addField('Facility address', contactSection.textbox('address')?.value() || ''));
});
pdf.addSection('Door', section => {
section.addRow(row => row
.addField('Type', kindNames[kind()] || '')
.addField('Finish', color())
.addField('Construction', kind() === 'sectional' ? insulation().replace('sectional-', '').toUpperCase() : 'not applicable')
.addField('Doors', String(count())));
section.addRow(row => row
.addField('Wind load', isExterior() ? wind() : 'interior door')
.addField('Extras', extras().join(', ') || 'none')
.addField('Operator and controls', describeOperator()));
});
pdf.addSection('Opening', section => {
section.addRow(row => row
.addField('Width', measureForUs() ? 'to be measured' : widthFt() + ' ft')
.addField('Height', measureForUs() ? 'to be measured' : heightFt() + ' ft')
.addField('Headroom', measureForUs() ? 'to be measured' : headroomIn() + ' in')
.addField('Side room', measureForUs() ? 'to be measured' : sideroomIn() + ' in'));
if (isSectional()) {
section.addRow(row => row.addField('Track', liftNames[lift()] || '').addField('Area per door', area() + ' sq ft'));
}
});
pdf.addSection('Installation and timing', section => {
section.addRow(row => row
.addField('Building', buildingSection.dropdown('facility')?.value() || '')
.addField('Project', replacing() ? 'replacing a door in a working building' : 'new construction')
.addField('Traffic', traffic())
.addField('Timing', installSection.dropdown('timing')?.value() || ''));
section.addRow(row => row
.addField('Scope', describeInstallation())
.addField('Dock equipment', describeDock())
.addField('Man doors', describeManDoors()));
});
pdf.addSection('Estimated cost', section => {
const rows: string[][] = [
['Doors and operators: ' + describeDoor() + '; ' + describeOperator(), String(count()), money(doorsTotal())]
];
if (volumeDiscount() > 0) rows.push(['Volume discount (-' + volumePct + '%)', '', '-' + money(volumeDiscount())]);
if (isDock()) rows.push(['Dock equipment: ' + describeDock(), String(count()), money(dockTotal())]);
if (manDoorCount() > 0) rows.push(['Hollow metal man doors: ' + describeManDoors(), String(manDoorCount()), money(manDoorsTotal())]);
rows.push(['Installation: ' + describeInstallation(), '', installing() ? money(installationTotal()) : 'not included']);
if (freight() > 0) rows.push(['Freight', '1', money(freight())]);
if (promoDiscount() > 0) rows.push([promoText + ' (-' + promoPct + '%)', '', '-' + money(promoDiscount())]);
rows.push(['Estimated total (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 site survey.');
if (maintenanceYearly() > 0) {
section.addText('Planned maintenance, separate agreement, not part of the order: ' + money(maintenanceYearly()) + ' a year for ' + count() + (count() === 1 ? ' door' : ' doors')
+ (kind() === 'fire' ? ', including the annual drop test and its record under NFPA 80.' : ', with a yearly inspection, adjustment and a written report.'));
}
});
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 door proposal and schedule the free site survey within ' + surveyDays + ' business days; the door is installed about ' + leadTimeWeeks + ' weeks after the survey.');
section.addText('Prices are before sales tax. The cost is an estimate, not an offer to contract; the firm price is set after the free site survey. Fire ratings and wind ratings follow the plans and the building department; permits are quoted only where we pull them.');
});
});
}