export function entryDoorCalculator(form: FormTs) {
// Entry door prices: one prehung 36 x 80 in door in its frame, factory white,
// satin nickel handleset and deadbolt, without installation
const materialPrices: Record<string, number> = {
'steel': 1650,
'fiberglass-smooth': 2600,
'fiberglass-grain': 2900,
'mahogany': 5200,
'alder': 4400
};
const materialNames: Record<string, string> = {
'steel': 'Insulated steel',
'fiberglass-smooth': 'Fiberglass, smooth',
'fiberglass-grain': 'Fiberglass, woodgrain',
'mahogany': 'Mahogany',
'alder': 'Knotty alder'
};
const stainableMaterials = ['fiberglass-grain', 'mahogany', 'alder'];
// Style surcharge on the door price (%), whether the style has glass, whether it is a pair
const styles: Record<string, { name: string; surcharge: number; glass: boolean; double: boolean }> = {
'six-panel': { name: 'Six-panel', surcharge: 0, glass: false, double: false },
'craftsman': { name: 'Craftsman', surcharge: 8, glass: true, double: false },
'modern': { name: 'Modern flush with lite', surcharge: 10, glass: true, double: false },
'three-quarter': { name: 'Three-quarter lite', surcharge: 15, glass: true, double: false },
'full-lite': { name: 'Full lite', surcharge: 20, glass: true, double: false },
'double': { name: 'Double doors', surcharge: 90, glass: false, double: true }
};
const oversizeSurcharge = 15; // % when wider than 36 in (72 in for a pair) or taller than 80 in
// Sidelites, transom, glass (per lite), finish
const sidelitePrices: Record<string, number> = { 'full': 750, 'half': 650 };
const transomPrices: Record<string, number> = { 'none': 0, 'rect': 600, 'arch': 950 };
const transomNames: Record<string, string> = { 'none': 'No transom', 'rect': 'Rectangular transom', 'arch': 'Arched transom' };
const glassStylePrices: Record<string, number> = { 'clear': 0, 'rain': 120, 'frosted': 120, 'decorative': 350 };
const glassStyleNames: Record<string, string> = { 'clear': 'Clear', 'rain': 'Rain privacy', 'frosted': 'Frosted privacy', 'decorative': 'Decorative leaded' };
const glassOptionPrices: Record<string, number> = { 'low-e': 90, 'impact': 425, 'grilles': 110 };
const glassOptionNames: Record<string, string> = { 'low-e': 'Low-E glass', 'impact': 'Impact-rated glass', 'grilles': 'Grilles between the glass' };
const finishPrices: Record<string, number> = { 'paint': 240, 'stain': 380 };
const finishes: Record<string, { name: string; kind: string }> = {
'white': { name: 'White (included)', kind: 'white' },
'primed': { name: 'Primed, paint it yourself', kind: 'none' },
'black': { name: 'Black paint', kind: 'paint' },
'navy': { name: 'Navy paint', kind: 'paint' },
'red': { name: 'Colonial red paint', kind: 'paint' },
'sage': { name: 'Sage paint', kind: 'paint' },
'walnut': { name: 'Walnut stain', kind: 'stain' },
'cherry': { name: 'Cherry stain', kind: 'stain' },
'mahogany': { name: 'Dark mahogany stain', kind: 'stain' },
'natural': { name: 'Natural, clear coat', kind: 'stain' }
};
// Hardware, locks and extras (per door)
const handlesetPrices: Record<string, number> = { 'nickel': 0, 'black': 85, 'bronze': 85, 'brass': 120, 'pull-bar': 320 };
const handlesetNames: Record<string, string> = {
'nickel': 'Satin nickel handleset', 'black': 'Matte black handleset', 'bronze': 'Aged bronze handleset',
'brass': 'Polished brass handleset', 'pull-bar': 'Modern pull bar'
};
const lockPrices: Record<string, number> = { 'deadbolt': 0, 'keypad': 280, 'app': 420 };
const lockNames: Record<string, string> = { 'deadbolt': 'Standard deadbolt', 'keypad': 'Keypad smart lock', 'app': 'Smart lock with app' };
const extraPrices: Record<string, number> = { 'multipoint': 550, 'ada': 140, 'storm': 520, 'kick': 65, 'viewer': 45 };
const extraNames: Record<string, string> = { 'multipoint': 'Multipoint lock', 'ada': 'Low-profile threshold', 'storm': 'Storm door', 'kick': 'Kick plate', 'viewer': 'Door viewer' };
// Patio doors: white vinyl, 80 in tall, tempered Low-E glass and a screen, without installation
const patioPrices: Record<string, number> = {
'sliding-6': 2100, 'sliding-8': 2900, 'sliding-12': 4700,
'french-5': 3100, 'french-6': 3400,
'multislide-12': 9800, 'multislide-16': 13500
};
const patioWidths: Record<string, { id: string; name: string }[]> = {
'sliding': [{ id: 'sliding-6', name: '6 ft, 2 panels' }, { id: 'sliding-8', name: '8 ft, 2 panels' }, { id: 'sliding-12', name: '12 ft, 3 panels' }],
'french': [{ id: 'french-5', name: '5 ft' }, { id: 'french-6', name: '6 ft' }],
'multislide': [{ id: 'multislide-12', name: '12 ft, 3 panels' }, { id: 'multislide-16', name: '16 ft, 4 panels' }]
};
const patioTypeNames: Record<string, string> = { 'sliding': 'Sliding glass door', 'french': 'French hinged door', 'multislide': 'Multi-slide door' };
const patioFrameSurcharges: Record<string, number> = { 'vinyl': 0, 'fiberglass': 40, 'aluminum': 25, 'wood-clad': 80 }; // %
const patioFrameNames: Record<string, string> = { 'vinyl': 'Vinyl', 'fiberglass': 'Fiberglass', 'aluminum': 'Aluminum', 'wood-clad': 'Wood-clad' };
const patioTallSurcharge = 15; // % for 96 in panels
const patioImpactSurcharge = 35; // % for impact-rated glass
const patioBlindsPrice = 450;
const patioGrillesPrice = 140;
// Installation and everyday settings
const installPrices: Record<string, number> = {
'door': 650, 'wide-entry': 950, 'patio': 850, 'haul-away': 75, 'steel-cutout': 275, 'trim': 220, 'permit': 250, 'delivery': 150
};
const promotion = { percent: 0, name: 'Spring entry door sale' }; // 0 hides the banner
const rangePercent = 12; // the estimate is shown as a range of +/- this
const measureDays = 3; // free in-home measure within N business days
const leadWeeks = 6; // order to installation
// Common door sizes in inches; a pair takes the wide ones
const commonSizes: { id: string; width: number; height: number; double: boolean }[] = [
{ id: '32x80', width: 32, height: 80, double: false },
{ id: '36x80', width: 36, height: 80, double: false },
{ id: '42x80', width: 42, height: 80, double: false },
{ id: '36x96', width: 36, height: 96, double: false },
{ id: '60x80', width: 60, height: 80, double: true },
{ id: '72x80', width: 72, height: 80, double: true },
{ id: '72x96', width: 72, height: 96, double: true }
];
const money = (amount: number): string => {
const whole = String(Math.round(Math.abs(amount)));
let out = '';
for (let i = 0; i < whole.length; i++) {
out += whole.charAt(i);
const left = whole.length - i - 1;
if (left > 0 && left % 3 === 0) out += ',';
}
return (amount < 0 ? '-$' : '$') + out;
};
const plural = (count: number, one: string, many: string) => count + ' ' + (count === 1 ? one : many);
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Entry & Patio Door Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addSpacer({ height: 20 });
// Project Section
const projectSection = form.addSubform('project', { title: '🏠 Your Project' });
projectSection.addRow(row => {
row.addRadioButton('scope', {
label: 'What do you need a quote for?',
options: [
{ id: 'entry', name: 'Entry door (front, back or side)' },
{ id: 'patio', name: 'Patio door (sliding, French or multi-slide)' },
{ id: 'both', name: 'Both' }
],
defaultValue: 'entry',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addDropdown('projectType', {
label: 'Replacement or new construction?',
options: [
{ id: 'replacement', name: 'Replacement in my home' },
{ id: 'new-build', name: 'New construction' }
],
defaultValue: 'replacement',
isRequired: true
}, '1fr');
});
const scope = form.computedValue(() => projectSection.radioButton('scope')?.value() || 'entry');
const withEntry = form.computedValue(() => scope() !== 'patio');
const withPatio = form.computedValue(() => scope() !== 'entry');
const replacement = form.computedValue(() => projectSection.dropdown('projectType')?.value() !== 'new-build');
projectSection.addRow(row => {
row.addDropdown('oldFrame', {
label: 'What frame is the old door in?',
tooltip: 'Many block homes have a steel frame set in the masonry; cutting it out changes the job',
options: [
{ id: 'wood', name: 'Wood jamb' },
{ id: 'steel', name: 'Steel frame in block (+$' + installPrices['steel-cutout'] + ' cut-out with haul-away)' },
{ id: 'not-sure', name: 'Not sure' }
],
defaultValue: 'not-sure',
isVisible: () => replacement()
}, '1fr');
});
const steelFrame = form.computedValue(() => replacement() && projectSection.dropdown('oldFrame')?.value() === 'steel');
// Entry Door Section
const entrySection = form.addSubform('entryDoor', { title: '🚪 Entry Door', isVisible: () => withEntry() });
entrySection.addRow(row => {
row.addRadioButton('style', {
label: 'Door style',
options: [
{ id: 'six-panel', name: 'Six-panel - classic colonial slab, no glass' },
{ id: 'craftsman', name: 'Craftsman - three small lites over two panels (+8%)' },
{ id: 'modern', name: 'Modern flush - narrow vertical lite (+10%)' },
{ id: 'three-quarter', name: 'Three-quarter lite - glass down to the bottom panel (+15%)' },
{ id: 'full-lite', name: 'Full lite - glass edge to edge (+20%)' },
{ id: 'double', name: 'Double doors - two leaves in one frame (+90%)' }
],
defaultValue: 'six-panel',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('material', {
label: 'Material',
options: [
{ id: 'steel', name: 'Insulated steel - best value (from $1,650)' },
{ id: 'fiberglass-smooth', name: 'Fiberglass, smooth - paint grade (from $2,600)' },
{ id: 'fiberglass-grain', name: 'Fiberglass, woodgrain - stain grade (from $2,900)' },
{ id: 'mahogany', name: 'Mahogany - solid wood (from $5,200)' },
{ id: 'alder', name: 'Knotty alder - rustic solid wood (from $4,400)' }
],
defaultValue: 'steel',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const style = form.computedValue(() => entrySection.radioButton('style')?.value() || 'six-panel');
const styleInfo = () => styles[style()] || { name: 'Six-panel', surcharge: 0, glass: false, double: false };
const material = form.computedValue(() => entrySection.radioButton('material')?.value() || 'steel');
const stainable = form.computedValue(() => stainableMaterials.includes(material()));
entrySection.addRow(row => {
row.addDropdown('finish', {
label: 'Finish (both sides)',
tooltip: 'Stains need a woodgrain fiberglass or wood door; paint covers the grain',
options: () => Object.keys(finishes)
.filter(id => finishes[id]?.kind !== 'stain' || stainable())
.map(id => {
const f = finishes[id] || { name: id, kind: 'none' };
const price = f.kind === 'paint' ? finishPrices['paint'] : f.kind === 'stain' ? finishPrices['stain'] : 0;
return { id, name: f.name + (price ? ' (+' + money(price) + ')' : '') };
}),
defaultValue: 'white',
isRequired: true
}, '1fr');
row.addDropdown('sidelites', {
label: 'Sidelites',
options: [
{ id: '0', name: 'No sidelites' },
{ id: '1', name: 'One sidelite (glass on the handle side)' },
{ id: '2', name: 'Two sidelites (glass on both sides)' }
],
defaultValue: '0',
isRequired: true
}, '1fr');
});
// A stain picked on wood does not follow to a steel door
const finish = form.computedValue(() => {
const chosen = entrySection.dropdown('finish')?.value() || 'white';
const kind = finishes[chosen]?.kind || 'white';
return kind === 'stain' && !stainable() ? 'white' : chosen;
});
const finishKind = form.computedValue(() => finishes[finish()]?.kind || 'white');
entrySection.addRow(row => {
row.addRadioButton('sideliteGlass', {
label: 'Sidelite glass',
options: [
{ id: 'full', name: 'Full length ($' + sidelitePrices['full'] + ' each)' },
{ id: 'half', name: 'Half, panel below ($' + sidelitePrices['half'] + ' each)' }
],
defaultValue: 'full',
isVisible: () => sidelitesCount() > 0
}, '1fr');
row.addDropdown('transom', {
label: 'Transom above the door',
options: [
{ id: 'none', name: 'No transom' },
{ id: 'rect', name: 'Rectangular transom (+$' + transomPrices['rect'] + ')' },
{ id: 'arch', name: 'Arched transom - elliptical fanlight (+$' + transomPrices['arch'] + ')' }
],
defaultValue: 'none',
isRequired: true
}, '1fr');
});
const sidelitesCount = form.computedValue(() => Number(entrySection.dropdown('sidelites')?.value() || '0') || 0);
const sideliteGlass = form.computedValue(() => entrySection.radioButton('sideliteGlass')?.value() === 'half' ? 'half' : 'full');
const transom = form.computedValue(() => entrySection.dropdown('transom')?.value() || 'none');
// Every pane the glass options apply to: the lite in the door, each sidelite and the transom
const glassUnits = form.computedValue(() => (styleInfo().glass ? 1 : 0) + sidelitesCount() + (transom() !== 'none' ? 1 : 0));
entrySection.addRow(row => {
row.addDropdown('glassStyle', {
label: () => 'Glass, ' + plural(glassUnits(), 'lite', 'lites'),
tooltip: 'The door, each sidelite and the transom count as one lite each',
options: [
{ id: 'clear', name: 'Clear (included)' },
{ id: 'rain', name: 'Rain privacy - textured (+$' + glassStylePrices['rain'] + '/lite)' },
{ id: 'frosted', name: 'Frosted privacy - satin etched (+$' + glassStylePrices['frosted'] + '/lite)' },
{ id: 'decorative', name: 'Decorative leaded - beveled with caming (+$' + glassStylePrices['decorative'] + '/lite)' }
],
defaultValue: 'clear',
isVisible: () => glassUnits() > 0
}, '1fr');
row.addCheckboxList('glassOptions', {
label: 'Glass options (per lite)',
options: [
{ id: 'low-e', name: 'Low-E glass - blocks summer heat (+$' + glassOptionPrices['low-e'] + '/lite)' },
{ id: 'impact', name: 'Impact-rated - laminated hurricane glass (+$' + glassOptionPrices['impact'] + '/lite)' },
{ id: 'grilles', name: 'Grilles between the glass - colonial or prairie (+$' + glassOptionPrices['grilles'] + '/lite)' }
],
orientation: 'vertical',
isVisible: () => glassUnits() > 0
}, '1fr');
});
const glassStyle = form.computedValue(() => glassUnits() > 0 ? (entrySection.dropdown('glassStyle')?.value() || 'clear') : 'clear');
const glassOptions = form.computedValue(() => glassUnits() > 0 ? (entrySection.checkboxList('glassOptions')?.value() || []) : []);
// Hardware & Size Section
const hardwareSection = form.addSubform('hardware', { title: '🔒 Hardware & Size', isVisible: () => withEntry() });
hardwareSection.addRow(row => {
row.addDropdown('handleset', {
label: 'Handleset',
tooltip: 'The lock and the kick plate come in the same finish',
options: [
{ id: 'nickel', name: 'Satin nickel handleset (included)' },
{ id: 'black', name: 'Matte black handleset (+$' + handlesetPrices['black'] + ')' },
{ id: 'bronze', name: 'Aged bronze handleset (+$' + handlesetPrices['bronze'] + ')' },
{ id: 'brass', name: 'Polished brass handleset (+$' + handlesetPrices['brass'] + ')' },
{ id: 'pull-bar', name: 'Modern pull bar, lever inside (+$' + handlesetPrices['pull-bar'] + ')' }
],
defaultValue: 'nickel',
isRequired: true
}, '1fr');
row.addDropdown('lock', {
label: 'Lock',
options: [
{ id: 'deadbolt', name: 'Standard keyed deadbolt (included)' },
{ id: 'keypad', name: 'Keypad smart lock (+$' + lockPrices['keypad'] + ')' },
{ id: 'app', name: 'Smart lock with app, Wi-Fi (+$' + lockPrices['app'] + ')' }
],
defaultValue: 'deadbolt',
isRequired: true
}, '1fr');
});
hardwareSection.addRow(row => {
row.addCheckboxList('extras', {
label: 'Locks and extras',
options: [
{ id: 'multipoint', name: 'Multipoint lock - bolts top, middle and bottom with one turn (+$' + extraPrices['multipoint'] + ')' },
{ id: 'ada', name: 'Low-profile ADA threshold instead of the adjustable sill (+$' + extraPrices['ada'] + ')' },
{ id: 'storm', name: 'Storm door - full-view aluminum with retractable screen (+$' + extraPrices['storm'] + ')' },
{ id: 'kick', name: 'Kick plate (+$' + extraPrices['kick'] + ')' },
{ id: 'viewer', name: 'Door viewer (+$' + extraPrices['viewer'] + ')' }
],
orientation: 'vertical'
});
});
hardwareSection.addRow(row => {
row.addDropdown('size', {
label: 'Door size (width x height)',
tooltip: 'Measure the slab, not the frame, in inches. We confirm the size at the measure',
options: () => {
const list: ControlOption[] = [
{ id: 'measure', name: 'Measure for me' },
{ id: 'custom', name: 'I know the size' }
];
for (const s of commonSizes) {
if (s.double === styleInfo().double) list.push({ id: s.id, name: s.width + ' x ' + s.height + ' in' });
}
return list;
},
defaultValue: 'measure',
isRequired: true
}, '1fr');
row.addInteger('quantity', {
label: 'How many of these doors?',
tooltip: 'E.g. a second one to the garage or the back, in the same style',
min: 1,
max: 4,
defaultValue: 1,
isRequired: true
}, '1fr');
});
const sizeChoice = form.computedValue(() => hardwareSection.dropdown('size')?.value() || 'measure');
const sizeMode = form.computedValue(() => {
const chosen = sizeChoice();
if (chosen === 'custom') return 'custom';
return commonSizes.some(s => s.id === chosen) ? 'common' : 'measure';
});
hardwareSection.addRow(row => {
row.addInteger('width', {
label: 'Width (in)',
min: 24,
max: 96,
defaultValue: 36,
isRequired: true,
isVisible: () => sizeMode() === 'custom'
}, '1fr');
row.addInteger('height', {
label: 'Height (in)',
min: 72,
max: 108,
defaultValue: 80,
isRequired: true,
isVisible: () => sizeMode() === 'custom'
}, '1fr');
});
hardwareSection.addRow(row => {
row.addRadioButton('swing', {
label: 'Swing',
tooltip: 'Outswing stands up better to wind; most homes have an inswing',
options: [
{ id: 'inswing', name: 'Inswing' },
{ id: 'outswing', name: 'Outswing' }
],
defaultValue: 'inswing'
}, '1fr');
row.addRadioButton('hand', {
label: 'Hinge side',
tooltip: 'Stand outside facing the door: hinges on the left is a left-hand door',
options: [
{ id: 'left', name: 'Left-hand' },
{ id: 'right', name: 'Right-hand' },
{ id: 'not-sure', name: 'Not sure' }
],
defaultValue: 'not-sure'
}, '1fr');
});
const typicalWidth = () => styleInfo().double ? 72 : 36;
const typicalHeight = () => 80;
const width = form.computedValue(() => {
const common = commonSizes.find(s => s.id === sizeChoice());
if (common) return common.width;
return sizeMode() === 'custom' ? (hardwareSection.integer('width')?.value() || typicalWidth()) : typicalWidth();
});
const height = form.computedValue(() => {
const common = commonSizes.find(s => s.id === sizeChoice());
if (common) return common.height;
return sizeMode() === 'custom' ? (hardwareSection.integer('height')?.value() || typicalHeight()) : typicalHeight();
});
const sized = form.computedValue(() => sizeMode() !== 'measure');
const oversize = form.computedValue(() => sized() && (width() > typicalWidth() || height() > typicalHeight()));
const quantity = form.computedValue(() => withEntry() ? (hardwareSection.integer('quantity')?.value() || 1) : 0);
// Patio Door Section
const patioSection = form.addSubform('patioDoor', { title: '🌴 Patio Door', isVisible: () => withPatio() });
patioSection.addRow(row => {
row.addRadioButton('patioType', {
label: 'Patio door',
options: [
{ id: 'sliding', name: 'Sliding glass door - one panel slides past a fixed one (from $2,100)' },
{ id: 'french', name: 'French hinged - two leaves that swing open (from $3,100)' },
{ id: 'multislide', name: 'Multi-slide - tall panels that stack to one side (from $9,800)' }
],
defaultValue: 'sliding',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addDropdown('patioWidth', {
label: 'Width',
tooltip: 'Measure the rough opening outside to outside of the old frame, or leave it to the measure',
options: () => (patioWidths[patioType()] || []).map(w => ({ id: w.id, name: w.name + ' (' + money(patioPrices[w.id] || 0) + ')' })),
defaultValue: 'sliding-6',
isRequired: true
}, '1fr');
});
const patioType = form.computedValue(() => patioSection.radioButton('patioType')?.value() || 'sliding');
// The chosen width if the line offers it, else its first: a 16 ft picked on a multi-slide does not follow to a slider
const patioSize = form.computedValue(() => {
const widths = patioWidths[patioType()] || [];
const chosen = patioSection.dropdown('patioWidth')?.value();
return widths.find(w => w.id === chosen) || widths[0] || { id: 'sliding-6', name: '6 ft, 2 panels' };
});
patioSection.addRow(row => {
row.addRadioButton('patioHeight', {
label: 'Height',
options: [
{ id: '80', name: '80 in, standard' },
{ id: '96', name: '96 in, 8 ft (+' + patioTallSurcharge + '%)' }
],
defaultValue: '80'
}, '1fr');
row.addDropdown('patioFrame', {
label: 'Frame material',
options: [
{ id: 'vinyl', name: 'Vinyl (included)' },
{ id: 'fiberglass', name: 'Fiberglass - slimmer, stronger (+' + patioFrameSurcharges['fiberglass'] + '%)' },
{ id: 'aluminum', name: 'Aluminum - thermally broken (+' + patioFrameSurcharges['aluminum'] + '%)' },
{ id: 'wood-clad', name: 'Wood-clad - wood inside (+' + patioFrameSurcharges['wood-clad'] + '%)' }
],
defaultValue: 'vinyl',
isRequired: true
}, '1fr');
});
patioSection.addRow(row => {
row.addCheckboxList('patioOptions', {
label: 'Glass and options (tempered Low-E glass and a screen are included)',
options: [
{ id: 'impact', name: 'Impact-rated glass - no shutters needed (+' + patioImpactSurcharge + '%)' },
{ id: 'blinds', name: 'Blinds between the glass (+$' + patioBlindsPrice + ')' },
{ id: 'grilles', name: 'Grilles between the glass (+$' + patioGrillesPrice + ')' }
],
orientation: 'vertical'
}, '1fr');
row.addInteger('patioQuantity', {
label: 'How many patio doors?',
tooltip: 'Of the same kind and size',
min: 1,
max: 3,
defaultValue: 1,
isRequired: true
}, '1fr');
});
const patioTall = form.computedValue(() => patioSection.radioButton('patioHeight')?.value() === '96');
const patioFrame = form.computedValue(() => patioSection.dropdown('patioFrame')?.value() || 'vinyl');
const patioOptions = form.computedValue(() => patioSection.checkboxList('patioOptions')?.value() || []);
const patioQuantity = form.computedValue(() => withPatio() ? (patioSection.integer('patioQuantity')?.value() || 1) : 0);
// Installation Section
const installSection = form.addSubform('installation', { title: '🔧 Installation' });
installSection.addRow(row => {
row.addRadioButton('work', {
label: 'Who installs?',
options: [
{ id: 'install', name: 'Measure and install' },
{ id: 'supply', name: 'Supply only (+$' + installPrices['delivery'] + ' delivery)' }
],
defaultValue: 'install',
isRequired: true
}, '1fr');
row.addDropdown('timing', {
label: 'When would you like this done?',
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 withInstall = form.computedValue(() => installSection.radioButton('work')?.value() !== 'supply');
installSection.addRow(row => {
row.addCheckbox('haulAway', {
label: () => 'Haul away the old doors (+$' + haulAwayEach() + '/door' + (steelFrame() ? ', includes the steel frame cut-out' : '') + ')',
defaultValue: true,
isVisible: () => withInstall() && replacement()
}, '1fr');
row.addCheckbox('trim', {
label: 'New interior casing and exterior trim (+$' + installPrices['trim'] + '/door)',
defaultValue: false,
isVisible: () => withInstall()
}, '1fr');
});
installSection.addRow(row => {
row.addCheckbox('permit', {
label: 'We pull the building permit and schedule the inspection (+$' + installPrices['permit'] + ')',
defaultValue: true,
isVisible: () => withInstall()
}, '1fr');
row.addDropdown('hoa', {
label: 'Is the home in an HOA?',
tooltip: 'Most HOAs approve the door color and style; we prepare the drawings for the application',
options: [
{ id: 'no', name: 'No HOA' },
{ id: 'yes', name: 'Yes, rules apply' }
],
defaultValue: 'no'
}, '1fr');
});
const inHoa = form.computedValue(() => installSection.dropdown('hoa')?.value() === 'yes');
// Pricing helpers
const haulAwayEach = form.computedValue(() => (installPrices['haul-away'] || 0) + (steelFrame() ? (installPrices['steel-cutout'] || 0) : 0));
const haulAway = form.computedValue(() => withInstall() && replacement() && installSection.checkbox('haulAway')?.value() === true);
const trim = form.computedValue(() => withInstall() && installSection.checkbox('trim')?.value() === true);
const permit = form.computedValue(() => withInstall() && installSection.checkbox('permit')?.value() === true);
const finishCost = form.computedValue(() => finishPrices[finishKind()] || 0);
const sidelitesCost = form.computedValue(() => sidelitesCount() * (sidelitePrices[sideliteGlass()] || 0));
const transomCost = form.computedValue(() => transomPrices[transom()] || 0);
// Every lite pays the glass style and each option once
const glassCost = form.computedValue(() => {
let perLite = glassStylePrices[glassStyle()] || 0;
for (const id of glassOptions()) perLite += glassOptionPrices[id] || 0;
return glassUnits() * perLite;
});
const hardwareCost = form.computedValue(() => {
const handleset = hardwareSection.dropdown('handleset')?.value() || 'nickel';
const lock = hardwareSection.dropdown('lock')?.value() || 'deadbolt';
return (handlesetPrices[handleset] || 0) + (lockPrices[lock] || 0);
});
const extrasCost = form.computedValue(() => {
let total = 0;
for (const id of hardwareSection.checkboxList('extras')?.value() || []) total += extraPrices[id] || 0;
return total;
});
// One entry door: the material price with the style and oversize surcharges, then everything on top
const doorEach = form.computedValue(() => {
const base = materialPrices[material()] || 1650;
const pct = styleInfo().surcharge + (oversize() ? oversizeSurcharge : 0);
return base * (1 + pct / 100) + finishCost() + sidelitesCost() + transomCost() + glassCost() + hardwareCost() + extrasCost();
});
// A pair of doors or an entry with side glass is one wide frame: the heavier install
const wideEntry = form.computedValue(() => styleInfo().double || sidelitesCount() > 0 || transom() !== 'none');
const doorInstallEach = form.computedValue(() => {
if (!withInstall()) return 0;
return (installPrices[wideEntry() ? 'wide-entry' : 'door'] || 0)
+ (haulAway() ? haulAwayEach() : 0)
+ (trim() ? (installPrices['trim'] || 0) : 0);
});
// One patio door: the width price with the frame, height and impact surcharges, then the per-door options
const patioEach = form.computedValue(() => {
const base = patioPrices[patioSize().id] || 0;
const options = patioOptions();
const pct = (patioFrameSurcharges[patioFrame()] || 0)
+ (patioTall() ? patioTallSurcharge : 0)
+ (options.includes('impact') ? patioImpactSurcharge : 0);
const flat = (options.includes('blinds') ? patioBlindsPrice : 0) + (options.includes('grilles') ? patioGrillesPrice : 0);
return base * (1 + pct / 100) + flat;
});
const patioInstallEach = form.computedValue(() => {
if (!withInstall()) return 0;
return (installPrices['patio'] || 0)
+ (haulAway() ? (installPrices['haul-away'] || 0) : 0)
+ (trim() ? (installPrices['trim'] || 0) : 0);
});
const entryCost = form.computedValue(() => doorEach() * quantity());
const entryInstallCost = form.computedValue(() => doorInstallEach() * quantity());
const patioCost = form.computedValue(() => patioEach() * patioQuantity());
const patioInstallCost = form.computedValue(() => patioInstallEach() * patioQuantity());
const permitCost = form.computedValue(() => permit() ? (installPrices['permit'] || 0) : 0);
const deliveryCost = form.computedValue(() => withInstall() ? 0 : (installPrices['delivery'] || 0));
const beforePromo = form.computedValue(() => entryCost() + entryInstallCost() + patioCost() + patioInstallCost() + permitCost() + deliveryCost());
const promoDiscount = form.computedValue(() => beforePromo() * promotion.percent / 100);
const total = form.computedValue(() => Math.round(beforePromo() - promoDiscount()));
const roundTo50 = (amount: number) => Math.round(amount / 50) * 50;
const totalLow = form.computedValue(() => roundTo50(total() * (1 - rangePercent / 100)));
const totalHigh = form.computedValue(() => roundTo50(total() * (1 + rangePercent / 100)));
form.addSpacer({ height: 20, showLine: true, lineStyle: 'dashed' });
// Estimate Section
const estimateSection = form.addSubform('estimate', { title: '💰 Your Estimate', isCollapsible: false });
estimateSection.addRow(row => {
row.addTextPanel('promo', {
computedValue: () => promotion.name + ': ' + promotion.percent + '% off is already included in the estimate below.',
customStyles: { 'font-size': '0.9rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => promotion.percent > 0
});
});
estimateSection.addRow(row => {
row.addPriceDisplay('entryDoors', {
label: () => 'Entry ' + plural(quantity(), 'door', 'doors') + ' (' + styleInfo().name + ', ' + (materialNames[material()] || '').toLowerCase() + ')',
computedValue: () => Math.round(entryCost()),
decimals: 0,
variant: 'default',
isVisible: () => withEntry()
}, '1fr');
row.addPriceDisplay('entryInstall', {
label: 'Entry door installation',
computedValue: () => Math.round(entryInstallCost()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => withEntry() && withInstall()
}, '1fr');
});
estimateSection.addRow(row => {
row.addPriceDisplay('patioDoors', {
label: () => 'Patio ' + plural(patioQuantity(), 'door', 'doors') + ' (' + (patioTypeNames[patioType()] || '').toLowerCase() + ', ' + patioSize().name + ')',
computedValue: () => Math.round(patioCost()),
decimals: 0,
variant: 'default',
isVisible: () => withPatio()
}, '1fr');
row.addPriceDisplay('patioInstall', {
label: 'Patio door installation',
computedValue: () => Math.round(patioInstallCost()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => withPatio() && withInstall()
}, '1fr');
});
estimateSection.addRow(row => {
row.addPriceDisplay('permitFee', {
label: 'Building permit and inspection',
computedValue: () => permitCost(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => permitCost() > 0
}, '1fr');
row.addPriceDisplay('delivery', {
label: 'Delivery (supply only)',
computedValue: () => deliveryCost(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => deliveryCost() > 0
}, '1fr');
row.addPriceDisplay('promoDiscount', {
label: () => promotion.name + ' (-' + promotion.percent + '%)',
computedValue: () => -Math.round(promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => promoDiscount() > 0
}, '1fr');
});
estimateSection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
estimateSection.addRow(row => {
row.addPriceDisplay('total', {
label: 'Estimated cost',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: () => (withInstall() ? 'installed' : 'delivered, supply only') + ', before sales tax'
});
});
estimateSection.addRow(row => {
row.addTextPanel('range', {
computedValue: () => 'Expect ' + money(totalLow()) + ' - ' + money(totalHigh()) + ' after the in-home measure (range of ' + rangePercent + '%). '
+ 'Financing available: 0% APR for 12 months on approved credit.',
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' }
});
});
estimateSection.addRow(row => {
row.addTextPanel('nextSteps', {
computedValue: () => 'What happens next: a consultant calls to go over your request and books a free in-home measure within ' + measureDays
+ ' business days. After the measure you get a firm quote, and installation takes about ' + leadWeeks + ' weeks from your order.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// Sticky Summary Section
const finalSection = form.addSubform('final', {
title: '🧾 Summary',
isCollapsible: false,
sticky: 'bottom'
});
finalSection.addRow(row => {
row.addPriceDisplay('total', {
label: () => 'Estimated cost (' + money(totalLow()) + ' - ' + money(totalHigh()) + ')',
computedValue: () => total(),
decimals: 0,
variant: 'large'
});
});
finalSection.addRow(row => {
row.addTextPanel('disclaimer', {
computedValue: () => 'Estimate before sales tax. A firm quote follows the free in-home measure.',
customStyles: { 'font-size': '0.85rem', 'color': '#94a3b8', 'font-style': 'italic' }
});
});
// Contact Section
const contactSection = form.addSubform('contact', { title: '📋 Request Your Quote' });
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Your Name',
placeholder: 'John Smith',
isRequired: true
}, '1fr');
row.addTextbox('phone', {
label: 'Phone Number',
placeholder: '(555) 123-4567',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addEmail('email', {
label: 'Email Address',
placeholder: 'john@example.com',
isRequired: true
}, '1fr');
row.addTextbox('cityZip', {
label: 'City and ZIP Code',
placeholder: 'Sarasota, 34232',
tooltip: 'Where we come out to measure',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addRadioButton('callTime', {
label: 'Best time to call?',
options: [
{ id: 'any', name: 'Any time' },
{ id: 'morning', name: 'Before noon' },
{ id: 'afternoon', name: 'After noon' }
],
defaultValue: 'any',
orientation: 'horizontal'
});
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else?',
placeholder: 'E.g. steps up to the porch, a gate code, a video doorbell to move, days that work for the measure',
rows: 3,
isRequired: false
});
});
// Descriptions shared by the PDF
const sizeText = (): string => sized() ? width() + ' x ' + height() + ' in' + (oversize() ? ' (oversize)' : '') : 'to be measured';
const describeGlass = (): string => {
if (glassUnits() === 0) return 'no glass';
let text = plural(glassUnits(), 'lite', 'lites') + ': ' + (glassStyleNames[glassStyle()] || '').toLowerCase();
for (const id of glassOptions()) text += ', ' + (glassOptionNames[id] || '').toLowerCase();
return text;
};
const describeHardware = (): string => {
const parts = [
(handlesetNames[hardwareSection.dropdown('handleset')?.value() || 'nickel'] || '').toLowerCase(),
(lockNames[hardwareSection.dropdown('lock')?.value() || 'deadbolt'] || '').toLowerCase()
];
for (const id of hardwareSection.checkboxList('extras')?.value() || []) parts.push((extraNames[id] || '').toLowerCase());
return parts.join(', ');
};
const describePatio = (): string => {
const parts = [
(patioTypeNames[patioType()] || '') + ' ' + patioSize().name + (patioTall() ? ', 96 in tall' : ''),
(patioFrameNames[patioFrame()] || '').toLowerCase() + ' frames'
];
const options = patioOptions();
if (options.includes('impact')) parts.push('impact-rated glass');
if (options.includes('blinds')) parts.push('blinds between the glass');
if (options.includes('grilles')) parts.push('grilles');
return parts.join(', ');
};
const describeInstall = (): string => {
if (!withInstall()) return 'supply only, customer installs';
const parts = ['measure and install'];
if (haulAway()) parts.push(steelFrame() ? 'haul-away with steel frame cut-out' : 'haul-away');
if (trim()) parts.push('new casing and exterior trim');
parts.push(permit() ? 'permit and inspection' : 'permit by the homeowner');
return parts.join(', ');
};
const timingNames: Record<string, string> = { 'asap': 'as soon as possible', 'quarter': 'within 3 months', 'year': 'this year', 'comparing': 'still comparing quotes' };
const handNames: Record<string, string> = { 'left': 'left-hand', 'right': 'right-hand', 'not-sure': 'to confirm at the measure' };
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: withEntry() ? 'entry-door-estimate' : 'patio-door-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the PDF estimate',
header: {
title: withEntry() ? 'Entry door estimate' : 'Patio door estimate',
subtitle: 'Prepared for ' + (contactSection.textbox('name')?.value() || 'you') + ' - ' + money(totalLow()) + ' to ' + money(totalHigh())
},
footer: {
text: 'Estimate only, before sales tax. A firm quote follows the free in-home measure.',
showPageNumbers: true
}
});
if (withEntry()) {
pdf.addSection('Entry door', section => {
section.addRow(row => row
.addField('Style', styleInfo().name)
.addField('Material', materialNames[material()] || '')
.addField('Size', sizeText())
.addField('Quantity', String(quantity())));
section.addRow(row => row
.addField('Finish', finishes[finish()]?.name || '')
.addField('Swing', hardwareSection.radioButton('swing')?.value() || 'inswing')
.addField('Hinge side', handNames[hardwareSection.radioButton('hand')?.value() || 'not-sure'] || ''));
section.addRow(row => row
.addField('Sidelites', sidelitesCount() > 0 ? plural(sidelitesCount(), 'sidelite', 'sidelites') + ', ' + sideliteGlass() : 'none')
.addField('Transom', transomNames[transom()] || '')
.addField('Glass', describeGlass()));
section.addRow(row => row.addField('Hardware and extras', describeHardware()));
});
}
if (withPatio()) {
pdf.addSection('Patio door', section => {
section.addRow(row => row
.addField('Patio door', describePatio())
.addField('Quantity', String(patioQuantity())));
});
}
pdf.addSection('Installation and timing', section => {
section.addRow(row => row
.addField('Project', replacement() ? 'replacement in an existing home' : 'new construction')
.addField('Old frame', replacement() ? (projectSection.dropdown('oldFrame')?.value() || 'not sure') : '-')
.addField('Scope', describeInstall())
.addField('HOA', inHoa() ? 'rules apply' : 'no')
.addField('Timing', timingNames[installSection.dropdown('timing')?.value() || 'quarter'] || ''));
});
pdf.addSection('Estimated cost', section => {
const rows: string[][] = [];
if (withEntry()) {
rows.push(['Entry doors: ' + styleInfo().name + ', ' + (materialNames[material()] || '').toLowerCase(), String(quantity()), money(entryCost())]);
rows.push(['Entry door installation', withInstall() ? String(quantity()) : '', withInstall() ? money(entryInstallCost()) : 'supply only']);
}
if (withPatio()) {
rows.push(['Patio doors: ' + describePatio(), String(patioQuantity()), money(patioCost())]);
rows.push(['Patio door installation', withInstall() ? String(patioQuantity()) : '', withInstall() ? money(patioInstallCost()) : 'supply only']);
}
if (permitCost() > 0) rows.push(['Building permit and inspection', '1', money(permitCost())]);
if (deliveryCost() > 0) rows.push(['Delivery', '1', money(deliveryCost())]);
if (promoDiscount() > 0) rows.push([promotion.name + ' (-' + promotion.percent + '%)', '', '-' + money(promoDiscount())]);
rows.push(['Estimated total, before sales tax', '', money(total())]);
section.addTable(['Item', 'Qty', 'Amount'], rows);
section.addText('Expected range after the measure: ' + money(totalLow()) + ' to ' + money(totalHigh()) + ' (' + rangePercent + '%).');
});
pdf.addSection('What happens next', section => {
section.addText('We call you to go over your request and book your free in-home measure within ' + measureDays + ' business days. '
+ 'After the measure you get a firm quote, and installation takes about ' + leadWeeks + ' weeks from your order.');
const notes = contactSection.textarea('notes')?.value();
if (notes) section.addText('Notes: ' + notes);
section.addDivider();
section.addText('This is an estimate, not a binding offer. Prices are before sales tax and may change after the in-home measure, '
+ 'which confirms sizes, the condition of the opening and the installation. A firm quote in writing follows the measure.');
});
});
form.configureCompletionScreen({
type: 'text',
title: 'Your request is with a consultant',
message: () => 'We will call you ' + (contactSection.radioButton('callTime')?.value() === 'morning' ? 'before noon' : contactSection.radioButton('callTime')?.value() === 'afternoon' ? 'after noon' : 'shortly')
+ ' to go over your door and book your free in-home measure within ' + measureDays + ' business days.'
});
form.configureSubmitButton({
label: 'Request My Quote'
});
}