export function fenceInstallationCalculator(form: FormTs) {
// Base prices per linear foot by material (includes labor)
const materialPrices: Record<string, number> = {
'chain-link': 15,
'wood-privacy': 25,
'wood-picket': 20,
'vinyl': 30,
'aluminum': 35,
'wrought-iron': 50,
'composite': 45,
'bamboo': 22
};
// Height multipliers
const heightMultipliers: Record<string, number> = {
'3ft': 0.7,
'4ft': 0.85,
'5ft': 1.0,
'6ft': 1.15,
'8ft': 1.4
};
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Fence Installation Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addSpacer({ height: 20 });
// Service Location Section
const locationSection = form.addSubform('serviceLocation', { title: '๐ Installation Location' });
locationSection.addRow(row => {
row.addAddress('propertyAddress', {
label: 'Property Address',
placeholder: 'Enter your property address...',
showMap: true,
showDistance: true,
referenceAddress: {
formattedAddress: 'Service Center, Denver, CO',
coordinates: { lat: 39.7392, lng: -104.9903 }
},
restrictToCountries: ['US', 'CA'],
distanceUnit: 'miles',
isRequired: true
});
});
locationSection.addRow(row => {
row.addTextPanel('serviceAreaInfo', {
computedValue: () => {
const addressField = locationSection.address('propertyAddress');
const miles = addressField?.distance();
if (miles == null) return '๐ Enter address to check service area';
if (miles <= 25) return '๐ Within service area - No travel fee';
if (miles <= 50) return '๐ Extended area - $100 travel fee';
if (miles <= 75) return '๐ Remote area - $200 travel fee';
return '๐ Outside service area - Please call for availability';
},
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// Project Details Section
const projectSection = form.addSubform('projectDetails', { title: '๐ Project Details' });
projectSection.addRow(row => {
row.addInteger('linearFeet', {
label: 'Total Linear Feet',
min: 20,
max: 2000,
defaultValue: 150,
placeholder: 'e.g. 150',
isRequired: true
}, '1fr');
row.addDropdown('projectType', {
label: 'Project Type',
options: [
{ id: 'new', name: 'New Installation' },
{ id: 'replacement', name: 'Replacement (includes removal, +$5/ft)' },
{ id: 'extension', name: 'Extension to Existing' }
],
defaultValue: 'new',
isRequired: true
}, '1fr');
});
// Fence Material Section
const materialSection = form.addSubform('materialDetails', { title: '๐ชต Fence Material' });
materialSection.addRow(row => {
row.addRadioButton('material', {
label: 'Fence Material',
options: [
{ id: 'chain-link', name: 'Chain Link - $15/ft (Budget-friendly)' },
{ id: 'wood-privacy', name: 'Wood Privacy - $25/ft (Most popular)' },
{ id: 'wood-picket', name: 'Wood Picket - $20/ft (Classic look)' },
{ id: 'vinyl', name: 'Vinyl/PVC - $30/ft (Low maintenance)' },
{ id: 'aluminum', name: 'Aluminum - $35/ft (Durable)' },
{ id: 'wrought-iron', name: 'Wrought Iron - $50/ft (Premium)' },
{ id: 'composite', name: 'Composite - $45/ft (Eco-friendly)' },
{ id: 'bamboo', name: 'Bamboo - $22/ft (Natural look)' }
],
defaultValue: 'wood-privacy',
orientation: 'vertical',
isRequired: true
});
});
materialSection.addRow(row => {
row.addDropdown('height', {
label: 'Fence Height',
options: [
{ id: '3ft', name: '3 feet (-30%)' },
{ id: '4ft', name: '4 feet (-15%)' },
{ id: '5ft', name: '5 feet (Standard)' },
{ id: '6ft', name: '6 feet (+15%)' },
{ id: '8ft', name: '8 feet (+40%)' }
],
defaultValue: '6ft',
isRequired: true
}, '1fr');
row.addDropdown('style', {
label: 'Fence Style',
options: [
{ id: 'standard', name: 'Standard' },
{ id: 'decorative', name: 'Decorative Top (+10%)' },
{ id: 'lattice', name: 'Lattice Top (+15%)' },
{ id: 'shadowbox', name: 'Shadowbox (+12%)' }
],
defaultValue: 'standard',
isVisible: () => {
const material = materialSection.radioButton('material')?.value();
return material === 'wood-privacy' || material === 'vinyl' || material === 'composite';
}
}, '1fr');
});
// Site Conditions Section
const siteSection = form.addSubform('siteConditions', { title: '๐๏ธ Site Conditions' });
siteSection.addRow(row => {
row.addDropdown('terrain', {
label: 'Terrain Type',
options: [
{ id: 'flat', name: 'Flat/Level Ground' },
{ id: 'slight-slope', name: 'Slight Slope (+10%)' },
{ id: 'moderate-slope', name: 'Moderate Slope (+20%)' },
{ id: 'steep', name: 'Steep Slope (+35%)' }
],
defaultValue: 'flat'
}, '1fr');
row.addDropdown('soilType', {
label: 'Soil Type',
options: [
{ id: 'normal', name: 'Normal Soil' },
{ id: 'clay', name: 'Clay (+10%)' },
{ id: 'rocky', name: 'Rocky (+25%)' },
{ id: 'sandy', name: 'Sandy' }
],
defaultValue: 'normal'
}, '1fr');
});
siteSection.addRow(row => {
row.addDropdown('accessibility', {
label: 'Site Accessibility',
options: [
{ id: 'easy', name: 'Easy Access' },
{ id: 'moderate', name: 'Moderate (Side yard, +10%)' },
{ id: 'difficult', name: 'Difficult (No vehicle access, +20%)' }
],
defaultValue: 'easy'
}, '1fr');
});
// Gates & Add-ons Section
const addonsSection = form.addSubform('addons', { title: '๐ช Gates & Options' });
addonsSection.addRow(row => {
row.addInteger('walkGates', {
label: 'Walk Gates (3-4ft wide)',
min: 0,
max: 10,
defaultValue: 1
}, '1fr');
row.addInteger('driveGates', {
label: 'Drive Gates (10-12ft wide)',
min: 0,
max: 5,
defaultValue: 0
}, '1fr');
});
addonsSection.addRow(row => {
row.addCheckbox('gateHardware', {
label: 'Premium Gate Hardware (+$75/gate)',
defaultValue: false
}, '1fr');
row.addCheckbox('autoGate', {
label: 'Automatic Gate Opener (+$800)',
defaultValue: false,
isVisible: () => (addonsSection.integer('driveGates')?.value() || 0) > 0
}, '1fr');
});
addonsSection.addRow(row => {
row.addCheckbox('postCaps', {
label: 'Decorative Post Caps (+$8/post)',
defaultValue: false
}, '1fr');
row.addCheckbox('staining', {
label: 'Staining/Sealing (+$3/ft)',
defaultValue: false,
isVisible: () => {
const material = materialSection.radioButton('material')?.value();
return material === 'wood-privacy' || material === 'wood-picket' || material === 'bamboo';
}
}, '1fr');
});
addonsSection.addRow(row => {
row.addCheckbox('permitHandling', {
label: 'Permit Handling (+$150)',
defaultValue: false
}, '1fr');
row.addCheckbox('surveying', {
label: 'Property Line Survey (+$300)',
defaultValue: false
}, '1fr');
});
addonsSection.addRow(row => {
row.addCheckbox('concreteFootings', {
label: 'Concrete Post Footings (+$15/post)',
defaultValue: true
}, '1fr');
});
form.addSpacer({ height: 20, showLine: true, lineStyle: 'dashed' });
// Helper to calculate travel fee
const getTravelFee = () => {
const addressField = locationSection.address('propertyAddress');
const miles = addressField?.distance();
if (miles == null || miles <= 25) return 0;
if (miles <= 50) return 100;
if (miles <= 75) return 200;
return 300;
};
// Quote Summary Section
const summarySection = form.addSubform('summary', { title: '๐ฐ Your Estimate', isCollapsible: false });
const getHeightMultiplier = () => {
const height = materialSection.dropdown('height')?.value() || '6ft';
return heightMultipliers[height] || 1.0;
};
const getStyleMultiplier = () => {
const style = materialSection.dropdown('style')?.value() || 'standard';
const multipliers: Record<string, number> = {
'standard': 1.0,
'decorative': 1.1,
'lattice': 1.15,
'shadowbox': 1.12
};
return multipliers[style] || 1.0;
};
const getTerrainMultiplier = () => {
const terrain = siteSection.dropdown('terrain')?.value() || 'flat';
const multipliers: Record<string, number> = {
'flat': 1.0,
'slight-slope': 1.1,
'moderate-slope': 1.2,
'steep': 1.35
};
return multipliers[terrain] || 1.0;
};
const getSoilMultiplier = () => {
const soil = siteSection.dropdown('soilType')?.value() || 'normal';
const multipliers: Record<string, number> = {
'normal': 1.0,
'clay': 1.1,
'rocky': 1.25,
'sandy': 1.0
};
return multipliers[soil] || 1.0;
};
const getAccessMultiplier = () => {
const access = siteSection.dropdown('accessibility')?.value() || 'easy';
const multipliers: Record<string, number> = {
'easy': 1.0,
'moderate': 1.1,
'difficult': 1.2
};
return multipliers[access] || 1.0;
};
// Calculate estimated post count (roughly 1 post per 8 feet)
const getPostCount = () => {
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
return Math.ceil(linearFeet / 8) + 1;
};
summarySection.addRow(row => {
row.addPriceDisplay('materialLaborCost', {
label: 'Materials & Installation',
computedValue: () => {
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
const material = materialSection.radioButton('material')?.value() || 'wood-privacy';
const pricePerFoot = materialPrices[material] || 25;
const baseCost = linearFeet * pricePerFoot;
const totalMultiplier = getHeightMultiplier() * getStyleMultiplier() * getTerrainMultiplier() * getSoilMultiplier() * getAccessMultiplier();
return Math.round(baseCost * totalMultiplier);
},
variant: 'default'
}, '1fr');
row.addPriceDisplay('removalCost', {
label: 'Old Fence Removal',
computedValue: () => {
const projectType = projectSection.dropdown('projectType')?.value() || 'new';
if (projectType !== 'replacement') return 0;
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
return linearFeet * 5;
},
variant: 'default',
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('gatesCost', {
label: 'Gates',
computedValue: () => {
const material = materialSection.radioButton('material')?.value() || 'wood-privacy';
const pricePerFoot = materialPrices[material] || 25;
const walkGates = addonsSection.integer('walkGates')?.value() || 0;
const driveGates = addonsSection.integer('driveGates')?.value() || 0;
// Walk gate = ~4ft equivalent but at 3x material cost
// Drive gate = ~12ft equivalent but at 3x material cost
const walkGateCost = walkGates * (pricePerFoot * 4 * 2.5);
const driveGateCost = driveGates * (pricePerFoot * 12 * 2.5);
let gateHardware = 0;
if (addonsSection.checkbox('gateHardware')?.value()) {
gateHardware = (walkGates + driveGates) * 75;
}
let autoGate = 0;
if (addonsSection.checkbox('autoGate')?.value() && driveGates > 0) {
autoGate = 800;
}
return Math.round(walkGateCost + driveGateCost + gateHardware + autoGate);
},
variant: 'default',
prefix: '+'
}, '1fr');
row.addPriceDisplay('addonsCost', {
label: 'Additional Options',
computedValue: () => {
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
const postCount = getPostCount();
let addons = 0;
if (addonsSection.checkbox('postCaps')?.value()) addons += postCount * 8;
if (addonsSection.checkbox('staining')?.value()) addons += linearFeet * 3;
if (addonsSection.checkbox('permitHandling')?.value()) addons += 150;
if (addonsSection.checkbox('surveying')?.value()) addons += 300;
if (addonsSection.checkbox('concreteFootings')?.value()) addons += postCount * 15;
return addons;
},
variant: 'default',
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('travelFee', {
label: 'Travel Fee',
computedValue: () => getTravelFee(),
variant: 'default',
prefix: '+',
isVisible: () => getTravelFee() > 0
});
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('totalEstimate', {
label: 'Total Estimated Cost',
computedValue: () => {
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
const material = materialSection.radioButton('material')?.value() || 'wood-privacy';
const pricePerFoot = materialPrices[material] || 25;
const projectType = projectSection.dropdown('projectType')?.value() || 'new';
const postCount = getPostCount();
// Base material & labor
const baseCost = linearFeet * pricePerFoot;
const totalMultiplier = getHeightMultiplier() * getStyleMultiplier() * getTerrainMultiplier() * getSoilMultiplier() * getAccessMultiplier();
let total = baseCost * totalMultiplier;
// Old fence removal
if (projectType === 'replacement') {
total += linearFeet * 5;
}
// Gates
const walkGates = addonsSection.integer('walkGates')?.value() || 0;
const driveGates = addonsSection.integer('driveGates')?.value() || 0;
total += walkGates * (pricePerFoot * 4 * 2.5);
total += driveGates * (pricePerFoot * 12 * 2.5);
if (addonsSection.checkbox('gateHardware')?.value()) {
total += (walkGates + driveGates) * 75;
}
if (addonsSection.checkbox('autoGate')?.value() && driveGates > 0) {
total += 800;
}
// Add-ons
if (addonsSection.checkbox('postCaps')?.value()) total += postCount * 8;
if (addonsSection.checkbox('staining')?.value()) total += linearFeet * 3;
if (addonsSection.checkbox('permitHandling')?.value()) total += 150;
if (addonsSection.checkbox('surveying')?.value()) total += 300;
if (addonsSection.checkbox('concreteFootings')?.value()) total += postCount * 15;
const travelFee = getTravelFee();
return Math.round(total + travelFee);
},
variant: 'large'
});
});
summarySection.addRow(row => {
row.addTextPanel('perFootCost', {
computedValue: () => {
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
const material = materialSection.radioButton('material')?.value() || 'wood-privacy';
const pricePerFoot = materialPrices[material] || 25;
const totalMultiplier = getHeightMultiplier() * getStyleMultiplier() * getTerrainMultiplier() * getSoilMultiplier() * getAccessMultiplier();
const effectivePerFoot = Math.round(pricePerFoot * totalMultiplier);
return `Effective cost: $${effectivePerFoot}/linear foot (fence only)`;
},
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' }
});
});
// Sticky Summary Section
const finalSection = form.addSubform('final', {
title: '๐งพ Summary',
isCollapsible: false,
sticky: 'bottom'
});
finalSection.addRow(row => {
row.addPriceDisplay('totalEstimate', {
label: 'Total Estimated Cost',
computedValue: () => {
const linearFeet = projectSection.integer('linearFeet')?.value() || 150;
const material = materialSection.radioButton('material')?.value() || 'wood-privacy';
const pricePerFoot = materialPrices[material] || 25;
const projectType = projectSection.dropdown('projectType')?.value() || 'new';
const postCount = getPostCount();
const baseCost = linearFeet * pricePerFoot;
const totalMultiplier = getHeightMultiplier() * getStyleMultiplier() * getTerrainMultiplier() * getSoilMultiplier() * getAccessMultiplier();
let total = baseCost * totalMultiplier;
if (projectType === 'replacement') total += linearFeet * 5;
const walkGates = addonsSection.integer('walkGates')?.value() || 0;
const driveGates = addonsSection.integer('driveGates')?.value() || 0;
total += walkGates * (pricePerFoot * 4 * 2.5);
total += driveGates * (pricePerFoot * 12 * 2.5);
if (addonsSection.checkbox('gateHardware')?.value()) total += (walkGates + driveGates) * 75;
if (addonsSection.checkbox('autoGate')?.value() && driveGates > 0) total += 800;
if (addonsSection.checkbox('postCaps')?.value()) total += postCount * 8;
if (addonsSection.checkbox('staining')?.value()) total += linearFeet * 3;
if (addonsSection.checkbox('permitHandling')?.value()) total += 150;
if (addonsSection.checkbox('surveying')?.value()) total += 300;
if (addonsSection.checkbox('concreteFootings')?.value()) total += postCount * 15;
const travelFee = getTravelFee();
return Math.round(total + travelFee);
},
variant: 'large'
});
});
finalSection.addRow(row => {
row.addTextPanel('disclaimer', {
computedValue: () => 'Final price confirmed after on-site measurement.',
customStyles: { 'font-size': '0.85rem', 'color': '#94a3b8', 'font-style': 'italic' }
});
});
form.configureSubmitButton({
label: 'Get Free Estimate'
});
}