export function retractableAwningCalculator(form: FormTs) {
// Awning price per square foot of coverage (width x projection), with the fabric and the arms
const modelRates: Record<string, number> = {
'cassette': 48, // full cassette: rolled in, the fabric and the arms disappear into a closed housing
'semi': 40, // semi-cassette: a hood over the roller, the fabric and the arms show underneath
'open': 32 // open: a bare roller on a torque bar, best under an eave
};
// The model caps the width, and the width caps the projection: the arms fold side by side
const modelMaxWidth: Record<string, number> = { 'cassette': 24, 'semi': 24, 'open': 20 };
const modelMaxProjection: Record<string, number> = { 'cassette': 13, 'semi': 13, 'open': 10 };
const armRoomFt = 1.5;
const minimumSqft = 80; // below this coverage the motor, the roller and the arms cost the same
const windRatingMph = 25; // the awning must be retracted above this wind when extended
// Options
const motorPrice = 900; // motor in the roller with a handheld remote
const windSensorPrice = 450; // add-on to the motor: retracts by itself in a gust
const sunSensorPrice = 300; // add-on to the motor: extends in sun, retracts at dusk
const smartHubPrice = 350; // add-on to the motor: phone app, schedules, voice assistants
const ledPrice = 850; // dimmable LED strip under the housing and in the front bar
// Installation
const installPrice = 650; // one awning on the wall, lag bolts into the framing or anchors into masonry
const mountSurcharge: Record<string, number> = { 'wall': 0, 'soffit': 250, 'roof': 450 };
const wallSurcharge: Record<string, number> = { 'wood': 0, 'vinyl': 220, 'masonry': 0, 'eifs': 480, 'unknown': 0 };
const permitPrice = 350; // city or county permit with the attachment details and the inspection
const hoaWeeks = 3; // HOA architectural review, on the schedule only
const freeMiles = 40;
const milePrice = 2.5; // for every mile one way beyond the free radius
// Promotion, range and schedule
const promoPct = 0; // discount on the whole order; 0 hides the banner
const promoText = 'Spring shade sale';
const rangePct = 10; // the estimate is shown as a range of +/- this percent
const leadWeeks = 5; // from the measurement to the installation
const siteVisitDays = 5;
const validityDays = 30;
const modelNames: Record<string, string> = { 'cassette': 'Full cassette', 'semi': 'Semi-cassette', 'open': 'Open' };
const facingBearing: Record<string, number> = { 'south': 180, 'west': 270, 'east': 90, 'north': 0 };
const facingNames: Record<string, string> = { 'south': 'South', 'west': 'West', 'east': 'East', 'north': 'North' };
const timeHours: Record<string, number> = { 'morning': 10, 'midday': 13, 'afternoon': 16, 'evening': 19 };
const timeNames: Record<string, string> = { 'morning': 'Morning', 'midday': 'Midday', 'afternoon': 'Afternoon', 'evening': 'Evening' };
const fabricNames: Record<string, string> = {
'linen': 'Linen', 'natural': 'Natural', 'charcoal': 'Charcoal', 'black': 'Black',
'forest': 'Forest green', 'navy': 'Navy', 'terracotta': 'Terracotta', 'burgundy': 'Burgundy'
};
const frameNames: Record<string, string> = { 'black': 'Black', 'white': 'White', 'bronze': 'Bronze', 'silver': 'Silver' };
const valanceNames: Record<string, string> = { 'wave': 'Wave', 'scallop': 'Scallop', 'straight': 'Straight', 'none': 'No valance' };
const mountNames: Record<string, string> = { 'wall': 'On the wall', 'soffit': 'Under the soffit', 'roof': 'On the roof' };
const mountOutFt: Record<string, number> = { 'wall': 0, 'soffit': 0, 'roof': 1.6 };
const wallNames: Record<string, string> = {
'wood': 'Wood frame with siding',
'vinyl': 'Vinyl siding',
'masonry': 'Brick, block or stucco on block',
'eifs': 'Foam stucco or stone veneer',
'unknown': 'Not sure'
};
const money = (amount: number): string => {
const whole = Math.round(Math.abs(amount));
let digits = '' + whole;
let grouped = '';
while (digits.length > 3) {
grouped = ',' + digits.slice(-3) + grouped;
digits = digits.slice(0, -3);
}
return (amount < 0 ? '-$' : '$') + digits + grouped;
};
const pick = (table: Record<string, unknown>, value: unknown, fallback: string): string =>
typeof value === 'string' && value in table ? value : fallback;
const feetInches = (ft: number): string => {
const inches = Math.round(ft * 12);
const whole = Math.floor(inches / 12);
const rest = inches % 12;
return rest === 0 ? whole + ' ft' : whole + ' ft ' + rest + ' in';
};
const feet = (ft: number): string => (Math.round(ft * 10) / 10).toFixed(1) + ' ft';
const clock = (hour: number): string => (hour % 12 === 0 ? 12 : hour % 12) + (hour < 12 ? ' AM' : ' PM');
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Retractable Awning Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Tell us which way your patio faces and when you sit outside, size the awning in feet, and see how far its shade reaches and what it costs installed, before the site visit.',
customStyles: { 'font-size': '0.95rem', 'color': '#64748b' }
});
});
form.addSpacer({ height: 16 });
const steps = form.addPages('steps', { heightMode: 'current-page' });
const patioPage = steps.addPage('patio');
const fabricPage = steps.addPage('fabric');
const optionsPage = steps.addPage('options');
const estimatePage = steps.addPage('estimate');
// Step 1: the patio and the awning
const patioSection = patioPage.addSubform('patioDetails', { title: '☀️ Your Patio' });
patioSection.addRow(row => {
row.addRadioButton('facing', {
label: 'Which way does the wall with the patio door face?',
tooltip: 'Standing at the back door and looking out into the yard',
options: [
{ id: 'south', name: 'South - sun most of the day' },
{ id: 'west', name: 'West - sun in the afternoon and evening' },
{ id: 'east', name: 'East - sun in the morning' },
{ id: 'north', name: 'North - sun only early and late' }
],
defaultValue: 'south',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('time', {
label: 'When do you sit on the patio most?',
options: [
{ id: 'morning', name: 'Morning, about ' + clock(10) + ' - breakfast, coffee' },
{ id: 'midday', name: 'Midday, about ' + clock(13) + ' - lunch' },
{ id: 'afternoon', name: 'Afternoon, about ' + clock(16) + ' - most often' },
{ id: 'evening', name: 'Evening, about ' + clock(19) + ' - dinner' }
],
defaultValue: 'afternoon',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
const facing = form.computedValue(() => pick(facingNames, patioSection.radioButton('facing')?.value(), 'south'));
const time = form.computedValue(() => pick(timeNames, patioSection.radioButton('time')?.value(), 'afternoon'));
const hour = form.computedValue(() => timeHours[time()] || 16);
const awningSection = patioPage.addSubform('awningDetails', { title: '⛱️ Awning' });
awningSection.addRow(row => {
row.addRadioButton('model', {
label: 'Model',
tooltip: 'Told apart by what shows once the awning is rolled in',
options: [
{ id: 'cassette', name: 'Full cassette - rolled in, the fabric and the arms disappear into a closed housing ($' + modelRates['cassette'] + '/sq ft)' },
{ id: 'semi', name: 'Semi-cassette - a hood covers the roller, the fabric and the arms show underneath ($' + modelRates['semi'] + '/sq ft)' },
{ id: 'open', name: 'Open - a bare roller on a torque bar, no cover; best under an eave ($' + modelRates['open'] + '/sq ft)' }
],
defaultValue: 'cassette',
orientation: 'vertical',
isRequired: true,
onValueChange: () => fitSizes()
});
});
// Declared before the sliders: the engine reads their max while building them
const model = form.computedValue(() => pick(modelNames, awningSection.radioButton('model')?.value(), 'cassette'));
const maxWidth = form.computedValue(() => modelMaxWidth[model()] || 24);
const width = form.computedValue(() => Math.min(awningSection.slider('width')?.value() || 16, maxWidth()));
const maxProjection = form.computedValue(() => Math.max(8, Math.min(modelMaxProjection[model()] || 13, width() - armRoomFt)));
awningSection.addRow(row => {
row.addSlider('width', {
label: 'Width along the wall',
unit: 'ft',
min: 10,
max: () => maxWidth(),
step: 0.5,
defaultValue: 16,
tooltip: 'A full or semi-cassette awning goes up to 24 ft wide, an open awning up to 20 ft',
onValueChange: () => fitSizes()
}, '1fr');
row.addSlider('projection', {
label: 'Projection from the wall',
unit: 'ft',
min: 8,
max: () => maxProjection(),
step: 0.5,
defaultValue: 10,
tooltip: 'At least a foot and a half less than the width: the lateral arms fold side by side'
}, '1fr');
});
// A narrower model or width pulls the sliders back inside what can be built
const fitSizes = (): void => {
const widthField = awningSection.slider('width');
const projectionField = awningSection.slider('projection');
if (widthField && (widthField.value() || 16) > maxWidth()) widthField.setValue(maxWidth());
if (projectionField && (projectionField.value() || 10) > maxProjection()) projectionField.setValue(maxProjection());
};
awningSection.addRow(row => {
row.addSlider('pitch', {
label: 'Pitch',
unit: '°',
min: 5,
max: 35,
step: 1,
defaultValue: 15,
tooltip: 'Steeper: shade closer to the wall and rain runs off. Flatter: shade further from the house'
}, '1fr');
row.addSlider('height', {
label: 'Mounting height above the patio',
unit: 'ft',
min: 7.5,
max: 11,
step: 0.5,
defaultValue: 9,
tooltip: 'Usually about a foot above the door or window trim; the front bar should clear 7 ft when the awning is out'
}, '1fr');
});
const projection = form.computedValue(() => Math.min(awningSection.slider('projection')?.value() || 10, maxProjection()));
const pitch = form.computedValue(() => awningSection.slider('pitch')?.value() || 15);
const mountHeight = form.computedValue(() => awningSection.slider('height')?.value() || 9);
// Where the shade falls: the summer sun on the Gulf Coast at the chosen hour, for the chosen wall
const degrees = Math.PI / 180;
const latitude = 27.3 * degrees;
const declination = 21 * degrees;
const clockToSolar = 1.5;
const tableFt = 7.5; // a table for six with its chairs pulled out takes about this much depth
const rollerFt = 0.43; // the roller sits this far out from the wall
const valanceFt = 1; // the valance hangs this far under the front bar
const eaveAboveFt = 3; // the eave line above the awning: the house's own shade
const gapFt = 0.5; // a sunny strip along the wall narrower than this does not count
// The sun: `shift` is how far towards the wall a point's shadow falls per foot of its height,
// negative when the sun stands behind the house
const sunNow = form.computedValue(() => {
const angle = (hour() - clockToSolar - 12) * 15 * degrees;
const sinElevation = Math.sin(latitude) * Math.sin(declination) + Math.cos(latitude) * Math.cos(declination) * Math.cos(angle);
const elevation = Math.asin(Math.max(-1, Math.min(1, sinElevation)));
const azimuth = Math.atan2(Math.sin(angle), Math.sin(latitude) * Math.cos(angle) - Math.cos(latitude) * Math.tan(declination));
const relative = (180 * degrees + azimuth) - (facingBearing[facing()] || 180) * degrees;
if (elevation < 3 * degrees) return { up: false, shift: 0 };
return { up: true, shift: Math.cos(relative) / Math.tan(elevation) };
});
const houseShade = form.computedValue(() => sunNow().up ? (mountHeight() + eaveAboveFt) * Math.max(0, -sunNow().shift) : 0);
// The band of patio the awning shades, measured from the wall
const bandOf = (projectionFt: number, pitchDeg: number): { from: number; to: number } => {
const sun = sunNow();
if (!sun.up) return { from: 0, to: 0 };
const tilt = pitchDeg * degrees;
const out = (mountOutFt[mount()] || 0) + rollerFt;
const bottom = mountHeight() - projectionFt * Math.sin(tilt) - valanceFt;
return { from: Math.max(0, out - mountHeight() * sun.shift), to: out + projectionFt * Math.cos(tilt) - bottom * sun.shift };
};
const band = form.computedValue(() => bandOf(projection(), pitch()));
const gap = form.computedValue(() => band().from > houseShade() + gapFt);
const shade = form.computedValue(() => gap() ? houseShade() : Math.max(houseShade(), band().to));
const shadeText = (): string => {
const at = clock(hour());
if (!sunNow().up) return 'At ' + at + ' the sun is down: the patio is in the shade of the evening whatever hangs over it. Pick an earlier time to see what the awning does for you.';
if (houseShade() >= tableFt) return 'At ' + at + ' the house shades the patio: its own shadow already covers about ' + feet(houseShade()) + ' from the wall, so at this hour the awning does not need to keep the sun off.';
if (gap()) return 'At ' + at + ' the shade lies from ' + feet(band().from) + ' to ' + feet(band().to) + ' from the wall: the sun stands almost overhead, so a strip along the wall stays in the sun. A lower mounting height narrows it.';
if (shade() < 1.3) return 'At ' + at + ' the awning will not shade the patio: the sun is so low that it shines in under the fabric and the shade falls on the wall.';
const reach = 'At ' + at + ' the shade reaches about ' + feet(shade()) + ' from the wall. ';
if (shade() >= tableFt) return '✓ ' + reach + 'That covers a table with chairs, which takes about ' + feet(tableFt) + '.';
const best = Math.max(houseShade(), bandOf(maxProjection(), 5).to);
if (best < tableFt) return reach + 'With the sun this low even the longest projection of this model shades only ' + feet(best) + '; a bigger awning alone will not cover the whole table.';
const advice = [projection() < maxProjection() ? 'increase the projection' : '', pitch() > 5 ? 'flatten the pitch' : ''].filter(Boolean).join(' or ');
return reach + 'A table with chairs takes about ' + feet(tableFt) + '. To reach further, ' + advice + '.';
};
awningSection.addRow(row => {
row.addTextPanel('shade', {
computedValue: () => shadeText(),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// Step 2: fabric, valance and frame
const fabricSection = fabricPage.addSubform('fabricDetails', { title: '🎨 Fabric & Frame' });
fabricSection.addRow(row => {
row.addRadioButton('pattern', {
label: 'Fabric pattern',
options: [
{ id: 'solid', name: 'Solid - one color over the whole awning' },
{ id: 'stripe', name: 'Classic stripe - wide bands of the color and natural' }
],
defaultValue: 'solid',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('valance', {
label: 'Valance',
options: [
{ id: 'wave', name: 'Wave - soft, even waves' },
{ id: 'scallop', name: 'Scallop - deep arcs with points' },
{ id: 'straight', name: 'Straight - a level, hemmed edge' },
{ id: 'none', name: 'No valance - just the front bar' }
],
defaultValue: 'straight',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
fabricSection.addRow(row => {
row.addDropdown('fabric', {
label: 'Fabric color',
tooltip: 'Solution-dyed acrylic: fade-resistant, water-repellent. Light colors let more light through, dark ones give deeper shade',
options: [
{ id: 'linen', name: 'Linen' },
{ id: 'natural', name: 'Natural' },
{ id: 'charcoal', name: 'Charcoal' },
{ id: 'black', name: 'Black' },
{ id: 'forest', name: 'Forest green' },
{ id: 'navy', name: 'Navy' },
{ id: 'terracotta', name: 'Terracotta' },
{ id: 'burgundy', name: 'Burgundy' }
],
defaultValue: 'linen',
isRequired: true
}, '1fr');
row.addDropdown('frame', {
label: 'Frame color',
tooltip: 'Powder-coated aluminum: housing, arms and front bar',
options: [
{ id: 'black', name: 'Black' },
{ id: 'white', name: 'White' },
{ id: 'bronze', name: 'Bronze' },
{ id: 'silver', name: 'Silver' }
],
defaultValue: 'black',
isRequired: true
}, '1fr');
});
const stripe = form.computedValue(() => fabricSection.radioButton('pattern')?.value() === 'stripe');
const fabric = form.computedValue(() => pick(fabricNames, fabricSection.dropdown('fabric')?.value(), 'linen'));
const frame = form.computedValue(() => pick(frameNames, fabricSection.dropdown('frame')?.value(), 'black'));
const valance = form.computedValue(() => pick(valanceNames, fabricSection.radioButton('valance')?.value(), 'straight'));
// Step 3: control, lighting and mounting
const controlSection = optionsPage.addSubform('controlOptions', { title: '⚡ Control & Lighting' });
controlSection.addRow(row => {
row.addRadioButton('control', {
label: 'Control',
options: [
{ id: 'crank', name: 'Hand crank - you turn it by hand; no power needed at the wall' },
{ id: 'motor', name: 'Motor with remote - one button; needs power at the wall (+' + money(motorPrice) + ')' }
],
defaultValue: 'motor',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addCheckbox('led', {
label: 'LED strip under the housing and in the front bar (+' + money(ledPrice) + ')',
defaultValue: false
}, '1fr');
});
const motor = form.computedValue(() => controlSection.radioButton('control')?.value() !== 'crank');
const led = form.computedValue(() => controlSection.checkbox('led')?.value() === true);
controlSection.addRow(row => {
row.addCheckboxList('motorAddons', {
label: 'Motor add-ons',
options: [
{ id: 'wind', name: 'Wind sensor - rolls the awning in by itself when a gust comes, even when nobody is home (+' + money(windSensorPrice) + ')' },
{ id: 'sun', name: 'Sun sensor - rolls it out when the sun comes out and in at dusk (+' + money(sunSensorPrice) + ')' },
{ id: 'hub', name: 'Wi-Fi hub - phone app, schedules and voice assistants (+' + money(smartHubPrice) + ')' }
],
orientation: 'vertical',
isVisible: () => motor()
});
});
const addons = () => controlSection.checkboxList('motorAddons')?.value() || [];
const windSensor = form.computedValue(() => motor() && addons().includes('wind'));
const sunSensor = form.computedValue(() => motor() && addons().includes('sun'));
const smartHub = form.computedValue(() => motor() && addons().includes('hub'));
controlSection.addRow(row => {
row.addTextPanel('windNote', {
computedValue: () => 'Rated for winds up to about ' + windRatingMph + ' mph when extended. A retractable awning is shade, not a storm roof: above that wind, and before a storm warning, it is rolled in'
+ (model() === 'cassette' ? ', and in the closed cassette the fabric and the arms are protected.' : '.')
+ (windSensor() ? ' Your wind sensor does it by itself.' : motor() ? ' A wind sensor would do it by itself, even when nobody is home.' : '')
+ (motor() ? ' The motor is hard-wired or plugged into an outdoor outlet near the housing; plan the outlet when choosing where the awning goes.' : ''),
customStyles: { 'font-size': '0.9rem', 'color': '#475569', 'background': '#f1f5f9', 'padding': '10px', 'border-radius': '6px' }
});
});
const mountSection = optionsPage.addSubform('mounting', { title: '🔧 Mounting & Installation' });
mountSection.addRow(row => {
row.addRadioButton('mount', {
label: 'Where does it mount?',
options: [
{ id: 'wall', name: 'On the wall - brackets lag-bolted into the framing or anchored in masonry above the door (included)' },
{ id: 'soffit', name: 'Under the soffit - ceiling brackets up into the eave or the porch ceiling (+' + money(mountSurcharge['soffit'] || 0) + ')' },
{ id: 'roof', name: 'On the roof - roof brackets into the rafters, when the wall above the door is too low (+' + money(mountSurcharge['roof'] || 0) + ')' }
],
defaultValue: 'wall',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('wall', {
label: 'What is the wall made of?',
tooltip: 'What is behind the finish decides how the brackets go in. If you are not sure, we check at the site visit',
options: [
{ id: 'wood', name: 'Wood frame with siding' },
{ id: 'vinyl', name: 'Vinyl siding - mounting board (+' + money(wallSurcharge['vinyl'] || 0) + ')' },
{ id: 'masonry', name: 'Brick, block or stucco on block' },
{ id: 'eifs', name: 'Foam stucco or stone veneer - stand-off brackets (+' + money(wallSurcharge['eifs'] || 0) + ')' },
{ id: 'unknown', name: 'Not sure' }
],
defaultValue: 'unknown',
orientation: 'vertical',
isVisible: () => mount() === 'wall'
}, '1fr');
});
const mount = form.computedValue(() => pick(mountNames, mountSection.radioButton('mount')?.value(), 'wall'));
const wallKind = form.computedValue(() => mount() === 'wall' ? pick(wallNames, mountSection.radioButton('wall')?.value(), 'unknown') : 'wood');
mountSection.addRow(row => {
row.addTextPanel('wallNote', {
computedValue: () => wallKind() === 'eifs'
? 'Foam stucco and stone veneer will not hold an awning, so the brackets go through them on stand-offs to the studs or the block behind.'
: 'Brackets tightened onto vinyl crush it, so we fix a board to the framing first and the awning to the board.',
isVisible: () => wallKind() === 'eifs' || wallKind() === 'vinyl',
customStyles: { 'font-size': '0.9rem', 'color': '#92400e', 'background': '#fef3c7', 'padding': '10px', 'border-radius': '6px' }
});
});
mountSection.addRow(row => {
row.addInteger('miles', {
label: 'Distance from our shop (miles)',
min: 0,
max: 300,
defaultValue: 15,
tooltip: 'Travel is free up to ' + freeMiles + ' mi, beyond that $' + milePrice + ' per mile'
}, '1fr');
row.addCheckbox('hoa', {
label: 'My neighborhood has an HOA (review adds about ' + hoaWeeks + ' weeks)',
defaultValue: false
}, '1fr');
});
const miles = form.computedValue(() => mountSection.integer('miles')?.value() || 0);
const hoa = form.computedValue(() => mountSection.checkbox('hoa')?.value() === true);
// Pricing
const coverage = form.computedValue(() => Math.max(minimumSqft, width() * projection()));
const awningAmount = form.computedValue(() => coverage() * (modelRates[model()] || 0));
const motorAmount = form.computedValue(() => motor() ? motorPrice : 0);
const addonsAmount = form.computedValue(() => (windSensor() ? windSensorPrice : 0) + (sunSensor() ? sunSensorPrice : 0) + (smartHub() ? smartHubPrice : 0));
const ledAmount = form.computedValue(() => led() ? ledPrice : 0);
const mountAmount = form.computedValue(() => (mountSurcharge[mount()] || 0) + (wallSurcharge[wallKind()] || 0));
const installAmount = form.computedValue(() => installPrice + mountAmount());
const travelAmount = form.computedValue(() => Math.max(0, miles() - freeMiles) * milePrice);
const beforePromo = form.computedValue(() => awningAmount() + motorAmount() + addonsAmount() + ledAmount() + installAmount() + permitPrice + travelAmount());
const promoDiscount = form.computedValue(() => beforePromo() * promoPct / 100);
const total = form.computedValue(() => Math.round(beforePromo() - promoDiscount()));
const priceFrom = form.computedValue(() => Math.round(total() * (1 - rangePct / 100) / 10) * 10);
const priceTo = form.computedValue(() => Math.round(total() * (1 + rangePct / 100) / 10) * 10);
const sizeText = () => feetInches(width()) + ' × ' + feetInches(projection());
const mountText = () => (mountNames[mount()] || '').toLowerCase()
+ (wallKind() === 'eifs' ? ', stand-off brackets' : wallKind() === 'vinyl' ? ', mounting board' : '');
const fabricText = () => (stripe() ? 'classic stripe, ' : 'solid, ') + (fabricNames[fabric()] || '').toLowerCase()
+ ', ' + (valanceNames[valance()] || '').toLowerCase() + (valance() === 'none' ? '' : ' valance') + ', ' + (frameNames[frame()] || '').toLowerCase() + ' frame';
const optionsText = () => (motor() ? 'motor with remote' : 'hand crank')
+ (windSensor() ? ', wind sensor' : '') + (sunSensor() ? ', sun sensor' : '') + (smartHub() ? ', Wi-Fi hub' : '') + (led() ? ', LED' : '');
// Step 4: the estimate and the contact details
const summarySection = estimatePage.addSubform('summary', { title: '💰 Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addTextPanel('promo', {
computedValue: () => '🎉 ' + promoText + ': the ' + promoPct + '% discount is already in the amount below.',
isVisible: () => promoDiscount() > 0,
customStyles: { 'font-size': '0.9rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' }
});
});
summarySection.addRow(row => {
row.addPriceDisplay('awningPrice', {
label: () => modelNames[model()] + ' awning, ' + Math.round(coverage()) + ' sq ft',
computedValue: () => Math.round(awningAmount()),
decimals: 0
}, '1fr');
row.addPriceDisplay('optionsPrice', {
label: 'Control, sensors and LED',
computedValue: () => Math.round(motorAmount() + addonsAmount() + ledAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('installPrice', {
label: () => 'Installation, ' + mountText() + ', and permit',
computedValue: () => Math.round(installAmount() + permitPrice),
decimals: 0,
prefix: '+'
}, '1fr');
row.addPriceDisplay('travelPrice', {
label: () => 'Travel, ' + miles() + ' mi',
computedValue: () => Math.round(travelAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('promoPrice', {
label: () => promoText + ' (' + promoPct + '%)',
computedValue: () => -Math.round(promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => promoDiscount() > 0
});
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('total', {
label: 'Awning installed, about',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: 'before sales tax'
});
});
summarySection.addRow(row => {
row.addTextPanel('range', {
computedValue: () => 'Expect ' + money(priceFrom()) + ' – ' + money(priceTo()) + ' (±' + rangePct + '%). '
+ (width() * projection() < minimumSqft ? 'Small awnings are priced as ' + minimumSqft + ' sq ft: the motor, the roller and the arms cost the same. ' : '')
+ 'At the site visit we check the wall, the mounting height and the power for the motor, and set the firm price.',
customStyles: { 'font-size': '0.9rem', 'color': '#475569' }
});
});
summarySection.addRow(row => {
row.addTextPanel('schedule', {
computedValue: () => '📅 Site visit within ' + siteVisitDays + ' business days'
+ (hoa() ? ' → HOA review with our drawings and a fabric sample, about ' + hoaWeeks + ' weeks' : '')
+ ' → permit and inspection by us → installation about ' + leadWeeks + ' weeks after the measurement.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
const contactSection = estimatePage.addSubform('contact', { title: '📋 Schedule a Site Visit', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('name', { label: 'Name', placeholder: 'Jane Smith', isRequired: true }, '1fr');
row.addTextbox('address', { label: 'Street address', placeholder: '1234 Palm Ave', isRequired: true }, '2fr');
});
contactSection.addRow(row => {
row.addTextbox('city', { label: 'City, state', placeholder: 'Sarasota, FL', isRequired: true }, '2fr');
row.addTextbox('zip', { label: 'ZIP code', placeholder: '34237', maxLength: 10, isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('phone', { label: 'Phone', placeholder: '(555) 123-4567', isRequired: true }, '1fr');
row.addEmail('email', { label: 'Email', placeholder: 'jane@example.com', isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else we should know?',
placeholder: 'E.g. a gutter above the door, a light on the wall, a second-floor deck, the outlet is on the other side',
rows: 3
});
});
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'retractable-awning-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download your configuration (PDF)',
header: {
title: 'Retractable awning - estimate and configuration',
subtitle: modelNames[model()] + ' ' + sizeText()
},
footer: {
text: 'Estimate based on the configuration from the form, valid for ' + validityDays + ' days. The firm quote follows the site visit.',
showPageNumbers: true
}
});
pdf.addSection('Customer', section => {
section.addRow(row => {
row.addField('Name', contactSection.textbox('name')?.value() || '');
row.addField('Phone', contactSection.textbox('phone')?.value() || '');
row.addField('Email', contactSection.email('email')?.value() || '');
row.addField('Address', [contactSection.textbox('address')?.value(), contactSection.textbox('city')?.value(), contactSection.textbox('zip')?.value()].filter(Boolean).join(', '));
});
});
pdf.addSection('Patio', section => {
section.addRow(row => {
row.addField('Wall faces', facingNames[facing()] || '');
row.addField('Time on the patio', timeNames[time()] + ', about ' + clock(hour()));
});
section.addText(shadeText());
});
pdf.addSection('Awning', section => {
section.addRow(row => {
row.addField('Model', modelNames[model()] || '');
row.addField('Size', sizeText());
row.addField('Pitch', pitch() + '°');
row.addField('Mounting height', feetInches(mountHeight()));
});
section.addRow(row => {
row.addField('Fabric', fabricText());
row.addField('Options', optionsText());
row.addField('Mounting', mountText() + (mount() === 'wall' ? '; wall: ' + (wallNames[wallKind()] || '').toLowerCase() : ''));
});
});
pdf.addSection('Estimate', section => {
const rows: string[][] = [
[modelNames[model()] + ' awning ' + sizeText() + ', ' + Math.round(coverage()) + ' sq ft', money(awningAmount())]
];
if (motorAmount() > 0) rows.push(['Motor with remote', money(motorAmount())]);
if (windSensor()) rows.push(['Wind sensor', money(windSensorPrice)]);
if (sunSensor()) rows.push(['Sun sensor', money(sunSensorPrice)]);
if (smartHub()) rows.push(['Wi-Fi hub', money(smartHubPrice)]);
if (ledAmount() > 0) rows.push(['LED lighting', money(ledAmount())]);
rows.push(['Installation: ' + mountText(), money(installAmount())]);
rows.push(['Permit and inspection', money(permitPrice)]);
if (travelAmount() > 0) rows.push(['Travel, ' + miles() + ' mi', money(travelAmount())]);
if (promoDiscount() > 0) rows.push([promoText + ' (-' + promoPct + '%)', '-' + money(promoDiscount())]);
section.addTable(['Item', 'Amount'], rows);
section.addDivider();
section.addText('Estimate: ' + money(priceFrom()) + ' – ' + money(priceTo()) + ' before sales tax. Range ±' + rangePct + '%, firm price after the site visit.');
});
pdf.addSection('What happens next', section => {
section.addText('1. We call and schedule the site visit: an installer comes out within ' + siteVisitDays + ' business days.');
section.addText('2. At the visit we check the wall, the mounting height and the power for the motor, and bring fabric samples.');
if (hoa()) section.addText('3. We prepare the drawings and the fabric sample for your HOA review, which usually takes about ' + hoaWeeks + ' weeks.');
section.addText((hoa() ? '4' : '3') + '. We pull the permit with the attachment details and schedule the final inspection.');
section.addText((hoa() ? '5' : '4') + '. After the visit you get a firm quote; from the measurement to the installation it usually takes ' + leadWeeks + ' weeks.');
const notes = contactSection.textarea('notes')?.value();
if (notes) {
section.addSpacer(8);
section.addText('Notes: ' + notes);
}
section.addSpacer(8);
section.addText('This estimate is for information only and is not a binding offer. The shade reach is an approximation for the summer sun and depends on the lot and its surroundings. A retractable awning is rated for winds up to about ' + windRatingMph + ' mph when extended and must be retracted above that and before a storm.');
});
});
form.configureSubmitButton({ label: 'Schedule my site visit' });
form.configureCompletionScreen({
type: 'text',
title: 'We have your awning request',
message: 'We will call to set up the site visit within ' + siteVisitDays + ' business days. Download the PDF with your configuration below.'
});
}