export function windowBlindsCalculator(form: FormTs) {
// Price list in USD: what a dealer that measures and installs custom window treatments charges.
const prices = {
measureDays: 2, // free in-home measure within this many business days
leadWeeks: 3, // from confirmed sizes to installed shades
promoPct: 0, // current promotion on the whole order (0 = no banner)
promoText: 'Free installation on 5 or more windows',
financingNote: 'Financing available: 0% APR for 12 months on approved credit.',
minSqft: 8, // minimum billed area per window
minOrder: 250, // smaller orders are billed at this amount
blackoutPct: 25, // blackout fabric or liner: roller, cellular and Roman shades
solarPct: 15, // solar screen fabric on a roller shade
doubleCellPct: 20, // double-cell honeycomb
tdbuEach: 60, // top-down/bottom-up on a cellular shade
realWoodPct: 40, // real basswood slats instead of faux wood
stainedPct: 25, // stained hardwood shutters instead of painted composite
valanceEach: 45, // fascia or valance per window
motorBatteryEach: 300, // rechargeable battery motor with a remote
motorWiredEach: 450, // low-voltage hardwired motor with the wiring
hubEach: 180, // smart-home hub, once per home
installEach: 40, // mounting one treatment
tripFee: 75, // added once to an installed order below the threshold
tripFreeFrom: 1000, // order value from which the trip charge is waived
disc5Pct: 5, // discount from 5 windows
disc10Pct: 10 // discount from 10 windows
};
type Treatment = { name: string; short: string; rate: number; fabric: boolean; controls: boolean };
// What the dealer sells, priced per square foot: cordless, inside mount, standard fabric or painted finish.
const TREATMENTS: Record<string, Treatment> = {
'roller': { name: 'Roller shades (light-filtering, blackout or solar screen)', short: 'Roller shade', rate: 14, fabric: true, controls: true },
'zebra': { name: 'Zebra shades (sheer and solid bands)', short: 'Zebra shade', rate: 20, fabric: true, controls: true },
'cellular': { name: 'Cellular shades (honeycomb, top-down/bottom-up)', short: 'Cellular shade', rate: 18, fabric: true, controls: true },
'roman': { name: 'Roman shades (soft fabric folds)', short: 'Roman shade', rate: 30, fabric: true, controls: true },
'wood': { name: 'Wood blinds (faux or real wood slats)', short: 'Wood blinds', rate: 12, fabric: false, controls: true },
'vertical': { name: 'Vertical blinds (for sliding glass doors)', short: 'Vertical blinds', rate: 10, fabric: true, controls: true },
'shutter': { name: 'Plantation shutters (painted or stained louvers)', short: 'Plantation shutters', rate: 32, fabric: false, controls: false }
};
const ROOMS: Record<string, string> = {
'living': 'Living room', 'bedroom': 'Bedroom', 'nursery': 'Nursery', 'kitchen': 'Kitchen', 'bath': 'Bathroom',
'office': 'Home office', 'slider': 'Sliding glass door', 'other': 'Other room'
};
const MOUNTS: Record<string, string> = { 'inside': 'Inside mount (in the window frame, needs about 2.5 in of depth)', 'outside': 'Outside mount (on the trim or the wall, covers the whole opening)' };
const OPACITIES: Record<string, string> = { 'filtering': 'Light-filtering', 'blackout': 'Blackout', 'solar': 'Solar screen' };
const FABRIC_COLORS: Record<string, string> = { 'white': 'White', 'ivory': 'Ivory', 'linen': 'Linen', 'gray': 'Gray', 'charcoal': 'Charcoal', 'navy': 'Navy' };
const FINISHES: Record<string, { name: string; stained: boolean }> = {
'white': { name: 'Painted white', stained: false }, 'ivory': { name: 'Painted ivory', stained: false },
'oak': { name: 'Oak', stained: true }, 'walnut': { name: 'Walnut', stained: true }, 'espresso': { name: 'Espresso', stained: true }
};
const CONTROLS: Record<string, { name: string; price: number; motor: boolean }> = {
'cordless': { name: 'Cordless (lift by the bottom rail; the child-safe standard)', price: 0, motor: false },
'cord': { name: 'Cord loop (for tall or wide shades)', price: 0, motor: false },
'motor-battery': { name: 'Motorized, battery (rechargeable motor with a remote)', price: prices.motorBatteryEach, motor: true },
'motor-wired': { name: 'Motorized, hardwired (low-voltage wiring, never charge)', price: prices.motorWiredEach, motor: true }
};
const TIMINGS: Record<string, string> = { 'asap': 'As soon as possible', 'month': 'Within a month', 'quarter': 'Within 3 months', 'comparing': 'Just comparing quotes' };
const CALL_TIMES: Record<string, string> = { 'any': 'Any time', 'morning': 'Before noon', 'afternoon': 'After noon' };
const MAX_WINDOWS = 6;
// Typical openings for the first three cards: a bedroom window, a kitchen window, a sliding door; then the cycle repeats.
const START_SIZES: { w: number; h: number }[] = [{ w: 36, h: 60 }, { w: 30, h: 48 }, { w: 72, h: 80 }];
const START_TYPES: string[] = ['roller', 'cellular', 'vertical'];
const START_ROOMS: string[] = ['bedroom', 'kitchen', 'slider'];
const defaultSize = (i: number) => START_SIZES[(i - 1) % START_SIZES.length]!;
const defaultType = (i: number) => START_TYPES[(i - 1) % START_TYPES.length]!;
const defaultRoom = (i: number) => START_ROOMS[(i - 1) % START_ROOMS.length]!;
const money = (amount: number): string => {
const rounded = Math.round(amount);
const digits = String(Math.abs(rounded));
let out = '';
for (let i = 0; i < digits.length; i++) {
const fromEnd = digits.length - i;
out += digits.charAt(i);
if (fromEnd > 1 && (fromEnd - 1) % 3 === 0) out += ',';
}
return (rounded < 0 ? '-$' : '$') + out;
};
const pick = (table: Record<string, unknown>, value: unknown, fallback: string): string => typeof value === 'string' && value in table ? value : fallback;
const inches = (value: number): string => Math.abs(value - Math.round(value)) < 0.05 ? String(Math.round(value)) : value.toFixed(1);
const windowsWord = (n: number): string => n + (n === 1 ? ' window' : ' windows');
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Window Shades, Blinds & Shutters Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'A card for every kind of window: treatment, size in inches, quantity and options. The price follows the inches. '
+ 'Then choose a free in-home measure and installation or shipping to your door.',
customStyles: { 'font-size': '0.95rem', 'color': '#475569' }
});
});
form.addSpacer({ height: 16 });
const steps = form.addPages('steps', { heightMode: 'current-page' });
// ---------------------------------------------------------------- Step 1: the windows
const windowsPage = steps.addPage('windows');
windowsPage.addRow(row => {
row.addTextPanel('windowsTitle', {
label: 'Step 1 of 3: Windows',
computedValue: () => 'Windows with the same treatment, size and options are one card: set the quantity on it.'
});
});
const listSection = windowsPage.addSubform('windowList', { title: '🪟 Your Windows', isCollapsible: false });
listSection.addRow(row => {
row.addInteger('kinds', {
label: 'How many different windows are we covering?',
min: 1,
max: MAX_WINDOWS,
defaultValue: 1,
isRequired: true
}, '1fr');
row.addEmpty('1fr');
});
const kindCount = form.computedValue(() => {
const value = listSection.integer('kinds')?.value();
return typeof value === 'number' && value >= 1 ? Math.min(value, MAX_WINDOWS) : 1;
});
type WindowLine = {
number: number; type: string; width: number; height: number; quantity: number; mount: string; room: string;
opacity: string; openness: string; doubleCell: boolean; tdbu: boolean; slat: string; realWood: boolean; louver: string;
color: string; control: string; valance: boolean; sqft: number;
};
const cards: SubformTs[] = [];
for (let i = 1; i <= MAX_WINDOWS; i++) {
const size = defaultSize(i);
const card = listSection.addSubform('window' + i, {
title: () => 'Window ' + i + ': ' + describeWindow(windowLine(i)),
isCollapsible: true,
isVisible: () => kindCount() >= i
});
cards.push(card);
const cardType = () => pick(TREATMENTS, card.dropdown('type')?.value(), defaultType(i));
const isRoller = () => cardType() === 'roller';
const isCellular = () => cardType() === 'cellular';
const isWood = () => cardType() === 'wood';
const isVertical = () => cardType() === 'vertical';
const isShutter = () => cardType() === 'shutter';
const hasFabric = () => TREATMENTS[cardType()]!.fabric;
const hasOpacity = () => isRoller() || isCellular() || cardType() === 'roman';
const hasValance = () => isRoller() || cardType() === 'zebra' || isWood() || isVertical();
const isSolar = () => isRoller() && card.radioButton('opacity')?.value() === 'solar';
card.addRow(row => {
row.addDropdown('type', {
label: 'Treatment',
options: Object.keys(TREATMENTS).map(id => ({ id, name: TREATMENTS[id]!.name + ' - $' + TREATMENTS[id]!.rate + '/sq ft' })),
defaultValue: defaultType(i),
isRequired: true
}, '2fr');
row.addDropdown('room', {
label: 'Room',
options: Object.keys(ROOMS).map(id => ({ id, name: ROOMS[id]! })),
defaultValue: defaultRoom(i),
tooltip: 'Helps our designer bring the right fabric samples'
}, '1fr');
});
card.addRow(row => {
row.addSlider('width', {
label: 'Width',
unit: 'in',
min: 12,
max: 120,
step: 0.5,
defaultValue: size.w,
tooltip: 'Inside mount: the opening at its narrowest. Outside mount: the trim edge to edge'
}, '1fr');
row.addSlider('height', {
label: 'Height',
unit: 'in',
min: 12,
max: 108,
step: 0.5,
defaultValue: size.h
}, '1fr');
});
card.addRow(row => {
row.addInteger('quantity', {
label: 'Windows of this size',
min: 1,
max: 30,
defaultValue: 1
}, '1fr');
row.addRadioButton('mount', {
label: 'Mount',
options: Object.keys(MOUNTS).map(id => ({ id, name: MOUNTS[id]! })),
defaultValue: 'inside',
orientation: 'vertical'
}, '2fr');
});
card.addRow(row => {
row.addRadioButton('opacity', {
label: 'Fabric',
options: () => [
{ id: 'filtering', name: 'Light-filtering (included)' },
{ id: 'blackout', name: 'Blackout (+' + prices.blackoutPct + '%)' },
...(isRoller() ? [{ id: 'solar', name: 'Solar screen: cuts glare and heat, keeps the view (+' + prices.solarPct + '%)' }] : [])
],
defaultValue: 'filtering',
orientation: 'vertical',
isVisible: () => hasOpacity()
}, '1fr');
row.addRadioButton('openness', {
label: 'Openness (lower blocks more sun; 10% keeps the best view)',
options: [{ id: '1', name: '1%' }, { id: '3', name: '3%' }, { id: '5', name: '5%' }, { id: '10', name: '10%' }],
defaultValue: '5',
orientation: 'horizontal',
isVisible: () => isSolar()
}, '1fr');
row.addRadioButton('cell', {
label: 'Cell',
options: [{ id: 'single', name: 'Single cell' }, { id: 'double', name: 'Double cell: insulates better (+' + prices.doubleCellPct + '%)' }],
defaultValue: 'single',
orientation: 'vertical',
isVisible: () => isCellular()
}, '1fr');
row.addCheckbox('tdbu', {
label: 'Top-down/bottom-up: lower the top for light and privacy at once (+' + money(prices.tdbuEach) + ')',
defaultValue: false,
isVisible: () => isCellular()
}, '1fr');
});
card.addRow(row => {
row.addRadioButton('woodKind', {
label: 'Material',
options: [
{ id: 'faux', name: 'Faux wood: moisture-proof, for kitchens and baths (included)' },
{ id: 'real', name: 'Real basswood: lighter, warmer grain (+' + prices.realWoodPct + '%)' }
],
defaultValue: 'faux',
orientation: 'vertical',
isVisible: () => isWood()
}, '1fr');
row.addRadioButton('slat', {
label: 'Slat size (wider slats give a clearer view when open)',
options: [{ id: '2', name: '2 in' }, { id: '2.5', name: '2.5 in' }],
defaultValue: '2',
orientation: 'horizontal',
isVisible: () => isWood()
}, '1fr');
row.addRadioButton('louver', {
label: 'Louver size (3.5 in is the most popular; 4.5 in suits large windows)',
options: [{ id: '2.5', name: '2.5 in' }, { id: '3.5', name: '3.5 in' }, { id: '4.5', name: '4.5 in' }],
defaultValue: '3.5',
orientation: 'horizontal',
isVisible: () => isShutter()
}, '1fr');
});
card.addRow(row => {
row.addDropdown('fabricColor', {
label: 'Color family (the exact fabric is picked from samples at the measure)',
options: Object.keys(FABRIC_COLORS).map(id => ({ id, name: FABRIC_COLORS[id]! })),
defaultValue: 'white',
isVisible: () => hasFabric()
}, '1fr');
row.addDropdown('finish', {
label: 'Finish',
options: () => Object.keys(FINISHES).map(id => {
const f = FINISHES[id]!;
return { id, name: f.name + (isShutter() ? (f.stained ? ' (stained hardwood, +' + prices.stainedPct + '%)' : ' (painted composite, included)') : '') };
}),
defaultValue: 'white',
isVisible: () => !hasFabric()
}, '1fr');
row.addRadioButton('control', {
label: 'Operation',
options: () => Object.keys(CONTROLS)
.filter(id => !(isVertical() && id === 'cord'))
.map(id => {
const c = CONTROLS[id]!;
const name = isVertical() && id === 'cordless' ? 'Wand and cord (turn with the wand, traverse with the cord)' : c.name;
return { id, name: name + (c.price > 0 ? ' (+' + money(c.price) + ')' : '') };
}),
defaultValue: 'cordless',
orientation: 'vertical',
isVisible: () => TREATMENTS[cardType()]!.controls
}, '1fr');
});
card.addRow(row => {
row.addCheckbox('valance', {
label: () => (isWood() ? 'Valance: a matching wood valance that hides the head rail' : isVertical() ? 'Valance: a fabric valance over the track' : 'Valance: an aluminum fascia that hides the roll')
+ ' (+' + money(prices.valanceEach) + ')',
defaultValue: false,
isVisible: () => hasValance()
}, '1fr');
row.addEmpty('1fr');
});
}
// One window, every field read through its defaults, so the price never waits.
const windowLine = (i: number): WindowLine => {
const card = cards[i - 1]!;
const type = pick(TREATMENTS, card.dropdown('type')?.value(), defaultType(i));
const treatment = TREATMENTS[type]!;
const width = card.slider('width')?.value() || defaultSize(i).w;
const height = card.slider('height')?.value() || defaultSize(i).h;
const opacityRaw = pick(OPACITIES, card.radioButton('opacity')?.value(), 'filtering');
const opacity = type === 'roller' ? opacityRaw : (type === 'cellular' || type === 'roman') && opacityRaw === 'blackout' ? 'blackout' : 'filtering';
const controlRaw = treatment.controls ? pick(CONTROLS, card.radioButton('control')?.value(), 'cordless') : 'cordless';
return {
number: i,
type,
width,
height,
quantity: Math.max(1, card.integer('quantity')?.value() || 1),
mount: pick(MOUNTS, card.radioButton('mount')?.value(), 'inside'),
room: pick(ROOMS, card.dropdown('room')?.value(), defaultRoom(i)),
opacity,
openness: opacity === 'solar' ? (card.radioButton('openness')?.value() || '5') : '',
doubleCell: type === 'cellular' && card.radioButton('cell')?.value() === 'double',
tdbu: type === 'cellular' && card.checkbox('tdbu')?.value() === true,
slat: type === 'wood' ? (card.radioButton('slat')?.value() || '2') : '',
realWood: type === 'wood' && card.radioButton('woodKind')?.value() === 'real',
louver: type === 'shutter' ? (card.radioButton('louver')?.value() || '3.5') : '',
color: treatment.fabric ? pick(FABRIC_COLORS, card.dropdown('fabricColor')?.value(), 'white') : pick(FINISHES, card.dropdown('finish')?.value(), 'white'),
control: type === 'vertical' && controlRaw === 'cord' ? 'cordless' : controlRaw,
valance: (type === 'roller' || type === 'zebra' || type === 'wood' || type === 'vertical') && card.checkbox('valance')?.value() === true,
sqft: (width * height) / 144
};
};
const windowList = (): WindowLine[] => {
const out: WindowLine[] = [];
for (let i = 1; i <= kindCount(); i++) out.push(windowLine(i));
return out;
};
const windowCount = form.computedValue(() => windowList().reduce((sum, w) => sum + w.quantity, 0));
const motorizedCount = form.computedValue(() => windowList().filter(w => CONTROLS[w.control]!.motor).reduce((sum, w) => sum + w.quantity, 0));
const sizeText = (w: WindowLine): string => inches(w.width) + ' x ' + inches(w.height) + ' in';
const colorName = (w: WindowLine): string => TREATMENTS[w.type]!.fabric ? (FABRIC_COLORS[w.color] ?? w.color) : (FINISHES[w.color]?.name ?? w.color);
const controlName = (w: WindowLine): string => w.type === 'shutter' ? ''
: w.type === 'vertical' && w.control === 'cordless' ? 'wand and cord'
: CONTROLS[w.control]!.name.split(' (')[0]!.toLowerCase();
// The options of one window in words: what the designer reads on the PDF.
const optionNames = (w: WindowLine): string[] => {
const out: string[] = [];
if (w.type === 'roller' || w.type === 'cellular' || w.type === 'roman') out.push(OPACITIES[w.opacity]!.toLowerCase() + (w.opacity === 'solar' ? ' ' + w.openness + '%' : ''));
if (w.doubleCell) out.push('double cell');
if (w.tdbu) out.push('top-down/bottom-up');
if (w.type === 'wood') out.push((w.realWood ? 'real basswood' : 'faux wood') + ', ' + w.slat + ' in slats');
if (w.type === 'shutter') out.push(w.louver + ' in louvers');
if (w.valance) out.push('valance');
return out;
};
const describeWindow = (w: WindowLine): string => {
const parts = [TREATMENTS[w.type]!.short, sizeText(w)];
if (w.quantity > 1) parts.push(w.quantity + ' pcs');
parts.push(colorName(w).toLowerCase());
const control = controlName(w);
if (control) parts.push(control);
return parts.join(' - ');
};
// ---------------------------------------------------------------- Step 2: installation
const installPage = steps.addPage('install');
installPage.addRow(row => {
row.addTextPanel('installTitle', {
label: 'Step 2 of 3: Installation',
computedValue: () => 'Who measures and installs, and when you would like it done.'
});
});
const installSection = installPage.addSubform('installDetails', { title: '🔧 Measure and Installation' });
installSection.addRow(row => {
row.addRadioButton('service', {
label: 'Who measures and installs?',
options: [
{ id: 'install', name: 'You measure and install: a free in-home measure, and you take responsibility for the fit (' + money(prices.installEach) + '/window)' },
{ id: 'diy', name: 'Ship to me, I install: made to the sizes I give you, shipped to my door with brackets and instructions (no install charge)' }
],
defaultValue: 'install',
orientation: 'vertical',
isRequired: true
});
});
const withInstall = form.computedValue(() => installSection.radioButton('service')?.value() !== 'diy');
installSection.addRow(row => {
row.addTextPanel('sizesWarning', {
computedValue: () => '⚠️ Your measurements are final. Custom shades are made to the sizes you give us and cannot be returned for a wrong measurement. For a first order we recommend the free in-home measure.',
isVisible: () => !withInstall(),
customStyles: { 'font-size': '0.9rem', 'color': '#9a3412', 'background': '#ffedd5', 'padding': '10px', 'border-radius': '6px' }
});
});
installSection.addRow(row => {
row.addCheckbox('hub', {
label: 'Smart-home hub: control every motorized shade from your phone and voice assistant, with sunrise and sunset schedules (+' + money(prices.hubEach) + ' per home)',
defaultValue: false,
isVisible: () => motorizedCount() > 0
}, '1fr');
row.addDropdown('timing', {
label: 'When would you like this done?',
options: Object.keys(TIMINGS).map(id => ({ id, name: TIMINGS[id]! })),
defaultValue: 'month',
isRequired: true
}, '1fr');
});
// ---------------------------------------------------------------- Pricing
// Surcharges that scale with the area: fabric, cell, material and stain.
const areaFactor = (w: WindowLine): number => {
let pct = 0;
if (w.opacity === 'blackout') pct += prices.blackoutPct;
if (w.opacity === 'solar') pct += prices.solarPct;
if (w.doubleCell) pct += prices.doubleCellPct;
if (w.realWood) pct += prices.realWoodPct;
if (w.type === 'shutter' && FINISHES[w.color]?.stained) pct += prices.stainedPct;
return 1 + pct / 100;
};
// The billed area: never below the floor the company set.
const billedSqft = (w: WindowLine): number => Math.max(w.sqft, prices.minSqft);
// Options per window: top-down/bottom-up, a valance, a motor.
const optionsEach = (w: WindowLine): number => (w.tdbu ? prices.tdbuEach : 0) + (w.valance ? prices.valanceEach : 0) + CONTROLS[w.control]!.price;
// One treatment: the billed area at the rate with the surcharges, then the options.
const windowEach = (w: WindowLine): number => billedSqft(w) * TREATMENTS[w.type]!.rate * areaFactor(w) + optionsEach(w);
const treatmentsCost = form.computedValue(() => windowList().reduce((sum, w) => sum + windowEach(w) * w.quantity, 0));
const installCost = form.computedValue(() => withInstall() ? windowCount() * prices.installEach : 0);
const hubCost = form.computedValue(() => motorizedCount() > 0 && installSection.checkbox('hub')?.value() ? prices.hubEach : 0);
const beforeTrip = form.computedValue(() => treatmentsCost() + installCost() + hubCost());
const tripFee = form.computedValue(() => withInstall() && beforeTrip() < prices.tripFreeFrom ? prices.tripFee : 0);
const discountPct = form.computedValue(() => windowCount() >= 10 ? prices.disc10Pct : windowCount() >= 5 ? prices.disc5Pct : 0);
const subtotal = form.computedValue(() => beforeTrip() + tripFee());
const discount = form.computedValue(() => subtotal() * discountPct() / 100);
const afterDiscount = form.computedValue(() => subtotal() - discount());
const promo = form.computedValue(() => afterDiscount() * prices.promoPct / 100);
const afterPromo = form.computedValue(() => afterDiscount() - promo());
// The minimum holds last: a promotion may never take an order below what the trip costs.
const minimumApplied = form.computedValue(() => afterPromo() < prices.minOrder);
const total = form.computedValue(() => Math.round(Math.max(afterPromo(), prices.minOrder)));
const describeService = (): string => withInstall()
? 'in-home measure and installation, ' + windowsWord(windowCount())
: 'shipped to the customer, customer installs';
// ---------------------------------------------------------------- Step 3: estimate and contact
const quotePage = steps.addPage('quote');
quotePage.addRow(row => {
row.addTextPanel('quoteTitle', {
label: 'Step 3 of 3: Your quote',
computedValue: () => withInstall()
? 'A designer comes out for a free in-home measure within ' + prices.measureDays + ' business days, with fabric and finish samples. Installation is about ' + prices.leadWeeks + ' weeks from the confirmed sizes.'
: 'Your order ships in about ' + prices.leadWeeks + ' weeks from the confirmed sizes, with the brackets, screws and instructions.'
});
});
quotePage.addRow(row => {
row.addTextPanel('promoNote', {
computedValue: () => '🎉 ' + prices.promoText + ': ' + prices.promoPct + '% off is already in the estimate below, ' + money(promo()) + ' less.',
isVisible: () => promo() > 0,
customStyles: { 'font-size': '0.95rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px', 'font-weight': '500' }
});
});
const summarySection = quotePage.addSubform('summary', { title: '💰 What Makes Up the Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addTextPanel('windowsReceipt', {
computedValue: () => windowList().map(w => {
const billed = billedSqft(w);
const note = [
billed > w.sqft ? 'billed at ' + billed.toFixed(1) + ' sq ft minimum' : billed.toFixed(1) + ' sq ft',
MOUNTS[w.mount]!.split(' (')[0]!.toLowerCase(), colorName(w).toLowerCase(), ...optionNames(w), controlName(w)
].filter(part => part !== '').join(', ');
return TREATMENTS[w.type]!.short + ' ' + sizeText(w) + ' x ' + w.quantity + ' = ' + money(windowEach(w) * w.quantity) + ' (' + note + ')';
}).join('\n'),
customStyles: { 'font-size': '0.9rem', 'color': '#334155', 'white-space': 'pre-line', 'background': '#f8fafc', 'padding': '10px', 'border-radius': '6px' }
});
});
summarySection.addRow(row => {
row.addPriceDisplay('treatmentsCost', {
label: () => 'Window treatments (' + windowsWord(windowCount()) + ')',
computedValue: () => Math.round(treatmentsCost()),
decimals: 0,
variant: 'default'
}, '1fr');
row.addPriceDisplay('installCost', {
label: () => withInstall() ? 'Measure and installation' : 'Shipped, customer installs',
computedValue: () => installCost(),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('hubCost', {
label: () => 'Smart-home hub (' + motorizedCount() + ' motorized)',
computedValue: () => hubCost(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => hubCost() > 0
}, '1fr');
row.addPriceDisplay('tripFee', {
label: 'Trip charge (waived from ' + money(prices.tripFreeFrom) + ')',
computedValue: () => tripFee(),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => tripFee() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('discount', {
label: () => discountPct() + '% off for ' + windowsWord(windowCount()),
computedValue: () => -Math.round(discount()),
decimals: 0,
variant: 'success',
isVisible: () => discount() > 0
}, '1fr');
row.addPriceDisplay('promo', {
label: prices.promoText + ' (-' + prices.promoPct + '%)',
computedValue: () => -Math.round(promo()),
decimals: 0,
variant: 'success',
isVisible: () => promo() > 0
}, '1fr');
row.addPriceDisplay('minimum', {
label: 'Minimum order (small orders are billed at ' + money(prices.minOrder) + ')',
computedValue: () => Math.round(total() - afterPromo()),
decimals: 0,
variant: 'default',
prefix: '+',
isVisible: () => minimumApplied()
}, '1fr');
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('estimate', {
label: 'Estimated cost',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: () => (withInstall() ? 'installed' : 'shipped') + ', before sales tax'
});
});
summarySection.addRow(row => {
row.addTextPanel('financingNote', {
computedValue: () => '💳 ' + prices.financingNote,
isVisible: () => prices.financingNote.length > 0,
customStyles: { 'font-size': '0.9rem', 'color': '#475569' }
});
});
const contactSection = quotePage.addSubform('contact', { title: '📋 Contact Details', isCollapsible: false });
contactSection.addRow(row => {
row.addTextPanel('contactIntro', {
computedValue: () => withInstall() ? 'We e-mail the PDF estimate and call to book the measure.' : 'We e-mail the PDF estimate and call to confirm the sizes.',
customStyles: { 'font-size': '0.9rem', 'color': '#475569' }
});
});
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');
row.addEmail('email', { label: 'Email (for the PDF estimate)', placeholder: 'john@example.com', isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('city', { label: 'City, state', placeholder: 'Scottsdale, AZ', isRequired: true }, '1fr');
row.addTextbox('zip', { label: 'ZIP code (where we come out to measure)', placeholder: '85251', isRequired: true }, '1fr');
row.addDropdown('callTime', {
label: 'Best time to call?',
options: Object.keys(CALL_TIMES).map(id => ({ id, name: CALL_TIMES[id]! })),
defaultValue: 'any'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else?',
placeholder: 'E.g. an arched window, tile in the reveal, HOA rules for the street side, gate code, days that work for the measure',
rows: 3,
maxLength: 1500
});
});
// ---------------------------------------------------------------- Sticky summary on every step
const finalSection = form.addSubform('final', { title: '🧾 Estimate', isCollapsible: false, sticky: 'bottom' });
finalSection.addRow(row => {
row.addPriceDisplay('total', {
label: () => 'Estimated cost (' + windowsWord(windowCount()) + ')',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: () => (withInstall() ? 'installed' : 'shipped') + ', before sales tax'
});
});
finalSection.addRow(row => {
row.addTextPanel('disclaimer', {
computedValue: () => 'Sizes are confirmed at the in-home measure.'
+ (discountPct() > 0 ? ' Includes ' + discountPct() + '% off for ' + windowsWord(windowCount()) + '.' : minimumApplied() ? ' Billed at our minimum order.' : ''),
customStyles: { 'font-size': '0.85rem', 'color': '#94a3b8', 'font-style': 'italic' }
});
});
form.configureSubmitButton({ label: 'Request My Quote' });
const whenWeCall = (): string => {
const when = contactSection.dropdown('callTime')?.value();
return when === 'morning' ? 'before noon' : when === 'afternoon' ? 'after noon' : 'shortly';
};
form.configureCompletionScreen({
type: 'text',
title: 'Your request is with a designer',
message: () => withInstall()
? 'Your estimate of ' + money(total()) + ' is on its way to your inbox. We will call you ' + whenWeCall() + ' to book your free in-home measure within ' + prices.measureDays + ' business days.'
: 'Your estimate of ' + money(total()) + ' is on its way to your inbox. We will call you ' + whenWeCall() + ' to confirm the sizes before we cut the fabric.'
});
// ---------------------------------------------------------------- PDF: the estimate with the window table
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'window-shade-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the PDF estimate',
header: {
title: 'Window treatment estimate',
subtitle: windowsWord(windowCount()) + ' - ' + (withInstall() ? 'measured and installed' : 'shipped to you')
},
footer: { text: 'Estimate only. Sizes are confirmed at the in-home measure.', 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() || ''));
section.addRow(row => row
.addField('City and ZIP', [contactSection.textbox('city')?.value() || '', contactSection.textbox('zip')?.value() || ''].filter(part => part !== '').join(' '))
.addField('Best time to call', CALL_TIMES[pick(CALL_TIMES, contactSection.dropdown('callTime')?.value(), 'any')]!));
});
pdf.addSection('Windows', section => {
section.addTable(
['Treatment', 'Size', 'Qty', 'Mount', 'Room', 'Color and options'],
windowList().map(w => [
TREATMENTS[w.type]!.short,
sizeText(w),
String(w.quantity),
MOUNTS[w.mount]!.split(' (')[0]!,
ROOMS[w.room]!,
[colorName(w), ...optionNames(w), controlName(w)].filter(part => part !== '').join(', ')
])
);
});
pdf.addSection('Service and timing', section => {
section.addRow(row => row
.addField('Service', withInstall() ? 'free in-home measure and installation' : 'shipped, customer installs')
.addField('Smart-home hub', motorizedCount() > 0 ? (hubCost() > 0 ? 'yes' : 'no') : '-')
.addField('Timing', TIMINGS[pick(TIMINGS, installSection.dropdown('timing')?.value(), 'month')]!));
});
pdf.addSection('Estimated cost', section => {
const rows: string[][] = windowList().map(w => [
'Window ' + w.number + ': ' + TREATMENTS[w.type]!.short.toLowerCase() + ', ' + sizeText(w) + ', ' + billedSqft(w).toFixed(1) + ' sq ft'
+ (optionNames(w).length > 0 ? ', ' + optionNames(w).join(', ') : '') + (CONTROLS[w.control]!.motor ? ', ' + controlName(w) : ''),
String(w.quantity),
money(windowEach(w) * w.quantity)
]);
rows.push(['Measure and installation', withInstall() ? String(windowCount()) : '', withInstall() ? money(installCost()) : 'customer installs']);
if (hubCost() > 0) rows.push(['Smart-home hub', '1', money(hubCost())]);
if (tripFee() > 0) rows.push(['Trip charge', '', money(tripFee())]);
if (discount() > 0) rows.push([discountPct() + '% off for ' + windowsWord(windowCount()), '', '-' + money(discount())]);
if (promo() > 0) rows.push([prices.promoText + ' (-' + prices.promoPct + '%)', '', '-' + money(promo())]);
if (minimumApplied()) rows.push(['Minimum order', '', money(total() - afterPromo())]);
section.addTable(['Item', 'Qty', 'Amount'], rows);
section.addSpacer(8);
section.addRow(row => row
.addField('Estimated', money(total()))
.addField('Note', (withInstall() ? 'installed' : 'shipped') + ', before sales tax; sizes confirmed at the measure'));
section.addRow(row => row.addField('Service', describeService()));
});
pdf.addSection('What happens next', section => {
if (withInstall()) {
section.addText('1. We call you ' + whenWeCall() + ' to go over your request.');
section.addText('2. A designer comes out for a free in-home measure within ' + prices.measureDays + ' business days, with fabric and finish samples.');
section.addText('3. Installation is about ' + prices.leadWeeks + ' weeks from the confirmed sizes.');
} else {
section.addText('1. We call you ' + whenWeCall() + ' to confirm the sizes before we cut the fabric.');
section.addText('2. Your order ships in about ' + prices.leadWeeks + ' weeks, with brackets, screws and instructions.');
}
});
const notes = contactSection.textarea('notes')?.value();
if (notes) pdf.addSection('Notes', section => section.addText(notes));
pdf.addSection('Terms', section => {
section.addText('This is an estimate, not a binding offer. Prices are before sales tax and may change after the in-home measure, which confirms sizes, mount depth and fabric. '
+ 'Custom window treatments are made to order and cannot be returned.');
});
});
}