export function interiorDoorCalculator(form: FormTs) {
// Slab prices: one primed hollow-core 30 x 80 in slab in this style, without jamb, hardware or installation
const stylePrices: Record<string, number> = {
'shaker-2': 165,
'shaker-3': 185,
'panel-5': 195,
'colonial-6': 120,
'craftsman-3': 190,
'flush': 95,
'glass-frosted': 420,
'glass-10-lite': 460,
'louvered': 210
};
const styleNames: Record<string, string> = {
'shaker-2': '2-panel shaker', 'shaker-3': '3-panel shaker', 'panel-5': '5-panel', 'colonial-6': '6-panel colonial',
'craftsman-3': '3-panel craftsman', 'flush': 'Flush', 'glass-frosted': 'French, frosted glass', 'glass-10-lite': 'French, 10-lite clear glass', 'louvered': 'Louvered'
};
// A glass door is solid by construction, so the solid-core surcharge does not apply to it
const solidByConstruction = ['glass-frosted', 'glass-10-lite'];
// Construction surcharge on the slab price (%) and whether it takes stain
const constructions: Record<string, { name: string; surcharge: number; stainable: boolean }> = {
'hollow': { name: 'Hollow-core', surcharge: 0, stainable: false },
'solid-core': { name: 'Solid-core', surcharge: 65, stainable: false },
'veneer': { name: 'Wood veneer', surcharge: 100, stainable: true },
'solid-wood': { name: 'Solid wood', surcharge: 130, stainable: true }
};
const tallSurcharge = 40; // % for 96 in doors instead of 80 in
// Finish per slab: primed is included
const finishes: Record<string, { name: string; price: number; stain: boolean }> = {
'primed': { name: 'Primed, you paint', price: 0, stain: false },
'white': { name: 'White paint', price: 95, stain: false },
'alabaster': { name: 'Alabaster paint', price: 95, stain: false },
'gray': { name: 'Gray paint', price: 95, stain: false },
'black': { name: 'Black paint', price: 95, stain: false },
'natural': { name: 'Natural stain', price: 140, stain: true },
'honey': { name: 'Honey stain', price: 140, stain: true },
'walnut': { name: 'Walnut stain', price: 140, stain: true }
};
// Jambs, casing and slab prep (per door)
const jambPrices: Record<string, number> = { 'std': 85, 'wide': 115, 'not-sure': 85 }; // 4-9/16 in for 2x4 walls, 6-9/16 in for 2x6 walls
const slabPrepPrice = 35; // bore and mortise a slab to the existing jamb
const casingPrices: Record<string, number> = { 'colonial': 45, 'craftsman': 70, 'modern': 55 };
const casingNames: Record<string, string> = { 'colonial': 'Colonial 2-1/4 in', 'craftsman': 'Craftsman 3-1/2 in', 'modern': 'Modern flat 2-1/2 in' };
// Hardware (per door)
const shapePrices: Record<string, number> = { 'knob': 28, 'lever': 42 };
const hardwareFinishes: Record<string, { name: string; premium: boolean }> = {
'nickel': { name: 'Satin nickel', premium: false },
'black': { name: 'Matte black', premium: false },
'chrome': { name: 'Polished chrome', premium: false },
'bronze': { name: 'Oil-rubbed bronze', premium: true },
'brass': { name: 'Polished brass', premium: true }
};
const premiumFinishPrice = 15;
const privacyPrice = 8; // push-button lock for bedrooms and bathrooms
const keyedPrice = 35; // keyed entry set for an office or storage room
// Pocket, barn and closet door kits
const kitPrices: Record<string, number> = { 'pocket': 180, 'barn': 260, 'bifold': 45, 'bypass': 65, 'double': 140 };
// Installation
const installPrices: Record<string, number> = {
'prehung': 140, 'slab': 75, 'pocket': 320, 'barn': 160, 'closet': 110, 'removal': 30, 'delivery': 95
};
const minimumJob = 600;
const packageMinDoors = 8; // whole-house package from this many openings
const packagePercent = 8; // discount on doors, jambs, casing and hardware (not installation)
const promotion = { percent: 0, name: 'Whole-house door sale' }; // 0 hides the banner
const rangePercent = 10; // the estimate is shown as a range of +/- this
const measureDays = 5; // free in-home measure within N business days
const leadWeeks = 4; // order to installation
const SLAB_WIDTH = 30; // an opening is priced in slab equivalents of this width
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: () => 'Interior Door Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addSpacer({ height: 20 });
// Home Section
const homeSection = form.addSubform('home', { title: '🏠 Your Home' });
homeSection.addRow(row => {
row.addDropdown('property', {
label: 'Where are the doors going?',
options: [
{ id: 'house', name: 'Single-family home - replacing the doors I have' },
{ id: 'condo', name: 'Condo or townhome - replacing the doors I have' },
{ id: 'new-build', name: 'New construction - openings framed, no doors yet' }
],
defaultValue: 'house',
isRequired: true
}, '1fr');
row.addRadioButton('job', {
label: 'New frames or just the doors?',
options: [
{ id: 'prehung', name: 'Prehung: doors with new jambs and casing (the usual choice)' },
{ id: 'slab', name: 'Slabs only: new doors hung on the jambs I have' }
],
defaultValue: 'prehung',
orientation: 'vertical',
isRequired: true,
isVisible: () => replacement()
}, '1fr');
});
const property = form.computedValue(() => homeSection.dropdown('property')?.value() || 'house');
const replacement = form.computedValue(() => property() !== 'new-build');
// Slabs only is a replacement job; a new build always gets jambs and casing
const slabsOnly = form.computedValue(() => replacement() && homeSection.radioButton('job')?.value() === 'slab');
homeSection.addRow(row => {
row.addDropdown('wall', {
label: 'How thick are the walls?',
tooltip: 'The jamb width follows the wall: 4-9/16 in for 2x4 walls, 6-9/16 in for 2x6 walls. Not sure? We measure',
options: [
{ id: 'std', name: '2x4 framing (standard, $' + jambPrices['std'] + ' jamb)' },
{ id: 'wide', name: '2x6 framing ($' + jambPrices['wide'] + ' jamb)' },
{ id: 'not-sure', name: 'Not sure' }
],
defaultValue: 'not-sure',
isVisible: () => !slabsOnly()
}, '1fr');
row.addDropdown('oldDoors', {
label: 'What do you have now?',
options: [
{ id: 'hollow-flush', name: 'Hollow flush doors' },
{ id: 'colonial', name: 'Molded 6-panel doors' },
{ id: 'wood', name: 'Solid wood doors' },
{ id: 'mixed', name: 'A mix' },
{ id: 'not-sure', name: 'Not sure' }
],
defaultValue: 'hollow-flush',
isVisible: () => replacement()
}, '1fr');
});
const wall = form.computedValue(() => slabsOnly() ? 'std' : (homeSection.dropdown('wall')?.value() || 'not-sure'));
const jambPrice = () => jambPrices[wall()] || 85;
// Style Section
const styleSection = form.addSubform('style', { title: '🎨 Style for the Whole House' });
styleSection.addRow(row => {
row.addRadioButton('style', {
label: 'Door style (the same style goes on every door)',
options: [
{ id: 'shaker-2', name: '2-panel shaker - two flat panels, square edges (from $165)' },
{ id: 'shaker-3', name: '3-panel shaker - three equal flat panels (from $185)' },
{ id: 'panel-5', name: '5-panel - five horizontal flat panels (from $195)' },
{ id: 'colonial-6', name: '6-panel colonial - classic raised panels (from $120)' },
{ id: 'craftsman-3', name: '3-panel craftsman - two square panels over one tall (from $190)' },
{ id: 'flush', name: 'Flush - smooth slab, no panels (from $95)' },
{ id: 'glass-frosted', name: 'French, frosted glass - one full frosted lite (from $420)' },
{ id: 'glass-10-lite', name: 'French, 10-lite clear glass (from $460)' },
{ id: 'louvered', name: 'Louvered - for closets and laundry (from $210)' }
],
defaultValue: 'shaker-2',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('construction', {
label: 'Construction',
tooltip: 'Solid-core doors are heavier, quieter and feel like wood. A glass door is solid by construction',
options: [
{ id: 'hollow', name: 'Hollow-core - light, primed MDF skin (included)' },
{ id: 'solid-core', name: 'Solid-core - heavy, quiet, primed (+65%)' },
{ id: 'veneer', name: 'Wood veneer - birch or oak, stain-grade (+100%)' },
{ id: 'solid-wood', name: 'Solid wood - pine, poplar or knotty alder (+130%)' }
],
defaultValue: 'hollow',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const style = form.computedValue(() => styleSection.radioButton('style')?.value() || 'shaker-2');
const construction = form.computedValue(() => styleSection.radioButton('construction')?.value() || 'hollow');
const constructionInfo = () => constructions[construction()] || { name: 'Hollow-core', surcharge: 0, stainable: false };
const stainable = form.computedValue(() => constructionInfo().stainable);
styleSection.addRow(row => {
row.addDropdown('finish', {
label: 'Finish',
tooltip: 'Stain shows the grain of a wood veneer or solid wood door; primed is included',
options: () => Object.keys(finishes)
.filter(id => !(finishes[id]?.stain) || stainable())
.map(id => {
const f = finishes[id] || { name: id, price: 0, stain: false };
return { id, name: f.name + (f.price > 0 ? ' (+$' + f.price + '/door)' : ' (included)') };
}),
defaultValue: 'primed',
isRequired: true
}, '1fr');
row.addDropdown('casing', {
label: 'Casing on both sides of every new jamb',
options: [
{ id: 'colonial', name: 'Colonial 2-1/4 in, curved profile (+$' + casingPrices['colonial'] + '/door)' },
{ id: 'craftsman', name: 'Craftsman 3-1/2 in flat with a head (+$' + casingPrices['craftsman'] + '/door)' },
{ id: 'modern', name: 'Modern flat 2-1/2 in square edge (+$' + casingPrices['modern'] + '/door)' }
],
defaultValue: 'colonial',
isRequired: true,
isVisible: () => !slabsOnly()
}, '1fr');
});
// A stain picked on wood does not follow to a hollow-core door
const finish = form.computedValue(() => {
const chosen = styleSection.dropdown('finish')?.value() || 'primed';
return finishes[chosen]?.stain && !stainable() ? 'primed' : chosen;
});
const finishInfo = () => finishes[finish()] || { name: 'Primed', price: 0, stain: false };
const casing = form.computedValue(() => styleSection.dropdown('casing')?.value() || 'colonial');
const casingPrice = () => casingPrices[casing()] || 45;
styleSection.addRow(row => {
row.addTextPanel('slabNote', {
computedValue: () => 'Your casing stays: we bore and mortise every slab to fit your existing jamb ($' + slabPrepPrice + ' per door), so the trim and paint around the openings are untouched.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => slabsOnly()
});
});
styleSection.addRow(row => {
row.addRadioButton('shape', {
label: 'Knob or lever',
tooltip: 'Hinges come in the same finish. Closet doors get matching knobs or pulls with their hardware kit',
options: [
{ id: 'knob', name: 'Knob ($' + shapePrices['knob'] + '/door)' },
{ id: 'lever', name: 'Lever ($' + shapePrices['lever'] + '/door)' }
],
defaultValue: 'lever',
isRequired: true
}, '1fr');
row.addDropdown('hardwareFinish', {
label: 'Hardware finish',
options: Object.keys(hardwareFinishes).map(id => {
const f = hardwareFinishes[id] || { name: id, premium: false };
return { id, name: f.name + (f.premium ? ' (+$' + premiumFinishPrice + '/door)' : ' (included)') };
}),
defaultValue: 'nickel',
isRequired: true
}, '1fr');
});
const shape = form.computedValue(() => styleSection.radioButton('shape')?.value() || 'lever');
const hardwareFinish = form.computedValue(() => styleSection.dropdown('hardwareFinish')?.value() || 'nickel');
const hardwareSetPrice = () => (shapePrices[shape()] || 0) + (hardwareFinishes[hardwareFinish()]?.premium ? premiumFinishPrice : 0);
// Doors Section
const doorsSection = form.addSubform('doors', { title: '🚪 Your Doors' });
doorsSection.addRow(row => {
row.addRadioButton('height', {
label: 'Door height',
tooltip: 'Standard doors are 80 in tall; homes with 9 or 10 ft ceilings often take 96 in',
options: [
{ id: 'standard', name: '80 in (standard)' },
{ id: 'tall', name: '96 in, 8 ft (+' + tallSurcharge + '% on every slab)' }
],
defaultValue: 'standard',
orientation: 'horizontal'
}, '1fr');
row.addDropdown('closetWidth', {
label: 'Closet and double door opening width',
tooltip: 'The finished opening, side to side. We confirm at the measure',
options: [
{ id: '36', name: '36 in' },
{ id: '48', name: '48 in' },
{ id: '60', name: '60 in' },
{ id: '72', name: '72 in' }
],
defaultValue: '60'
}, '1fr');
});
doorsSection.addRow(row => {
row.addInteger('hingedDoors', {
label: 'Hinged doors (bedrooms, bathrooms, offices, hallways)',
min: 0,
max: 30,
defaultValue: 6,
isRequired: true
}, '1fr');
row.addInteger('privacyDoors', {
label: 'Of which with a privacy lock (+$' + privacyPrice + ' each)',
tooltip: 'Bedrooms and bathrooms: a push-button lock inside with an emergency release outside',
min: 0,
max: 30,
defaultValue: 4
}, '1fr');
row.addInteger('keyedDoors', {
label: 'Of which with a keyed lock (+$' + keyedPrice + ' each)',
tooltip: 'An office or a storage room',
min: 0,
max: 30,
defaultValue: 0
}, '1fr');
});
doorsSection.addRow(row => {
row.addInteger('pocketDoors', {
label: 'Pocket doors - slide into the wall (+$' + kitPrices['pocket'] + ' frame kit)',
min: 0,
max: 10,
defaultValue: 0
}, '1fr');
row.addInteger('barnDoors', {
label: 'Barn doors - slide on a track outside (+$' + kitPrices['barn'] + ' hardware kit)',
min: 0,
max: 10,
defaultValue: 0
}, '1fr');
});
doorsSection.addRow(row => {
row.addInteger('bifoldDoors', {
label: 'Bifold closet openings (+$' + kitPrices['bifold'] + ' hardware)',
min: 0,
max: 10,
defaultValue: 2
}, '1fr');
row.addInteger('bypassDoors', {
label: 'Bypass closet openings (+$' + kitPrices['bypass'] + ' hardware)',
min: 0,
max: 10,
defaultValue: 0
}, '1fr');
row.addInteger('doubleDoors', {
label: 'Double doors - a pair that swings (+$' + kitPrices['double'] + ' per pair)',
min: 0,
max: 10,
defaultValue: 0
}, '1fr');
});
const tall = form.computedValue(() => doorsSection.radioButton('height')?.value() === 'tall');
const closetWidth = form.computedValue(() => Number(doorsSection.dropdown('closetWidth')?.value() || '60') || 60);
const count = (id: string) => doorsSection.integer(id)?.value() || 0;
const hingedDoors = form.computedValue(() => count('hingedDoors'));
const privacyDoors = form.computedValue(() => Math.min(count('privacyDoors'), hingedDoors()));
const keyedDoors = form.computedValue(() => Math.min(count('keyedDoors'), hingedDoors() - privacyDoors()));
const pocketDoors = form.computedValue(() => count('pocketDoors'));
const barnDoors = form.computedValue(() => count('barnDoors'));
const bifoldDoors = form.computedValue(() => count('bifoldDoors'));
const bypassDoors = form.computedValue(() => count('bypassDoors'));
const doubleDoors = form.computedValue(() => count('doubleDoors'));
const doorCount = form.computedValue(() => hingedDoors() + pocketDoors() + barnDoors() + bifoldDoors() + bypassDoors() + doubleDoors());
const packageOn = form.computedValue(() => doorCount() >= packageMinDoors && packagePercent > 0);
doorsSection.addRow(row => {
row.addTextPanel('packageInfo', {
computedValue: () => packageOn()
? 'Whole-house package: ' + packagePercent + '% off every door, jamb, casing and hardware set on this order (' + plural(doorCount(), 'opening', 'openings') + ').'
: 'Whole-house package from ' + packageMinDoors + ' doors: add ' + plural(packageMinDoors - doorCount(), 'more door', 'more doors')
+ ' and ' + packagePercent + '% comes off every door, jamb, casing and hardware set.',
customStyles: () => packageOn()
? { 'font-size': '0.9rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' }
: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => packagePercent > 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: 'Delivery only (+$' + installPrices['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('removal', {
label: () => 'Remove and haul away the old doors' + (slabsOnly() ? '' : ', jambs and casing') + ' (+$' + installPrices['removal'] + '/door)',
defaultValue: true,
isVisible: () => withInstall() && replacement()
});
});
const removal = form.computedValue(() => withInstall() && replacement() && installSection.checkbox('removal')?.value() === true);
// Pricing helpers
// The slab: the style price with the construction and height surcharges, times the slab equivalents of the opening
const slabEach = (slabs: number): number => {
const constructionPct = solidByConstruction.includes(style()) && construction() === 'solid-core' ? 0 : constructionInfo().surcharge;
const surcharge = constructionPct + (tall() ? tallSurcharge : 0);
return (stylePrices[style()] || 165) * (1 + surcharge / 100) * slabs + finishInfo().price * slabs;
};
const openingSlabs = () => Math.round(closetWidth() / SLAB_WIDTH * 10) / 10;
const newFrame = () => jambPrice() + casingPrice();
// Jamb and casing of a new frame, or the prep of the slabs to the existing one, plus kits and hardware sets
const frameEach = (type: string): number => {
if (type === 'pocket') return slabsOnly() ? slabPrepPrice : (kitPrices['pocket'] || 0) + casingPrice();
if (type === 'barn') return kitPrices['barn'] || 0;
if (type === 'bifold') return (kitPrices['bifold'] || 0) + (slabsOnly() ? 0 : newFrame());
if (type === 'bypass') return (kitPrices['bypass'] || 0) + (slabsOnly() ? 0 : newFrame());
if (type === 'double') return (kitPrices['double'] || 0) + (slabsOnly() ? 2 * slabPrepPrice : newFrame());
return slabsOnly() ? slabPrepPrice : newFrame();
};
const installEach = (type: string): number => {
if (!withInstall()) return 0;
const haul = removal() ? (installPrices['removal'] || 0) * (type === 'double' ? 2 : 1) : 0;
const hang = slabsOnly() ? (installPrices['slab'] || 0) : (installPrices['prehung'] || 0);
if (type === 'pocket') return (slabsOnly() ? (installPrices['slab'] || 0) : (installPrices['pocket'] || 0)) + haul;
if (type === 'barn') return (installPrices['barn'] || 0) + haul;
if (type === 'bifold' || type === 'bypass') return (installPrices['closet'] || 0) + haul;
if (type === 'double') return 2 * hang + haul;
return hang + haul;
};
const slabsTotal = form.computedValue(() =>
(hingedDoors() + pocketDoors() + barnDoors()) * slabEach(1)
+ (bifoldDoors() + bypassDoors() + doubleDoors()) * slabEach(openingSlabs()));
// Hinged and double doors take a knob or lever set; pocket and barn doors bring a pull in their kit; closet doors come with their kit
const framesTotal = form.computedValue(() =>
hingedDoors() * (frameEach('hinged') + hardwareSetPrice())
+ privacyDoors() * privacyPrice + keyedDoors() * keyedPrice
+ pocketDoors() * frameEach('pocket')
+ barnDoors() * frameEach('barn')
+ bifoldDoors() * frameEach('bifold')
+ bypassDoors() * frameEach('bypass')
+ doubleDoors() * (frameEach('double') + hardwareSetPrice()));
const goodsTotal = form.computedValue(() => slabsTotal() + framesTotal());
const installTotal = form.computedValue(() =>
hingedDoors() * installEach('hinged')
+ pocketDoors() * installEach('pocket')
+ barnDoors() * installEach('barn')
+ bifoldDoors() * installEach('bifold')
+ bypassDoors() * installEach('bypass')
+ doubleDoors() * installEach('double'));
const packageDiscount = form.computedValue(() => packageOn() ? goodsTotal() * packagePercent / 100 : 0);
const deliveryCost = form.computedValue(() => withInstall() ? 0 : (installPrices['delivery'] || 0));
const beforePromo = form.computedValue(() => goodsTotal() - packageDiscount() + installTotal() + deliveryCost());
const promoDiscount = form.computedValue(() => beforePromo() * promotion.percent / 100);
const afterPromo = form.computedValue(() => beforePromo() - promoDiscount());
// The minimum job applies last, after every discount
const minimumApplied = form.computedValue(() => doorCount() > 0 && afterPromo() < minimumJob);
const total = form.computedValue(() => doorCount() > 0 ? Math.round(Math.max(afterPromo(), minimumJob)) : 0);
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('slabs', {
label: () => plural(doorCount(), 'door', 'doors') + ' (' + (styleNames[style()] || '') + ', ' + constructionInfo().name.toLowerCase() + ', ' + finishInfo().name.toLowerCase() + ')',
computedValue: () => Math.round(slabsTotal()),
decimals: 0,
variant: 'default'
}, '1fr');
row.addPriceDisplay('frames', {
label: () => slabsOnly() ? 'Slab prep, hardware kits and lock sets' : 'Jambs, casing, hardware kits and lock sets',
computedValue: () => Math.round(framesTotal()),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
});
estimateSection.addRow(row => {
row.addPriceDisplay('installation', {
label: () => 'Installation' + (removal() ? ' with removal and haul-away' : ''),
computedValue: () => Math.round(installTotal()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => withInstall()
}, '1fr');
row.addPriceDisplay('delivery', {
label: 'Delivery (no installation)',
computedValue: () => deliveryCost(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => deliveryCost() > 0
}, '1fr');
});
estimateSection.addRow(row => {
row.addPriceDisplay('packageDiscount', {
label: 'Whole-house package (-' + packagePercent + '%)',
computedValue: () => -Math.round(packageDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => packageDiscount() > 0
}, '1fr');
row.addPriceDisplay('promoDiscount', {
label: () => promotion.name + ' (-' + promotion.percent + '%)',
computedValue: () => -Math.round(promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => promoDiscount() > 0
}, '1fr');
row.addPriceDisplay('minimum', {
label: 'Minimum job (small jobs are billed at $' + minimumJob + ')',
computedValue: () => Math.round(total() - afterPromo()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => minimumApplied()
}, '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') + ', 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 doors and books a free in-home measure within ' + measureDays
+ ' business days. After the measure you get a firm quote, and we hang the doors 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: () => plural(doorCount(), 'door', 'doors') + ' - 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 Estimate' });
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Your Name',
placeholder: 'John Smith',
isRequired: true
}, '1fr');
row.addTextbox('phone', {
label: 'Phone Number',
placeholder: '(614) 555-0100',
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: 'Columbus, 43207',
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. a door that should differ from the rest, matching baseboards, a door to the garage, days that work for the measure',
rows: 3,
isRequired: false
});
});
// Descriptions shared by the PDF
const describeStyle = (): string => (styleNames[style()] || '') + ', ' + constructionInfo().name.toLowerCase() + ', ' + finishInfo().name.toLowerCase()
+ (slabsOnly() ? ', slabs only' : ', ' + (casingNames[casing()] || '').toLowerCase() + ' casing')
+ ', ' + (hardwareFinishes[hardwareFinish()]?.name || '').toLowerCase() + ' ' + shape() + 's';
const describeInstall = (): string => {
if (!withInstall()) return 'delivery only, sizes to be written down with the consultant';
const parts = ['measure and install', slabsOnly() ? 'slabs hung on the existing jambs' : 'prehung with new casing'];
if (removal()) parts.push('old doors hauled away');
return parts.join(', ');
};
const wallNames: Record<string, string> = { 'std': '2x4 walls, 4-9/16 in jamb', 'wide': '2x6 walls, 6-9/16 in jamb', 'not-sure': 'not sure, jamb confirmed at the measure' };
const propertyNames: Record<string, string> = { 'house': 'single-family home', 'condo': 'condo or townhome', 'new-build': 'new construction or addition' };
const oldDoorNames: Record<string, string> = { 'hollow-flush': 'hollow-core flush doors', 'colonial': 'molded 6-panel doors', 'wood': 'solid wood doors', 'mixed': 'a mix', 'not-sure': 'not sure' };
const timingNames: Record<string, string> = { 'asap': 'as soon as possible', 'quarter': 'within 3 months', 'year': 'this year', 'comparing': 'still comparing quotes' };
const heightText = () => tall() ? '96 in' : '80 in';
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'interior-door-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the PDF estimate',
header: {
title: 'Interior door estimate',
subtitle: plural(doorCount(), 'door', 'doors') + ' - ' + describeStyle()
},
footer: {
text: 'Estimate only, before sales tax. A firm quote follows the free in-home measure.',
showPageNumbers: true
}
});
pdf.addSection('Style for the house', section => {
section.addRow(row => row
.addField('Door style', styleNames[style()] || '')
.addField('Construction', constructionInfo().name)
.addField('Finish', finishInfo().name)
.addField('Height', heightText()));
section.addRow(row => row
.addField('Frames', slabsOnly() ? 'slabs only, existing jambs' : 'prehung, ' + (casingNames[casing()] || '').toLowerCase() + ' casing')
.addField('Jamb', slabsOnly() ? '-' : (wallNames[wall()] || ''))
.addField('Hardware', (hardwareFinishes[hardwareFinish()]?.name || '') + ' ' + shape())
.addField('Home', propertyNames[property()] || ''));
});
pdf.addSection('Doors', section => {
const rows: string[][] = [];
if (hingedDoors() > 0) rows.push(['Hinged doors', String(hingedDoors()), 'slab, ' + heightText() + '; ' + privacyDoors() + ' privacy, ' + keyedDoors() + ' keyed, ' + (hingedDoors() - privacyDoors() - keyedDoors()) + ' passage']);
if (pocketDoors() > 0) rows.push(['Pocket doors', String(pocketDoors()), 'slab, frame kit, recessed pull']);
if (barnDoors() > 0) rows.push(['Barn doors', String(barnDoors()), 'slab, flat track in matte black, pull']);
if (bifoldDoors() > 0) rows.push(['Bifold closet openings', String(bifoldDoors()), closetWidth() + ' in opening, track, pivots and knobs']);
if (bypassDoors() > 0) rows.push(['Bypass closet openings', String(bypassDoors()), closetWidth() + ' in opening, double track and pulls']);
if (doubleDoors() > 0) rows.push(['Double doors', String(doubleDoors()), closetWidth() + ' in opening, flush bolts, ball catch and dummy set']);
section.addTable(['Type', 'Qty', 'Details'], rows);
});
pdf.addSection('Installation and timing', section => {
section.addRow(row => row
.addField('Scope', describeInstall())
.addField('Timing', timingNames[installSection.dropdown('timing')?.value() || 'quarter'] || ''));
if (replacement()) {
section.addRow(row => row
.addField('Current doors', oldDoorNames[homeSection.dropdown('oldDoors')?.value() || 'not-sure'] || '')
.addField('Removal and haul-away', withInstall() ? (removal() ? 'yes' : 'no') : 'delivery only'));
}
});
pdf.addSection('Estimated cost', section => {
const rows: string[][] = [
['Doors: ' + (styleNames[style()] || '').toLowerCase() + ', ' + constructionInfo().name.toLowerCase() + ', ' + finishInfo().name.toLowerCase(), String(doorCount()), money(slabsTotal())],
[slabsOnly() ? 'Slab prep, hardware kits and lock sets' : 'Jambs, casing, hardware kits and lock sets', String(doorCount()), money(framesTotal())]
];
if (packageDiscount() > 0) rows.push(['Whole-house package (-' + packagePercent + '%)', '', '-' + money(packageDiscount())]);
rows.push(['Installation' + (removal() ? ' with removal and haul-away' : ''), withInstall() ? String(doorCount()) : '', withInstall() ? money(installTotal()) : 'delivery only']);
if (deliveryCost() > 0) rows.push(['Delivery', '1', money(deliveryCost())]);
if (promoDiscount() > 0) rows.push([promotion.name + ' (-' + promotion.percent + '%)', '', '-' + money(promoDiscount())]);
if (minimumApplied()) rows.push(['Minimum job', '', money(total() - afterPromo())]);
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 doors and book your free in-home measure within ' + measureDays + ' business days. '
+ 'After the measure you get a firm quote, and we hang the doors 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, wall thickness, swing and the condition of the openings. 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 doors and book your free in-home measure within ' + measureDays + ' business days.'
});
form.configureSubmitButton({
label: 'Request My Estimate'
});
}