export function drivewayGateCalculator(form: FormTs) {
// Gate price per foot of opening by style (fabricated, powder coated or sealed)
const stylePrices: Record<string, number> = {
'iron': 240,
'aluminum': 190,
'wood': 230,
'privacy': 260,
'ranch': 150,
'farm': 55
};
const styleNames: Record<string, string> = {
'iron': 'Wrought iron',
'aluminum': 'Ornamental aluminum',
'wood': 'Wood',
'privacy': 'Aluminum privacy',
'ranch': 'Ranch rail',
'farm': 'Farm tube gate'
};
// Colors each style is coated in; the first two are standard (no charge)
const styleColors: Record<string, string[]> = {
'iron': ['black', 'bronze', 'white', 'charcoal', 'green'],
'aluminum': ['black', 'bronze', 'white', 'charcoal'],
'wood': ['cedar', 'redwood'],
'privacy': ['black', 'bronze', 'charcoal', 'white'],
'ranch': ['black', 'galvanized', 'green', 'charcoal'],
'farm': ['galvanized', 'green', 'black']
};
const colorNames: Record<string, string> = {
'black': 'Black', 'bronze': 'Bronze', 'white': 'White', 'charcoal': 'Charcoal', 'green': 'Hunter green',
'galvanized': 'Galvanized', 'cedar': 'Cedar', 'redwood': 'Redwood'
};
// Percent surcharges on the gate (and on the walk gate and fence where noted)
const surcharges = {
doubleSwing: 8, // two leaves: two sets of hinges, drop rod, center stop
slide: 30, // cantilever frame, roller posts, receiver
tall: 15, // 7 ft and 8 ft gates, walk gate and fence
arch: 12, // rolled top rail
customColor: 8, // a color beyond the two standard ones of the style
redwood: 20 // redwood instead of cedar
};
const extras = {
finialsPerFt: 18, // spear finials, iron and aluminum only
walkGate: 1250, // 4 ft walk gate with latch and post, installed
fencePerFt: 78, // matching fence sections, per linear foot
lantern: 420 // per lantern on a column or post
};
const columnPrices: Record<string, number> = {
'steel': 0,
'stone': 2900,
'brick': 2300,
'stucco': 1900
};
const columnNames: Record<string, string> = {
'steel': 'Steel posts',
'stone': 'Stone columns',
'brick': 'Brick columns',
'stucco': 'Stucco columns'
};
const openerPrices = {
armPerLeaf: 1650,
undergroundPerLeaf: 3400,
slideOperator: 2900,
solarKit: 850,
app: 320,
remote: 45
};
const openerNames: Record<string, string> = {
'none': 'Manual, no opener',
'arm': 'Swing-arm opener',
'underground': 'In-ground opener',
'slide': 'Slide gate operator'
};
const accessPrices: Record<string, number> = {
'keypad': 520,
'intercom': 1450,
'exitLoop': 680,
'knox': 390
};
const accessNames: Record<string, string> = {
'keypad': 'Keypad on a pedestal',
'intercom': 'Video intercom',
'exitLoop': 'Free-exit loop',
'knox': 'Fire department access'
};
const siteWork = {
installGate: 1400,
installOpener: 950,
tearOut: 375,
powerPerFt: 14,
electrician: 350,
slideFooting: 1750,
permitFee: 275
};
// Promotion applied last on the whole job; 0 hides the banner
const promoPercent = 0;
const promoText = 'Free keypad with any opener';
// The range shown to the customer, in percent each way
const rangePercent = 12;
const estimateDays = 3;
const leadTimeWeeks = 5;
const BASE_HEIGHT = 6;
const WALK_GATE_FT = 4;
const SLIDE_RUNOFF = 1.5;
const SINGLE_SWING_MAX_FT = 16;
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 remaining = whole.length - i - 1;
if (remaining > 0 && remaining % 3 === 0) out += ',';
}
return (amount < 0 ? '-$' : '$') + out;
};
const feet = (value: number) => value + ' ft';
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Driveway Gate & Opener Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Pick the gate style, how it opens, the opener and the access control. We come out to measure within ' + estimateDays + ' business days.',
customStyles: { 'font-size': '0.95rem', 'color': '#475569' }
});
});
form.addSpacer({ height: 20 });
const steps = form.addPages('steps', { heightMode: 'current-page' });
// ---- Step 1: the project -------------------------------------------------------
const projectPage = steps.addPage('project');
const projectSection = projectPage.addSubform('project', { title: '🏡 Your Entry', isCollapsible: false });
projectSection.addRow(row => {
row.addRadioButton('projectType', {
label: 'What is the project?',
options: [
{ id: 'new', name: 'New gate at an open driveway' },
{ id: 'replace', name: 'Replace an old gate (we take the old one down)' },
{ id: 'openerOnly', name: 'Opener and access for my existing gate' }
],
defaultValue: 'new',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('propertyKind', {
label: 'Where is the gate?',
options: [
{ id: 'home', name: 'House in town' },
{ id: 'acreage', name: 'Acreage or ranch' }
],
defaultValue: 'home',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const projectType = form.computedValue(() => projectSection.radioButton('projectType')?.value() || 'new');
const replacing = form.computedValue(() => projectType() === 'replace');
// With an opener-only job the gate is the customer's: described for the opener's sake, never priced
const gatePriced = form.computedValue(() => projectType() !== 'openerOnly');
// ---- Step 2: the gate ----------------------------------------------------------
const gatePage = steps.addPage('gate');
const gateSection = gatePage.addSubform('gate', { title: '🚪 The Gate', isCollapsible: false });
gateSection.addRow(row => {
row.addTextPanel('existingHint', {
computedValue: () => 'Describe the gate you have: the style, how it opens and the size tell us which opener carries it. Nothing on this step is charged.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => !gatePriced()
});
});
gateSection.addRow(row => {
row.addRadioButton('style', {
label: () => gatePriced() ? 'Gate style' : 'Closest to your gate',
options: () => Object.keys(stylePrices).map(id => ({
id,
name: (styleNames[id] || id) + (gatePriced() ? ' - from ' + money(stylePrices[id] || 0) + '/ft' : '')
})),
defaultValue: 'iron',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('gateType', {
label: 'How does it open?',
options: [
{ id: 'single', name: 'Single swing (one leaf, up to ' + SINGLE_SWING_MAX_FT + ' ft)' },
{ id: 'double', name: 'Double swing (two leaves, +' + surcharges.doubleSwing + '%)' },
{ id: 'slide', name: 'Cantilever slide gate (+' + surcharges.slide + '%)' }
],
defaultValue: 'double',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const style = form.computedValue(() => {
const raw = gateSection.radioButton('style')?.value();
return raw && stylePrices[raw] !== undefined ? raw : 'iron';
});
const gateType = form.computedValue(() => gateSection.radioButton('gateType')?.value() || 'double');
const sliding = form.computedValue(() => gateType() === 'slide');
const leaves = form.computedValue(() => gateType() === 'double' ? 2 : 1);
const isWood = form.computedValue(() => style() === 'wood');
const hasArch = form.computedValue(() => style() !== 'privacy' && style() !== 'farm');
const hasFinials = form.computedValue(() => style() === 'iron' || style() === 'aluminum');
gateSection.addRow(row => {
row.addDropdown('color', {
label: () => isWood() ? 'Wood' : 'Color',
options: () => (styleColors[style()] || []).map((id, index) => {
let note = 'included';
if (id === 'redwood') note = '+' + surcharges.redwood + '%';
else if (index > 1) note = '+' + surcharges.customColor + '%';
return { id, name: (colorNames[id] || id) + (gatePriced() ? ' (' + note + ')' : '') };
}),
defaultValue: 'black',
isRequired: true
}, '1fr');
row.addDropdown('width', {
label: 'Opening width (between the posts)',
options: () => [10, 12, 14, 16, 20].map(w => ({
id: String(w),
name: feet(w) + (gateType() === 'single' && w > SINGLE_SWING_MAX_FT ? ' (double or slide only)' : '')
})),
defaultValue: '12',
isRequired: true
}, '1fr');
row.addDropdown('height', {
label: 'Height',
options: [4, 5, 6, 7, 8].map(h => ({
id: String(h),
name: feet(h) + (h > BASE_HEIGHT ? ' (+' + surcharges.tall + '%)' : '')
})),
defaultValue: '6',
isRequired: true
}, '1fr');
});
// The chosen color if the style comes in it, else the style's first color
const color = form.computedValue(() => {
const colors = styleColors[style()] || ['black'];
const raw = gateSection.dropdown('color')?.value() || '';
return colors.includes(raw) ? raw : (colors[0] || 'black');
});
const colorPercent = form.computedValue(() => {
const colors = styleColors[style()] || [];
const index = colors.indexOf(color());
if (color() === 'redwood') return surcharges.redwood;
return index > 1 ? surcharges.customColor : 0;
});
// A single swing leaf is offered up to 16 ft: a wider pick falls back to that limit
const width = form.computedValue(() => {
const raw = Number(gateSection.dropdown('width')?.value() || 12);
const max = gateType() === 'single' ? SINGLE_SWING_MAX_FT : 24;
return Math.min(raw || 12, max);
});
const height = form.computedValue(() => Number(gateSection.dropdown('height')?.value() || 6) || 6);
const tall = form.computedValue(() => height() > BASE_HEIGHT);
gateSection.addRow(row => {
row.addRadioButton('top', {
label: 'Top',
options: [
{ id: 'flat', name: 'Flat' },
{ id: 'arch', name: 'Arched (+' + surcharges.arch + '%)' }
],
defaultValue: 'flat',
orientation: 'horizontal',
isVisible: () => hasArch()
}, '1fr');
row.addCheckbox('finials', {
label: 'Spear finials (+' + money(extras.finialsPerFt) + '/ft of gate and fence)',
defaultValue: false,
isVisible: () => hasFinials()
}, '1fr');
});
const arched = form.computedValue(() => hasArch() && gateSection.radioButton('top')?.value() === 'arch');
const finials = form.computedValue(() => hasFinials() && gateSection.checkbox('finials')?.value() === true);
// Walk gate, fence and columns: only when the company builds the gate
const entrySection = gatePage.addSubform('entry', {
title: '🧱 Walk Gate, Fence & Columns',
isCollapsible: false,
isVisible: () => gatePriced()
});
entrySection.addRow(row => {
row.addCheckbox('walkGate', {
label: 'Walk gate beside the drive gate - 4 ft, same style, with a latch (+' + money(extras.walkGate) + ')',
defaultValue: false
}, '1fr');
row.addSlider('fenceFeet', {
label: 'Matching fence sections, both sides together (+' + money(extras.fencePerFt) + '/ft)',
min: 0,
max: 80,
step: 2,
unit: 'ft',
defaultValue: 0
}, '1fr');
});
entrySection.addRow(row => {
row.addRadioButton('columns', {
label: 'What does the gate hang on?',
options: [
{ id: 'steel', name: 'Steel posts in the gate color (included)' },
{ id: 'stone', name: 'Stone veneer columns with a cast stone cap (+' + money(columnPrices['stone'] || 0) + '/column)' },
{ id: 'brick', name: 'Brick columns with a cap (+' + money(columnPrices['brick'] || 0) + '/column)' },
{ id: 'stucco', name: 'Stucco columns to match the house (+' + money(columnPrices['stucco'] || 0) + '/column)' }
],
defaultValue: 'steel',
orientation: 'vertical'
}, '1fr');
row.addRadioButton('ownColumns', {
label: 'Do you already have columns or posts at the entry?',
options: [
{ id: 'no', name: 'No, build new' },
{ id: 'yes', name: 'Yes, hang the gate on mine' }
],
defaultValue: 'no',
orientation: 'vertical'
}, '1fr');
});
entrySection.addRow(row => {
row.addCheckbox('lanterns', {
label: 'Lanterns, one on each gate column or post (+' + money(extras.lantern) + ' each)',
defaultValue: false
});
});
const walkGate = form.computedValue(() => gatePriced() && entrySection.checkbox('walkGate')?.value() === true);
const fenceFeet = form.computedValue(() => gatePriced() ? (entrySection.slider('fenceFeet')?.value() || 0) : 0);
const ownColumns = form.computedValue(() => gatePriced() && entrySection.radioButton('ownColumns')?.value() === 'yes');
const columns = form.computedValue(() => {
if (!gatePriced() || ownColumns()) return 'steel';
return entrySection.radioButton('columns')?.value() || 'steel';
});
const columnCount = form.computedValue(() => columns() !== 'steel' ? 2 + (walkGate() ? 1 : 0) : 0);
const lanterns = form.computedValue(() => gatePriced() && entrySection.checkbox('lanterns')?.value() === true);
// ---- Step 3: the opener and the access control -------------------------------
const accessPage = steps.addPage('access');
const openerSection = accessPage.addSubform('opener', { title: '⚙️ Opener & Access', isCollapsible: false });
openerSection.addRow(row => {
row.addRadioButton('opener', {
label: 'Opener (every opener comes with two remotes, photo eyes and a warning light)',
options: () => {
const options: ControlOption[] = [];
if (gatePriced()) options.push({ id: 'none', name: 'Manual - latch and drop rod, no opener' });
if (sliding()) {
options.push({ id: 'slide', name: 'Slide gate operator, chain drive (+' + money(openerPrices.slideOperator) + ')' });
} else {
options.push({ id: 'arm', name: 'Swing-arm opener, one per leaf (+' + money(openerPrices.armPerLeaf) + '/leaf)' });
options.push({ id: 'underground', name: 'In-ground opener, hidden under the hinge (+' + money(openerPrices.undergroundPerLeaf) + '/leaf)' });
}
return options;
},
defaultValue: 'none',
orientation: 'vertical',
isRequired: true
});
});
// The chosen opener only while it fits the gate type and the project
const opener = form.computedValue(() => {
const raw = openerSection.radioButton('opener')?.value() || 'none';
if (raw === 'none') return 'none';
if (sliding()) return raw === 'slide' ? 'slide' : 'none';
return raw === 'arm' || raw === 'underground' ? raw : 'none';
});
const automated = form.computedValue(() => opener() !== 'none');
openerSection.addRow(row => {
row.addTextPanel('pickOpener', {
computedValue: () => 'For a gate you already have we quote the opener and the access control, so the estimate needs an opener picked above.',
customStyles: { 'font-size': '0.9rem', 'color': '#b45309', 'background': '#fef3c7', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => !gatePriced() && !automated()
});
});
openerSection.addRow(row => {
row.addCheckbox('solar', {
label: 'Solar-powered - a panel on a pole and a battery, no trench (+' + money(openerPrices.solarKit) + ')',
defaultValue: false,
isVisible: () => automated()
}, '1fr');
row.addCheckbox('app', {
label: 'Phone app - open from anywhere, share access (+' + money(openerPrices.app) + ')',
defaultValue: false,
isVisible: () => automated()
}, '1fr');
});
const solar = form.computedValue(() => automated() && openerSection.checkbox('solar')?.value() === true);
const app = form.computedValue(() => automated() && openerSection.checkbox('app')?.value() === true);
openerSection.addRow(row => {
row.addCheckboxList('access', {
label: 'Access control',
options: Object.keys(accessPrices).map(id => ({
id,
name: (accessNames[id] || id) + ' (+' + money(accessPrices[id] || 0) + ')'
})),
orientation: 'vertical',
isVisible: () => automated()
}, '1fr');
row.addInteger('remotes', {
label: 'Extra remotes (two come with the opener, ' + money(openerPrices.remote) + ' each more)',
min: 0,
max: 10,
defaultValue: 0,
isVisible: () => automated()
}, '1fr');
});
const access = form.computedValue(() => automated() ? (openerSection.checkboxList('access')?.value() || []).filter(id => accessPrices[id] !== undefined) : []);
const remotes = form.computedValue(() => automated() ? (openerSection.integer('remotes')?.value() || 0) : 0);
openerSection.addRow(row => {
row.addRadioButton('power', {
label: 'Power at the gate (the opener needs a 120 V circuit)',
options: [
{ id: 'yes', name: 'Within reach' },
{ id: 'no', name: 'Needs a run from the house' },
{ id: 'unsure', name: 'Not sure - check on the site visit' }
],
defaultValue: 'unsure',
orientation: 'vertical',
isVisible: () => automated() && !solar()
}, '1fr');
row.addSlider('powerFeet', {
label: 'Distance from the house to the gate (' + money(siteWork.powerPerFt) + '/ft plus the electrician)',
min: 20,
max: 400,
step: 10,
unit: 'ft',
defaultValue: 80,
isVisible: () => automated() && !solar() && openerSection.radioButton('power')?.value() === 'no'
}, '1fr');
});
const power = form.computedValue(() => automated() && !solar() ? (openerSection.radioButton('power')?.value() || 'unsure') : 'yes');
const powerRun = form.computedValue(() => power() === 'no');
const powerFeet = form.computedValue(() => powerRun() ? (openerSection.slider('powerFeet')?.value() || 80) : 0);
// ---- Step 4: the site ----------------------------------------------------------
const sitePage = steps.addPage('site');
const siteSection = sitePage.addSubform('site', { title: '🏗️ Site & Schedule', isCollapsible: false });
siteSection.addRow(row => {
row.addDropdown('timing', {
label: 'When would you like the gate working?',
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');
row.addCheckbox('tearOut', {
label: 'Tear-out of the old gate and its posts, with haul-away (+' + money(siteWork.tearOut) + ')',
defaultValue: true,
isVisible: () => replacing()
}, '1fr');
});
const tearOut = form.computedValue(() => replacing() && siteSection.checkbox('tearOut')?.value() === true);
siteSection.addRow(row => {
row.addDropdown('surface', {
label: 'What is the driveway at the gate?',
options: [
{ id: 'concrete', name: 'Concrete' },
{ id: 'pavers', name: 'Pavers' },
{ id: 'asphalt', name: 'Asphalt' },
{ id: 'gravel', name: 'Gravel (an exit loop becomes a buried probe)' }
],
defaultValue: 'concrete'
}, '1fr');
row.addDropdown('slope', {
label: 'How does the driveway run at the gate?',
options: [
{ id: 'level', name: 'Level' },
{ id: 'up', name: 'Slopes up into the property' },
{ id: 'down', name: 'Slopes down from the street' }
],
defaultValue: 'level'
}, '1fr');
});
const surface = form.computedValue(() => siteSection.dropdown('surface')?.value() || 'concrete');
const slope = form.computedValue(() => siteSection.dropdown('slope')?.value() || 'level');
siteSection.addRow(row => {
row.addTextPanel('uphillWarning', {
computedValue: () => 'A swing gate on an uphill driveway: leaves swinging into the property hit the rising ground. On the site visit we check the grade; the fixes are uphill hinges, a shorter leaf or a slide gate.',
customStyles: { 'font-size': '0.9rem', 'color': '#b45309', 'background': '#fef3c7', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => slope() === 'up' && !sliding()
});
});
siteSection.addRow(row => {
row.addRadioButton('footing', {
label: () => 'Footing for the slide gate - about ' + feet(Math.round(width() * SLIDE_RUNOFF)) + ' of concrete along the fence line',
options: [
{ id: 'have', name: 'I have one' },
{ id: 'need', name: 'Needs to be poured (+' + money(siteWork.slideFooting) + ')' },
{ id: 'unsure', name: 'Not sure' }
],
defaultValue: 'unsure',
orientation: 'vertical',
isVisible: () => sliding() && gatePriced()
});
});
const footing = form.computedValue(() => sliding() && gatePriced() ? (siteSection.radioButton('footing')?.value() || 'unsure') : 'have');
siteSection.addRow(row => {
row.addRadioButton('hoa', {
label: 'Is the property in an HOA?',
options: [
{ id: 'no', name: 'No HOA' },
{ id: 'yes', name: 'Yes, rules apply' }
],
defaultValue: 'no',
orientation: 'horizontal'
}, '1fr');
row.addTextarea('hoaNotes', {
label: 'HOA notes',
placeholder: 'E.g. 6 ft max, black or bronze only, no solid gates facing the street',
rows: 2,
isVisible: () => siteSection.radioButton('hoa')?.value() === 'yes'
}, '1fr');
});
const inHoa = form.computedValue(() => siteSection.radioButton('hoa')?.value() === 'yes');
// ---- Price logic ---------------------------------------------------------------
// Surcharges that apply to everything in the chosen style: the height and the color
const stylePercent = form.computedValue(() => (tall() ? surcharges.tall : 0) + colorPercent());
const gateCost = form.computedValue(() => {
if (!gatePriced()) return 0;
const typePercent = gateType() === 'double' ? surcharges.doubleSwing : gateType() === 'slide' ? surcharges.slide : 0;
const percent = stylePercent() + typePercent + (arched() ? surcharges.arch : 0);
const base = width() * (stylePrices[style()] || 0) * (1 + percent / 100);
return base + (finials() ? width() * extras.finialsPerFt : 0);
});
const walkGateCost = form.computedValue(() => walkGate()
? extras.walkGate * (1 + stylePercent() / 100) + (finials() ? WALK_GATE_FT * extras.finialsPerFt : 0)
: 0);
const fenceCost = form.computedValue(() => fenceFeet() * (extras.fencePerFt * (1 + stylePercent() / 100) + (finials() ? extras.finialsPerFt : 0)));
const columnsCost = form.computedValue(() => columnCount() * (columnPrices[columns()] || 0));
const lanternsCost = form.computedValue(() => lanterns() ? 2 * extras.lantern : 0);
const openerCost = form.computedValue(() => {
let cost = 0;
if (opener() === 'arm') cost = openerPrices.armPerLeaf * leaves();
else if (opener() === 'underground') cost = openerPrices.undergroundPerLeaf * leaves();
else if (opener() === 'slide') cost = openerPrices.slideOperator;
else return 0;
return cost + (solar() ? openerPrices.solarKit : 0) + (app() ? openerPrices.app : 0);
});
const accessCost = form.computedValue(() => {
let cost = 0;
for (const id of access()) cost += accessPrices[id] || 0;
return cost + remotes() * openerPrices.remote;
});
const powerCost = form.computedValue(() => powerRun() ? powerFeet() * siteWork.powerPerFt + siteWork.electrician : 0);
const siteCost = form.computedValue(() =>
(gatePriced() ? siteWork.installGate : 0)
+ (automated() ? siteWork.installOpener + siteWork.permitFee : 0)
+ (tearOut() ? siteWork.tearOut : 0)
+ powerCost()
+ (footing() === 'need' ? siteWork.slideFooting : 0));
const beforePromo = form.computedValue(() => gateCost() + walkGateCost() + fenceCost() + columnsCost() + lanternsCost() + openerCost() + accessCost() + siteCost());
const promo = form.computedValue(() => beforePromo() * promoPercent / 100);
const price = form.computedValue(() => Math.round(beforePromo() - promo()));
const priceFrom = form.computedValue(() => Math.round(price() * (1 - rangePercent / 100) / 50) * 50);
const priceTo = form.computedValue(() => Math.round(price() * (1 + rangePercent / 100) / 50) * 50);
const describeGate = (): string => {
const typeName = gateType() === 'single' ? 'Single swing' : gateType() === 'double' ? 'Double swing' : 'Slide gate';
const parts = [
typeName + ', ' + (styleNames[style()] || '').toLowerCase() + ', ' + (colorNames[color()] || '').toLowerCase(),
feet(width()) + ' opening, ' + feet(height()) + ' tall',
arched() ? 'arched top' : 'flat top'
];
if (finials()) parts.push('spear finials');
return parts.join(', ');
};
const describeOpener = (): string => {
if (!automated()) return gatePriced() ? 'manual, no opener' : 'not picked yet';
const parts = [(openerNames[opener()] || '').toLowerCase() + (opener() !== 'slide' ? ' x ' + leaves() : '')];
if (solar()) parts.push('solar-powered');
if (app()) parts.push('phone app');
if (remotes() > 0) parts.push(remotes() + ' extra remotes');
return parts.join(', ');
};
const describeAccess = (): string => access().map(id => (accessNames[id] || id).toLowerCase()).join(', ') || 'remotes only';
const describeSite = (): string => {
const slopeText = slope() === 'up' ? 'slopes up into the property' : slope() === 'down' ? 'slopes down from the street' : 'level';
const parts = ['driveway: ' + surface() + ', ' + slopeText];
if (automated()) {
const powerText = power() === 'yes' ? 'within reach' : power() === 'no' ? 'run of about ' + feet(powerFeet()) : 'to check on the site visit';
parts.push('power: ' + (solar() ? 'solar, no run' : powerText));
}
if (sliding() && gatePriced()) parts.push('slide footing: ' + (footing() === 'have' ? 'in place' : footing() === 'need' ? 'to pour' : 'to check'));
if (tearOut()) parts.push('tear-out of the old gate');
parts.push(inHoa() ? 'HOA rules apply' : 'no HOA');
return parts.join('; ');
};
// ---- Step 5: the estimate and contact ------------------------------------------
const quotePage = steps.addPage('quote');
quotePage.addRow(row => {
row.addTextPanel('promoBanner', {
computedValue: () => promoText + ': ' + promoPercent + '% off is already in the range below.',
customStyles: { 'font-size': '0.95rem', 'font-weight': '600', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => promoPercent > 0
});
});
const summarySection = quotePage.addSubform('summary', { title: '💰 Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addPriceDisplay('gateCost', {
label: () => gatePriced() ? 'Gate: ' + (styleNames[style()] || '').toLowerCase() + ', ' + feet(width()) : 'Your gate (existing, not quoted)',
computedValue: () => Math.round(gateCost()),
decimals: 0,
variant: 'default'
}, '1fr');
row.addPriceDisplay('entryCost', {
label: 'Walk gate, fence, columns & lanterns',
computedValue: () => Math.round(walkGateCost() + fenceCost() + columnsCost() + lanternsCost()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => gatePriced()
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('openerCost', {
label: () => 'Opener: ' + describeOpener(),
computedValue: () => Math.round(openerCost()),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
row.addPriceDisplay('accessCost', {
label: () => 'Access control: ' + describeAccess(),
computedValue: () => Math.round(accessCost()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => automated()
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('siteCost', {
label: () => 'Installation & site work' + (automated() ? ' (incl. ' + money(siteWork.permitFee) + ' electrical permit)' : ''),
computedValue: () => Math.round(siteCost()),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
row.addPriceDisplay('promoCost', {
label: () => promoText + ' (-' + promoPercent + '%)',
computedValue: () => -Math.round(promo()),
decimals: 0,
variant: 'success',
isVisible: () => promo() > 0
}, '1fr');
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('estimateFrom', {
label: 'Estimated cost, from',
computedValue: () => priceFrom(),
decimals: 0,
variant: 'large'
}, '1fr');
row.addPriceDisplay('estimateTo', {
label: 'to',
computedValue: () => priceTo(),
decimals: 0,
variant: 'large',
suffix: 'installed'
}, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('nextSteps', {
computedValue: () => 'What happens next: an estimator calls you and comes out within ' + estimateDays
+ ' business days to measure the opening and check the power. After the visit you get a firm quote, and the gate is hung and the opener programmed about '
+ leadTimeWeeks + ' weeks after you sign. The range (±' + rangePercent + '%) covers what the site visit usually turns up.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
const contactSection = quotePage.addSubform('contact', { title: '📋 Your Contact Details', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Name',
placeholder: 'John Smith',
isRequired: true
}, '1fr');
row.addTextbox('phone', {
label: 'Phone',
placeholder: '(555) 123-4567',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('address', {
label: 'Property address (street, city and ZIP)',
placeholder: '1740 W Mission Rd, Escondido, CA 92029',
isRequired: true
}, '1fr');
row.addEmail('email', {
label: 'Email (for the written estimate)',
placeholder: 'john@example.com',
isRequired: false
}, '1fr');
});
contactSection.addRow(row => {
row.addRadioButton('callTime', {
label: 'Best time to call',
options: [
{ id: 'anytime', name: 'Anytime' },
{ id: 'morning', name: 'Mornings' },
{ id: 'afternoon', name: 'Afternoons' }
],
defaultValue: 'anytime',
orientation: 'horizontal'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else?',
placeholder: 'E.g. a gate code for the estimator, a second driveway, an old opener to keep, days that work for the visit',
rows: 3,
isRequired: false
});
});
// Sticky estimate, visible on every step
const finalSection = form.addSubform('final', {
title: '🧾 Summary',
isCollapsible: false,
sticky: 'bottom'
});
finalSection.addRow(row => {
row.addPriceDisplay('totalFrom', {
label: 'Estimated cost, from',
computedValue: () => priceFrom(),
decimals: 0,
variant: 'large'
}, '1fr');
row.addPriceDisplay('totalTo', {
label: 'to',
computedValue: () => priceTo(),
decimals: 0,
variant: 'large'
}, '1fr');
});
finalSection.addRow(row => {
row.addTextPanel('disclaimer', {
computedValue: () => 'Estimated cost, installed. The firm quote follows the free site visit.',
customStyles: { 'font-size': '0.85rem', 'color': '#94a3b8', 'font-style': 'italic' }
});
});
const whenWeCall = form.computedValue(() => {
const when = contactSection.radioButton('callTime')?.value();
return when === 'morning' ? 'in the morning' : when === 'afternoon' ? 'in the afternoon' : 'shortly';
});
// ---- PDF estimate ----------------------------------------------------------------
form.configurePdf('estimate', pdf => {
const yes = (value: boolean) => value ? 'yes' : 'no';
const timingNames: Record<string, string> = { asap: 'as soon as possible', quarter: 'within 3 months', year: 'this year', comparing: 'still comparing quotes' };
const typeName = gateType() === 'single' ? 'Single swing' : gateType() === 'double' ? 'Double swing' : 'Slide gate';
pdf.configure({
filename: 'gate-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the estimate (PDF)',
header: {
title: gatePriced() ? 'Driveway gate estimate' : 'Gate opener estimate',
subtitle: typeName + ' - ' + (styleNames[style()] || '').toLowerCase() + ' - ' + feet(width()) + (automated() ? ' - ' + (openerNames[opener()] || '').toLowerCase() : '')
},
footer: {
text: 'Estimated cost. The firm quote follows the free site visit.',
showPageNumbers: true
}
});
pdf.addSection('Customer', section => {
section.addRow(row => row
.addField('Name', contactSection.textbox('name')?.value() || '')
.addField('Phone', contactSection.textbox('phone')?.value() || '')
.addField('Email', contactSection.email('email')?.value() || '')
.addField('Property address', contactSection.textbox('address')?.value() || ''));
});
pdf.addSection(gatePriced() ? 'The gate' : 'Your existing gate', section => {
section.addRow(row => row
.addField('Style', styleNames[style()] || '')
.addField('Opens', typeName)
.addField('Color', colorNames[color()] || '')
.addField('Size', feet(width()) + ' opening, ' + feet(height()) + ' tall'));
section.addRow(row => row
.addField('Top', arched() ? 'arched' : 'flat')
.addField('Finials', yes(finials()))
.addField('Walk gate', gatePriced() ? yes(walkGate()) : 'n/a')
.addField('Matching fence', gatePriced() && fenceFeet() > 0 ? feet(fenceFeet()) : 'none'));
if (gatePriced()) {
section.addRow(row => row
.addField('Columns', ownColumns() ? 'the customer\'s own' : (columnNames[columns()] || '') + (columnCount() > 0 ? ' x ' + columnCount() : ''))
.addField('Lanterns', yes(lanterns())));
}
if (sliding()) {
section.addRow(row => row.addField('Run-off for the slide gate', 'about ' + feet(Math.round(width() * SLIDE_RUNOFF)) + ' of clear fence line beside the opening'));
}
});
pdf.addSection('Opener and access', section => {
section.addRow(row => row
.addField('Opener', describeOpener())
.addField('Power', automated() ? (solar() ? 'solar kit' : power() === 'yes' ? 'within reach' : power() === 'no' ? 'power run needed' : 'to check on the site visit') : 'n/a')
.addField('Remotes', automated() ? String(2 + remotes()) : 'n/a'));
section.addRow(row => row
.addField('Access control', automated() ? describeAccess() : 'n/a')
.addField('Safety', automated() ? 'photo eyes and warning light, UL 325' : 'n/a'));
});
pdf.addSection('Site and schedule', section => {
const timing = siteSection.dropdown('timing')?.value() || 'quarter';
section.addRow(row => row
.addField('Project', projectType() === 'new' ? 'new gate at an open driveway' : projectType() === 'replace' ? 'replacing an old gate' : 'opener and access for an existing gate')
.addField('Property', projectSection.radioButton('propertyKind')?.value() === 'acreage' ? 'acreage or ranch' : 'house in town')
.addField('Timing', timingNames[timing] || ''));
section.addRow(row => row
.addField('Site', describeSite())
.addField('HOA', inHoa() ? (siteSection.textarea('hoaNotes')?.value() || 'rules apply') : 'no HOA'));
});
pdf.addSection('Estimated cost', section => {
const rows: string[][] = [];
if (gatePriced()) rows.push(['Gate: ' + describeGate(), money(gateCost())]);
if (walkGateCost() > 0) rows.push(['Walk gate, 4 ft', money(walkGateCost())]);
if (fenceCost() > 0) rows.push(['Matching fence, ' + feet(fenceFeet()), money(fenceCost())]);
if (columnsCost() > 0) rows.push([(columnNames[columns()] || '') + ' x ' + columnCount(), money(columnsCost())]);
if (lanternsCost() > 0) rows.push(['Lanterns x 2', money(lanternsCost())]);
if (automated()) rows.push(['Opener: ' + describeOpener(), money(openerCost())]);
if (accessCost() > 0) rows.push(['Access control: ' + describeAccess(), money(accessCost())]);
rows.push(['Installation and site work: ' + describeSite(), money(siteCost())]);
if (promo() > 0) rows.push([promoText + ' (-' + promoPercent + '%)', '-' + money(promo())]);
section.addTable(['Item', 'Amount'], rows);
section.addSpacer(6);
section.addText('Estimated: ' + money(priceFrom()) + ' - ' + money(priceTo()) + ' installed, range ±' + rangePercent + '%; firm quote after the site visit.');
});
pdf.addSection('What happens next', section => {
section.addText('We call you ' + whenWeCall() + ' to set up the free site visit. The estimator comes out within ' + estimateDays
+ ' business days, measures the opening and checks the power and the grade. You get a firm quote after the visit; the gate is hung and the opener programmed about '
+ leadTimeWeeks + ' weeks after you sign.');
});
const notes = contactSection.textarea('notes')?.value();
if (notes) {
pdf.addSection('Notes', section => section.addText(notes));
}
pdf.addSection('Terms', section => {
section.addText('Prices include materials, labor and standard footings; sales tax not included. The cost is an estimate, not an offer to contract; the firm price is set after the free site visit. Every automated gate is installed to UL 325 with photo eyes and a warning light. Permit and HOA approval, where required, are the owner\'s responsibility unless agreed otherwise.');
});
});
form.configureSubmitButton({
label: 'Request My Quote'
});
form.configureCompletionScreen({
type: 'text',
title: 'Your request is with our estimator',
message: () => 'We will call ' + whenWeCall() + ' to set up the free site visit within ' + estimateDays + ' business days.'
});
}