Retractable Awning Calculator

The one question every awning buyer asks is whether the shade will reach their table at the hour they sit outside, and no catalog answers it. This calculator does: the visitor picks the direction of the wall and the time of day, and the form computes where the summer sun stands and how far the awning's shade reaches from the wall, telling them whether a table with chairs fits in it. Then it covers everything that sets awnings apart: full cassette, semi-cassette and open models priced per square foot, a width capped by the model and a projection capped by the width, pitch and mounting height, solid or striped solution-dyed acrylic with a valance and a frame color, a hand crank or a motor with wind sensor, sun sensor and Wi-Fi hub, LED lighting, and wall, soffit or roof mounting with the surcharges for stand-off brackets through foam stucco and a mounting board on vinyl siding. The permit, travel and an optional promotion complete the estimate, shown as a range, and the visitor schedules a site visit with a PDF of their configuration.

Home Services

Try the Calculator

Retractable Awning Estimate
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.
☀️ Your Patio
 
 
⛱️ Awning
 
16ft
16 ft
1024
10ft
10 ft
813
15°
15 °
535
9ft
9 ft
7.511
✓ At 4 PM the shade reaches about 9.9 ft from the wall. That covers a table with chairs, which takes about 7.5 ft.
🎨 Fabric & Frame
 
 
⚡ Control & Lighting
 
 
Rated for winds up to about 25 mph when extended. A retractable awning is shade, not a storm roof: above that wind, and before a storm warning, it is rolled in, and in the closed cassette the fabric and the arms are protected. A wind sensor would do it by itself, even when nobody is home. The motor is hard-wired or plugged into an outdoor outlet near the housing; plan the outlet when choosing where the awning goes.
🔧 Mounting & Installation
 
 
 
💰 Your Estimate
$ 7,680
+
$ 900
+
$ 1,000
+
$ 0

$ 9,580
before sales tax
Expect $8,620 – $10,540 (±10%). At the site visit we check the wall, the mounting height and the power for the motor, and set the firm price.
📅 Site visit within 5 business days → permit and inspection by us → installation about 5 weeks after the measurement.
📋 Schedule a Site Visit
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
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.'
});
}
 

Frequently Asked Questions

How does the calculator know how far the shade reaches?

It computes the position of the summer sun for the direction of the wall and the hour the visitor picks, then projects the shadow of the awning's roller and valance onto the patio, allowing for the pitch and the mounting height, and adds the shadow of the house itself when the sun stands behind it. The result is an approximation that tells the visitor whether a table with chairs fits in the shade; the latitude is set for the Gulf Coast and can be changed in the code.

Can I change the prices and the awning models?

Yes. Every rate is a named constant at the top of the calculator: the per-square-foot price of a full cassette, a semi-cassette and an open awning, their maximum width and projection, the motor, the sensors, the hub, the LED, the installation and the mounting surcharges. Remove a model you do not sell or add one by following the same pattern.

Why is the price shown as a range?

An awning is quoted after a site visit, when the installer has seen the wall, the mounting height and the power for the motor. The calculator shows a range of plus or minus ten percent around the computed estimate so the visitor gets an honest number without the dealer committing to a price before the measurement. You can narrow or widen the range in the code.

How does the calculator handle wall construction and mounting?

The visitor picks wall, soffit or roof mounting, and for a wall mount says what the wall is made of. Foam stucco and stone veneer add stand-off brackets to the framing, vinyl siding adds a mounting board, each with its own surcharge, and the form explains why. If the visitor is not sure, the wall is checked at the site visit.

What does the dealer receive when the form is submitted?

The lead comes with the visitor's name, address, phone and email and every choice they made: the patio direction and hour, the model, size, pitch and height, the fabric, the control and sensors, the mounting and the wall, plus the estimate. The visitor can download the same configuration as a PDF, so the site visit starts from a configuration instead of from zero.