export function customGlassCalculator(form: FormTs) {
// Timing promised to the customer (business days)
const timing = {
leadDays: 7, // mirrors and glass ready after the confirmed size
showerDays: 15, // shower installed after the measure
measureDays: 3 // free on-site measure within
};
// Promotion on the whole order, on top of the quantity discount (0 hides the banner)
const promotion = {
percent: 0,
name: 'Free polished edges this month'
};
// Glass prices per square foot at 1/4 in
const glassRates: Record<string, number> = {
'mirror': 14,
'painted': 42,
'glass': 16
};
const paneRules = {
minimumSqft: 3, // the smallest area a pane is billed for
minimumOrder: 125 // smaller orders are billed at this amount
};
// Percent surcharges on the glass
const surcharges = {
thickness38: 45,
thickness12: 90,
curvedCut: 30, // any shape other than a rectangle
lowIron: 30,
frosted: 35,
bronze: 20,
tempered: 35
};
// Edgework and LED by the linear foot of the perimeter
const perFoot = {
polishedEdge: 3,
bevelEdge: 7,
led: 28
};
// Per-piece extras
const perPiece = {
hole: 12,
cutout: 28,
defogPad: 70
};
// Frameless showers
const shower = {
sqft: 44, // 3/8 in clear tempered, polished edges
thickness12Pct: 30,
doorHardware: 340, // hinges, ladder pull, seals (polished chrome)
panelClamps: 95, // per fixed panel
sliderHardware: 560, // header bar, rollers, guide, pulls
nickelPct: 10,
blackPct: 20,
goldPct: 35,
installation: 480,
coating: 175,
removal: 150
};
// Delivery and installation of panes
const service = {
installPerPane: 85,
delivery: 65,
freeDeliveryFrom: 600
};
const quantityDiscount = {
fromPieces: 3,
percent: 7
};
const money = (value: number) => {
const rounded = Math.round(Math.abs(value));
const whole = String(rounded).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (value < 0 ? '-' : '') + '$' + whole;
};
const nameOf = (map: Record<string, string>, key: string) => map[key] ?? key;
const inches = (value: number) => (Math.round(value * 2) / 2) + ' in';
const KINDS: Record<string, string> = { 'mirror': 'Mirror', 'shower': 'Frameless shower enclosure', 'painted': 'Back-painted glass', 'glass': 'Clear glass' };
const SHAPES: Record<string, string> = { 'rectangle': 'rectangle', 'rounded': 'rounded corners', 'circle': 'circle', 'oval': 'oval' };
const EDGES: Record<string, string> = { 'seamed': 'seamed edge', 'polished': 'flat polished edge', 'beveled': '1 in bevel' };
const THICKNESSES: Record<string, string> = { '0.25': '1/4 in', '0.375': '3/8 in', '0.5': '1/2 in' };
const TINTS: Record<string, string> = { 'clear': 'clear', 'lowiron': 'low-iron', 'frosted': 'frosted', 'bronze': 'bronze tint' };
const PAINTS: Record<string, string> = { 'white': 'white', 'gray': 'gray', 'charcoal': 'charcoal', 'black': 'black', 'sage': 'sage' };
const LAYOUTS: Record<string, string> = { 'door': 'single door', 'inline': 'door and inline panel', 'corner': '90-degree corner', 'neo': 'neo-angle', 'sliding': 'bypass slider' };
const FINISHES: Record<string, string> = { 'chrome': 'polished chrome', 'nickel': 'brushed nickel', 'matteblack': 'matte black', 'gold': 'brushed gold' };
const SERVICES: Record<string, string> = { 'install': 'delivered and installed', 'delivery': 'delivered, no installation', 'pickup': 'pickup at our shop' };
const headerStyles: CustomStyles = { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' };
const stepStyles: CustomStyles = { 'font-size': '1.1rem', 'font-weight': '600', 'color': '#1e293b' };
const hintStyles: CustomStyles = { 'font-size': '0.9rem', 'color': '#64748b' };
const infoStyles: CustomStyles = { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' };
const warningStyles: CustomStyles = { 'font-size': '0.9rem', 'color': '#9a3412', 'background': '#ffedd5', 'padding': '10px', 'border-radius': '6px' };
const successStyles: CustomStyles = { 'font-size': '0.95rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px', 'font-weight': '500' };
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Custom Glass & Mirror Estimate',
customStyles: headerStyles
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Mirrors, back-painted glass, clear glass and frameless showers in inches. Priced by the square foot and linear foot, the way a glazier prices them.',
customStyles: hintStyles
});
});
form.addSpacer({ height: 10 });
const pages = form.addPages('steps');
// Step 1: Product
const productPage = pages.addPage('product');
productPage.addRow(row => {
row.addTextPanel('productTitle', {
computedValue: () => 'Step 1 of 4: Product',
customStyles: stepStyles
});
});
const productSection = productPage.addSubform('product', { title: 'What are we making for you?', isCollapsible: false });
productSection.addRow(row => {
row.addRadioButton('kind', {
label: 'Product',
options: [
{ id: 'mirror', name: 'Mirror - bathroom, vanity, closet and wall mirrors, with LED if you like (' + money(glassRates['mirror'] ?? 0) + '/sq ft)' },
{ id: 'shower', name: 'Frameless shower - heavy tempered glass, no frame, installed by our crew (' + money(shower.sqft) + '/sq ft)' },
{ id: 'painted', name: 'Back-painted glass - kitchen backsplashes and wall panels in any color (' + money(glassRates['painted'] ?? 0) + '/sq ft)' },
{ id: 'glass', name: 'Clear glass - splash panels, tabletops and shelves cut to size (' + money(glassRates['glass'] ?? 0) + '/sq ft)' }
],
defaultValue: 'mirror',
orientation: 'vertical',
isRequired: true
});
});
const kind = form.computedValue(() => productSection.radioButton('kind')?.value() || 'mirror');
const isShower = form.computedValue(() => kind() === 'shower');
const isMirror = form.computedValue(() => kind() === 'mirror');
const isPane = form.computedValue(() => !isShower());
productSection.addRow(row => {
row.addTextPanel('measureInfo', {
computedValue: () => isShower()
? 'We measure every shower ourselves. Walls and curbs are rarely plumb, so the glass is cut only after our free on-site measure, within '
+ timing.measureDays + ' business days. The sizes you pick here give you the price.'
: 'The size is final. Glass cut to your size cannot be returned or recut. For a first order we recommend our free on-site measure, within '
+ timing.measureDays + ' business days.',
customStyles: warningStyles
});
});
// Step 2: Configuration
const configPage = pages.addPage('config');
configPage.addRow(row => {
row.addTextPanel('configTitle', {
computedValue: () => isShower() ? 'Step 2 of 4: Layout and size' : 'Step 2 of 4: Size and finish',
customStyles: stepStyles
});
});
// Pane: size, shape, thickness, edge, holes, extras
const paneSection = configPage.addSubform('pane', {
title: 'Size and finish',
isCollapsible: false,
isVisible: () => isPane()
});
paneSection.addRow(row => {
row.addSlider('width', {
label: 'Width',
unit: 'in',
min: 12,
max: 96,
step: 1,
defaultValue: 36,
tooltip: 'For a circle, the smaller side is the diameter'
}, '1fr');
row.addSlider('height', {
label: 'Height',
unit: 'in',
min: 12,
max: 84,
step: 1,
defaultValue: 30,
tooltip: 'The finished size of the pane'
}, '1fr');
});
paneSection.addRow(row => {
row.addRadioButton('shape', {
label: 'Shape',
options: [
{ id: 'rectangle', name: 'Rectangle (included)' },
{ id: 'rounded', name: 'Rounded corners (+' + surcharges.curvedCut + '%)' },
{ id: 'circle', name: 'Circle (+' + surcharges.curvedCut + '%)' },
{ id: 'oval', name: 'Oval (+' + surcharges.curvedCut + '%)' }
],
defaultValue: 'rectangle',
orientation: 'vertical'
}, '1fr');
row.addRadioButton('edge', {
label: 'Edge',
options: [
{ id: 'seamed', name: 'Seamed (included)' },
{ id: 'polished', name: 'Flat polish (+' + money(perFoot.polishedEdge) + '/ft)' },
{ id: 'beveled', name: '1 in bevel (+' + money(perFoot.bevelEdge) + '/ft)' }
],
defaultValue: 'polished',
orientation: 'vertical'
}, '1fr');
});
paneSection.addRow(row => {
row.addDropdown('thickness', {
label: 'Thickness',
options: [
{ id: '0.25', name: '1/4 in: mirrors, backsplashes, panels' },
{ id: '0.375', name: '3/8 in: shelves and splash guards (+' + surcharges.thickness38 + '%)' },
{ id: '0.5', name: '1/2 in: tabletops and freestanding panes (+' + surcharges.thickness12 + '%)' }
],
defaultValue: '0.25'
}, '1fr');
row.addInteger('pieces', {
label: 'Pieces of this size',
min: 1,
max: 10,
defaultValue: 1,
tooltip: quantityDiscount.percent + '% off from ' + quantityDiscount.fromPieces + ' pieces of the same size and finish'
}, '1fr');
});
paneSection.addRow(row => {
row.addRadioButton('tint', {
label: 'Glass',
options: [
{ id: 'clear', name: 'Clear (included)' },
{ id: 'lowiron', name: 'Low-iron (+' + surcharges.lowIron + '%)' },
{ id: 'frosted', name: 'Frosted (+' + surcharges.frosted + '%)' },
{ id: 'bronze', name: 'Bronze tint (+' + surcharges.bronze + '%)' }
],
defaultValue: 'clear',
orientation: 'vertical',
isVisible: () => kind() === 'glass'
}, '1fr');
row.addRadioButton('paint', {
label: 'Paint color',
options: [
{ id: 'white', name: 'White' },
{ id: 'gray', name: 'Gray' },
{ id: 'charcoal', name: 'Charcoal' },
{ id: 'black', name: 'Black' },
{ id: 'sage', name: 'Sage' }
],
defaultValue: 'charcoal',
orientation: 'vertical',
tooltip: 'Any other color matched to a paint chip on request',
isVisible: () => kind() === 'painted'
}, '1fr');
});
paneSection.addRow(row => {
row.addInteger('holes', {
label: 'Mounting holes (' + money(perPiece.hole) + ' each)',
min: 0,
max: 4,
defaultValue: 0,
tooltip: 'Drilled for standoffs or clips'
}, '1fr');
row.addInteger('cutouts', {
label: 'Outlet cutouts (' + money(perPiece.cutout) + ' each)',
min: 0,
max: 3,
defaultValue: 0,
tooltip: 'For an outlet or a switch; rectangles and rounded corners only',
isVisible: () => {
const shape = paneSection.radioButton('shape')?.value();
return shape !== 'circle' && shape !== 'oval';
}
}, '1fr');
});
paneSection.addRow(row => {
row.addCheckbox('led', {
label: 'LED backlight (' + money(perFoot.led) + ' per linear foot of the edge, driver included)',
defaultValue: false,
isVisible: () => isMirror()
}, '1fr');
row.addCheckbox('defog', {
label: 'Anti-fog pad (' + money(perPiece.defogPad) + ', keeps the mirror clear after a shower)',
defaultValue: false,
isVisible: () => isMirror()
}, '1fr');
});
paneSection.addRow(row => {
row.addCheckbox('tempered', {
label: 'Tempered safety glass (+' + surcharges.tempered + '%): required behind a cooktop, recommended for tabletops',
defaultValue: false,
isVisible: () => kind() === 'glass' || kind() === 'painted'
});
});
// Shower: layout, sizes, glass, hardware
const showerSection = configPage.addSubform('shower', {
title: 'Layout, glass and hardware',
isCollapsible: false,
isVisible: () => isShower()
});
showerSection.addRow(row => {
row.addRadioButton('layout', {
label: 'Shower layout',
options: [
{ id: 'door', name: 'Single door - one swinging door between two walls' },
{ id: 'inline', name: 'Door and inline panel - a fixed panel beside the door, the most common' },
{ id: 'corner', name: '90-degree corner - door in front, a return panel on the side' },
{ id: 'neo', name: 'Neo-angle - a door on the angle between two panels' },
{ id: 'sliding', name: 'Bypass slider - two panes, one rolls past the other on a header bar' }
],
defaultValue: 'inline',
orientation: 'vertical'
});
});
const layout = form.computedValue(() => showerSection.radioButton('layout')?.value() || 'inline');
const hasPanel = form.computedValue(() => isShower() && (layout() === 'inline' || layout() === 'corner' || layout() === 'neo'));
const isSlider = form.computedValue(() => isShower() && layout() === 'sliding');
showerSection.addRow(row => {
row.addSlider('doorWidth', {
label: 'Door width',
unit: 'in',
min: 22,
max: 36,
step: 0.5,
defaultValue: 28,
tooltip: 'Most frameless doors are 24 to 30 in',
isVisible: () => !isSlider()
}, '1fr');
row.addSlider('opening', {
label: 'Opening width',
unit: 'in',
min: 44,
max: 72,
step: 0.5,
defaultValue: 60,
tooltip: 'Wall to wall; a tub is usually 60 in',
isVisible: () => isSlider()
}, '1fr');
row.addSlider('showerHeight', {
label: 'Glass height',
unit: 'in',
min: 60,
max: 90,
step: 1,
defaultValue: 72,
tooltip: '72 in is standard, 78 in to clear a tall showerhead'
}, '1fr');
});
showerSection.addRow(row => {
row.addSlider('panelWidth', {
label: () => layout() === 'corner' ? 'Return panel (shower depth)' : layout() === 'neo' ? 'Side panels (each)' : 'Inline panel width',
unit: 'in',
min: 12,
max: 48,
step: 0.5,
defaultValue: 30,
isVisible: () => hasPanel()
});
});
showerSection.addRow(row => {
row.addRadioButton('showerThickness', {
label: 'Glass thickness',
options: [
{ id: '0.375', name: '3/8 in (included)' },
{ id: '0.5', name: '1/2 in (+' + shower.thickness12Pct + '%)' }
],
defaultValue: '0.375',
orientation: 'vertical'
}, '1fr');
row.addRadioButton('showerGlass', {
label: 'Glass',
options: [
{ id: 'clear', name: 'Clear (included)' },
{ id: 'lowiron', name: 'Low-iron (+' + surcharges.lowIron + '%)' }
],
defaultValue: 'clear',
orientation: 'vertical'
}, '1fr');
row.addRadioButton('finish', {
label: 'Hardware finish',
options: [
{ id: 'chrome', name: 'Polished chrome (included)' },
{ id: 'nickel', name: 'Brushed nickel (+' + shower.nickelPct + '%)' },
{ id: 'matteblack', name: 'Matte black (+' + shower.blackPct + '%)' },
{ id: 'gold', name: 'Brushed gold (+' + shower.goldPct + '%)' }
],
defaultValue: 'chrome',
orientation: 'vertical',
tooltip: 'Hinges, pull and clamps; match your faucet'
}, '1fr');
});
// Reactive values
const paneWidth = form.computedValue(() => paneSection.slider('width')?.value() ?? 36);
const paneHeight = form.computedValue(() => paneSection.slider('height')?.value() ?? 30);
const shape = form.computedValue(() => isShower() ? 'rectangle' : paneSection.radioButton('shape')?.value() || 'rectangle');
const thickness = form.computedValue(() => isShower()
? showerSection.radioButton('showerThickness')?.value() || '0.375'
: paneSection.dropdown('thickness')?.value() || '0.25');
const edge = form.computedValue(() => paneSection.radioButton('edge')?.value() || 'polished');
const tint = form.computedValue(() => paneSection.radioButton('tint')?.value() || 'clear');
const paint = form.computedValue(() => paneSection.radioButton('paint')?.value() || 'charcoal');
const canCutout = form.computedValue(() => isPane() && (shape() === 'rectangle' || shape() === 'rounded'));
const holeCount = form.computedValue(() => isPane() ? paneSection.integer('holes')?.value() ?? 0 : 0);
const cutoutCount = form.computedValue(() => canCutout() ? paneSection.integer('cutouts')?.value() ?? 0 : 0);
const withLed = form.computedValue(() => isMirror() && paneSection.checkbox('led')?.value() === true);
const withDefog = form.computedValue(() => isMirror() && paneSection.checkbox('defog')?.value() === true);
const tempered = form.computedValue(() => (kind() === 'glass' || kind() === 'painted') && paneSection.checkbox('tempered')?.value() === true);
const pieces = form.computedValue(() => isShower() ? 1 : Math.max(1, paneSection.integer('pieces')?.value() ?? 1));
const doorWidth = form.computedValue(() => showerSection.slider('doorWidth')?.value() ?? 28);
const opening = form.computedValue(() => showerSection.slider('opening')?.value() ?? 60);
const panelWidth = form.computedValue(() => showerSection.slider('panelWidth')?.value() ?? 30);
const showerHeight = form.computedValue(() => showerSection.slider('showerHeight')?.value() ?? 72);
const showerGlass = form.computedValue(() => showerSection.radioButton('showerGlass')?.value() || 'clear');
const finish = form.computedValue(() => showerSection.radioButton('finish')?.value() || 'chrome');
// Step 3: Installation and delivery
const servicePage = pages.addPage('service');
servicePage.addRow(row => {
row.addTextPanel('serviceTitle', {
computedValue: () => isShower() ? 'Step 3 of 4: Installation' : 'Step 3 of 4: Delivery',
customStyles: stepStyles
});
});
const serviceSection = servicePage.addSubform('service', {
title: () => isShower() ? 'Old enclosure and coating' : 'Install, deliver or pick up',
isCollapsible: false
});
serviceSection.addRow(row => {
row.addRadioButton('service', {
label: 'How do you want it?',
options: [
{ id: 'install', name: 'Delivered and installed - clips, standoffs or mirror mastic, level and clean (' + money(service.installPerPane) + ' per pane)' },
{ id: 'delivery', name: 'Delivered only - on a glass rack, to your door (' + money(service.delivery) + ', free from ' + money(service.freeDeliveryFrom) + ')' },
{ id: 'pickup', name: 'Pickup at our shop - packed with corner guards, ready when we call (no charge)' }
],
defaultValue: 'install',
orientation: 'vertical',
isVisible: () => isPane()
});
});
serviceSection.addRow(row => {
row.addTextPanel('crewInfo', {
computedValue: () => 'Delivered and installed by our crew. Frameless glass is heavy and set to fractions of an inch, so we deliver and install every shower ourselves for '
+ money(shower.installation) + '. Leave the silicone 24 hours before the first shower.',
customStyles: infoStyles,
isVisible: () => isShower()
});
});
serviceSection.addRow(row => {
row.addCheckbox('removal', {
label: 'Remove my old enclosure (' + money(shower.removal) + '): framed door or tub slider out, holes patched, hauled away',
defaultValue: false,
isVisible: () => isShower()
}, '1fr');
row.addCheckbox('coating', {
label: 'Protective glass coating (' + money(shower.coating) + '): keeps water spots and hard-water stains off the glass',
defaultValue: false,
isVisible: () => isShower()
}, '1fr');
});
// Pricing
const serviceChoice = form.computedValue(() => isShower() ? 'install' : serviceSection.radioButton('service')?.value() || 'install');
// The finished pane in square feet: a circle and an oval take less than the rectangle around them
const paneArea = form.computedValue(() => {
const w = paneWidth() / 12;
const h = paneHeight() / 12;
if (shape() === 'circle') {
const r = Math.min(w, h) / 2;
return Math.PI * r * r;
}
if (shape() === 'oval') return Math.PI * (w / 2) * (h / 2);
return w * h;
});
// The perimeter in feet, which edgework and the LED strip are charged by
const perimeter = form.computedValue(() => {
const w = paneWidth() / 12;
const h = paneHeight() / 12;
if (shape() === 'circle') return Math.PI * Math.min(w, h);
if (shape() === 'oval') {
// Ramanujan's approximation: an ellipse has no elementary formula
const a = w / 2;
const b = h / 2;
return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
}
if (shape() === 'rounded') {
const r = Math.min(1.5 / 12, w / 2, h / 2);
return 2 * (w + h) - 8 * r + 2 * Math.PI * r;
}
return 2 * (w + h);
});
const billableArea = form.computedValue(() => Math.max(paneArea(), paneRules.minimumSqft));
const paneRate = form.computedValue(() => glassRates[kind()] ?? glassRates['mirror'] ?? 0);
// The pane: area with the minimum, rate, thickness, cutting, tint and tempering
const paneGlass = form.computedValue(() => {
if (isShower()) return 0;
const byThickness = thickness() === '0.5' ? 1 + surcharges.thickness12 / 100
: thickness() === '0.375' ? 1 + surcharges.thickness38 / 100
: 1;
const byShape = shape() === 'rectangle' ? 1 : 1 + surcharges.curvedCut / 100;
const byTint = kind() !== 'glass' ? 1
: tint() === 'lowiron' ? 1 + surcharges.lowIron / 100
: tint() === 'frosted' ? 1 + surcharges.frosted / 100
: tint() === 'bronze' ? 1 + surcharges.bronze / 100
: 1;
const byTempering = tempered() ? 1 + surcharges.tempered / 100 : 1;
return billableArea() * paneRate() * byThickness * byShape * byTint * byTempering;
});
const edgeCost = form.computedValue(() => {
if (isShower()) return 0;
if (edge() === 'beveled') return perimeter() * perFoot.bevelEdge;
if (edge() === 'polished') return perimeter() * perFoot.polishedEdge;
return 0;
});
const drilling = form.computedValue(() => holeCount() * perPiece.hole + cutoutCount() * perPiece.cutout);
const ledCost = form.computedValue(() => withLed() ? perimeter() * perFoot.led : 0);
const defogCost = form.computedValue(() => withDefog() ? perPiece.defogPad : 0);
// Everything charged per pane, times the pieces ordered
const perPane = form.computedValue(() => paneGlass() + edgeCost() + drilling() + ledCost() + defogCost());
const panesTotal = form.computedValue(() => isShower() ? 0 : perPane() * pieces());
const paneInstall = form.computedValue(() => isPane() && serviceChoice() === 'install' ? pieces() * service.installPerPane : 0);
// Shower glass in square feet, pane by pane as the layout stands it up
const fixedPanels = form.computedValue(() => {
if (!isShower()) return 0;
if (layout() === 'neo') return 2;
if (layout() === 'inline' || layout() === 'corner') return 1;
return 0;
});
const showerArea = form.computedValue(() => {
if (!isShower()) return 0;
const h = showerHeight() / 12;
if (isSlider()) return 2 * ((opening() / 2 + 1) / 12) * h;
return (doorWidth() / 12 + fixedPanels() * panelWidth() / 12) * h;
});
const showerGlassCost = form.computedValue(() => {
const byThickness = thickness() === '0.5' ? 1 + shower.thickness12Pct / 100 : 1;
const byLowIron = showerGlass() === 'lowiron' ? 1 + surcharges.lowIron / 100 : 1;
return showerArea() * shower.sqft * byThickness * byLowIron;
});
const hardware = form.computedValue(() => {
if (!isShower()) return 0;
const set = isSlider() ? shower.sliderHardware : shower.doorHardware + fixedPanels() * shower.panelClamps;
const byFinish = finish() === 'nickel' ? 1 + shower.nickelPct / 100
: finish() === 'matteblack' ? 1 + shower.blackPct / 100
: finish() === 'gold' ? 1 + shower.goldPct / 100
: 1;
return set * byFinish;
});
const showerInstall = form.computedValue(() => isShower() ? shower.installation : 0);
const removal = form.computedValue(() => isShower() && serviceSection.checkbox('removal')?.value() ? shower.removal : 0);
const coating = form.computedValue(() => isShower() && serviceSection.checkbox('coating')?.value() ? shower.coating : 0);
const showerTotal = form.computedValue(() => showerGlassCost() + hardware() + showerInstall() + removal() + coating());
const beforeDelivery = form.computedValue(() => isShower() ? showerTotal() : panesTotal() + paneInstall());
const delivery = form.computedValue(() => {
if (isShower() || serviceChoice() === 'pickup') return 0;
return beforeDelivery() >= service.freeDeliveryFrom ? 0 : service.delivery;
});
const quantityPercent = form.computedValue(() => isPane() && pieces() >= quantityDiscount.fromPieces ? quantityDiscount.percent : 0);
const subtotal = form.computedValue(() => beforeDelivery() + delivery());
const quantityAmount = form.computedValue(() => subtotal() * quantityPercent() / 100);
const beforePromo = form.computedValue(() => subtotal() - quantityAmount());
const promoAmount = form.computedValue(() => beforePromo() * promotion.percent / 100);
const afterPromo = form.computedValue(() => Math.round(beforePromo() - promoAmount()));
// The minimum holds last: a promotion may never take an order below what the bench costs
const total = form.computedValue(() => Math.max(afterPromo(), paneRules.minimumOrder));
const minimumApplied = form.computedValue(() => afterPromo() < paneRules.minimumOrder);
const minimumAmount = form.computedValue(() => minimumApplied() ? total() - afterPromo() : 0);
const paneSize = (): string => shape() === 'circle'
? inches(Math.min(paneWidth(), paneHeight())) + ' diameter'
: paneWidth() + ' x ' + paneHeight() + ' in';
const describeShower = (): string => {
const size = isSlider()
? inches(opening()) + ' opening'
: 'door ' + inches(doorWidth()) + (hasPanel() ? (layout() === 'neo' ? ', panels ' : ', panel ') + inches(panelWidth()) : '');
return nameOf(LAYOUTS, layout()) + ', ' + size + ', ' + inches(showerHeight()) + ' high';
};
const describeGlass = (): string => {
if (isShower()) {
return nameOf(THICKNESSES, thickness()) + ' ' + (showerGlass() === 'lowiron' ? 'low-iron' : 'clear') + ' tempered, ' + nameOf(FINISHES, finish()) + ' hardware';
}
const parts: string[] = [nameOf(THICKNESSES, thickness()), nameOf(SHAPES, shape()), nameOf(EDGES, edge())];
if (kind() === 'glass') parts.push(nameOf(TINTS, tint()));
if (kind() === 'painted') parts.push(nameOf(PAINTS, paint()) + ' paint');
if (tempered()) parts.push('tempered');
return parts.join(', ');
};
const extras = (): string[] => {
const list: string[] = [];
if (holeCount() > 0) list.push(holeCount() + (holeCount() === 1 ? ' hole' : ' holes'));
if (cutoutCount() > 0) list.push(cutoutCount() + (cutoutCount() === 1 ? ' outlet cutout' : ' outlet cutouts'));
if (withLed()) list.push('LED backlight');
if (withDefog()) list.push('anti-fog pad');
return list;
};
// Step 4: Quote and contact
const quotePage = pages.addPage('quote');
quotePage.addRow(row => {
row.addTextPanel('quoteTitle', {
computedValue: () => 'Step 4 of 4: Your quote',
customStyles: stepStyles
});
});
quotePage.addRow(row => {
row.addTextPanel('promoBanner', {
computedValue: () => promotion.name + ': ' + promotion.percent + '% off is already in the price below, ' + money(promoAmount()) + ' less.',
customStyles: successStyles,
isVisible: () => promotion.percent > 0
});
});
const summarySection = quotePage.addSubform('summary', { title: 'Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addPriceDisplay('total', {
label: () => isShower() ? 'Your shower, installed' : 'Your order',
computedValue: () => total(),
currency: '$',
decimals: 0,
variant: 'large',
suffix: () => (isShower() ? 'installed' : nameOf(SERVICES, serviceChoice())) + ', before sales tax'
});
});
summarySection.addRow(row => {
row.addTextPanel('totalNote', {
computedValue: () => {
if (quantityPercent() > 0) return quantityPercent() + '% off for ' + pieces() + ' pieces is included.';
if (minimumApplied()) return 'Small orders are billed at our minimum of ' + money(paneRules.minimumOrder) + '.';
return 'Estimate before sales tax. Glass cut to size cannot be returned.';
},
customStyles: { 'font-size': '0.85rem', 'color': '#64748b', 'font-style': 'italic' }
});
});
const breakdownSection = quotePage.addSubform('breakdown', { title: 'What makes up the price', isCollapsible: false });
breakdownSection.addRow(row => {
row.addTextPanel('spec', {
computedValue: () => isShower()
? 'Shower: ' + describeShower() + '; ' + showerArea().toFixed(1) + ' sq ft of ' + describeGlass()
: nameOf(KINDS, kind()) + ' ' + paneSize() + ', ' + pieces() + (pieces() === 1 ? ' pc' : ' pcs') + '; '
+ (billableArea() > paneArea() ? 'billed at ' + billableArea().toFixed(1) + ' sq ft (minimum)' : paneArea().toFixed(1) + ' sq ft')
+ ', perimeter ' + perimeter().toFixed(1) + ' ft; ' + describeGlass()
+ (extras().length > 0 ? '; ' + extras().join(', ') : ''),
customStyles: hintStyles
});
});
// Pane breakdown
breakdownSection.addRow(row => {
row.addPriceDisplay('glassCost', {
label: () => 'Glass (' + pieces() + ' x ' + money(paneGlass()) + ')',
computedValue: () => paneGlass() * pieces(),
currency: '$',
decimals: 0,
isVisible: () => isPane()
}, '1fr');
row.addPriceDisplay('edgeworkCost', {
label: () => nameOf(EDGES, edge()) + ' (' + perimeter().toFixed(1) + ' ft per pane)',
computedValue: () => edgeCost() * pieces(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => isPane() && edgeCost() > 0
}, '1fr');
});
breakdownSection.addRow(row => {
row.addPriceDisplay('drillingCost', {
label: 'Holes and cutouts',
computedValue: () => drilling() * pieces(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => isPane() && drilling() > 0
}, '1fr');
row.addPriceDisplay('ledCost', {
label: () => 'LED backlight (' + perimeter().toFixed(1) + ' ft of strip per mirror)',
computedValue: () => ledCost() * pieces(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => ledCost() > 0
}, '1fr');
row.addPriceDisplay('defogCost', {
label: 'Anti-fog pad',
computedValue: () => defogCost() * pieces(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => defogCost() > 0
}, '1fr');
});
breakdownSection.addRow(row => {
row.addPriceDisplay('paneInstall', {
label: () => 'Installation (' + pieces() + ' x ' + money(service.installPerPane) + ')',
computedValue: () => paneInstall(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => paneInstall() > 0
}, '1fr');
row.addPriceDisplay('deliveryCost', {
label: () => delivery() > 0 ? 'Delivery' : serviceChoice() === 'pickup' ? 'Pickup at our shop' : 'Delivery (free from ' + money(service.freeDeliveryFrom) + ')',
computedValue: () => delivery(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => isPane()
}, '1fr');
});
// Shower breakdown
breakdownSection.addRow(row => {
row.addPriceDisplay('showerGlassCost', {
label: () => 'Shower glass (' + showerArea().toFixed(1) + ' sq ft)',
computedValue: () => showerGlassCost(),
currency: '$',
decimals: 0,
isVisible: () => isShower()
}, '1fr');
row.addPriceDisplay('hardwareCost', {
label: () => 'Hardware, ' + nameOf(FINISHES, finish()) + (isSlider() ? ' (header bar, rollers and pulls)' : ' (hinges, ladder pull' + (fixedPanels() > 0 ? ' and clamps)' : ')')),
computedValue: () => hardware(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => isShower()
}, '1fr');
});
breakdownSection.addRow(row => {
row.addPriceDisplay('showerInstall', {
label: 'Installation',
computedValue: () => showerInstall(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => isShower()
}, '1fr');
row.addPriceDisplay('removalCost', {
label: 'Removal of the old enclosure',
computedValue: () => removal(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => removal() > 0
}, '1fr');
row.addPriceDisplay('coatingCost', {
label: 'Protective glass coating',
computedValue: () => coating(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => coating() > 0
}, '1fr');
});
// Discounts and minimum
breakdownSection.addRow(row => {
row.addPriceDisplay('quantityDiscount', {
label: () => 'Discount for ' + pieces() + ' pieces (' + quantityPercent() + '%)',
computedValue: () => -quantityAmount(),
currency: '$',
decimals: 0,
variant: 'success',
isVisible: () => quantityAmount() > 0
}, '1fr');
row.addPriceDisplay('promoDiscount', {
label: () => promotion.name + ' (' + promotion.percent + '%)',
computedValue: () => -promoAmount(),
currency: '$',
decimals: 0,
variant: 'success',
isVisible: () => promoAmount() > 0
}, '1fr');
row.addPriceDisplay('minimumOrder', {
label: 'Minimum order',
computedValue: () => minimumAmount(),
currency: '$',
decimals: 0,
prefix: '+',
isVisible: () => minimumApplied()
}, '1fr');
});
breakdownSection.addRow(row => {
row.addTextPanel('timingInfo', {
computedValue: () => isShower()
? 'What happens next: we call to book your free on-site measure within ' + timing.measureDays + ' business days. Your shower is installed about '
+ timing.showerDays + ' business days after the measure.'
: 'What happens next: we cut and finish your glass within ' + timing.leadDays + ' business days of confirming the size.',
customStyles: infoStyles
});
});
// Contact
const contactSection = quotePage.addSubform('contact', { title: 'Contact details', isCollapsible: false });
contactSection.addRow(row => {
row.addTextPanel('contactNote', {
computedValue: () => 'We send the PDF estimate and confirm the size before we cut.',
customStyles: hintStyles
});
});
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Name',
placeholder: 'Jane Smith',
isRequired: true
}, '1fr');
row.addTextbox('phone', {
label: 'Phone',
placeholder: '(555) 123-4567',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addEmail('email', {
label: 'Email',
placeholder: 'jane@example.com',
tooltip: 'For the PDF estimate',
isRequired: true
}, '1fr');
row.addTextbox('cityZip', {
label: 'City and ZIP code',
placeholder: 'Scottsdale, 85260',
tooltip: 'Helps us book the measure or delivery'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else?',
placeholder: 'E.g. a sketch of an odd shape, a paint color to match, a tile ledge on the curb, gate code',
rows: 3,
maxLength: 1500
});
});
form.configureSubmitButton({
label: 'Request my quote'
});
form.configureCompletionScreen({
type: 'text',
title: 'Thank you!',
message: () => isShower()
? 'Your request is with us. We will call you to book the free on-site measure within ' + timing.measureDays + ' business days.'
: 'We sent the estimate to your email. We will call to confirm the size before we cut.'
});
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: isShower() ? 'shower-estimate' : 'glass-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the PDF estimate',
header: {
title: isShower() ? 'Frameless shower estimate' : 'Glass and mirror estimate',
subtitle: nameOf(KINDS, kind()) + ' - ' + (isShower() ? describeShower() : paneSize() + ', ' + pieces() + (pieces() === 1 ? ' pc' : ' pcs'))
},
footer: {
text: 'Prices before sales tax. Glass cut to size cannot be returned.',
showPageNumbers: true
}
});
pdf.addSection(isShower() ? 'Shower' : 'Pane', section => {
const rows: string[][] = isShower()
? [
['Layout', nameOf(LAYOUTS, layout())],
['Size', describeShower().split(', ').slice(1).join(', ')],
['Glass area', showerArea().toFixed(1) + ' sq ft'],
['Glass', nameOf(THICKNESSES, thickness()) + ' ' + (showerGlass() === 'lowiron' ? 'low-iron' : 'clear') + ' tempered'],
['Hardware', nameOf(FINISHES, finish())],
['Remove old enclosure', removal() > 0 ? 'yes' : 'no'],
['Protective coating', coating() > 0 ? 'yes' : 'no']
]
: [
['Product', nameOf(KINDS, kind())],
['Size', paneSize()],
['Shape', nameOf(SHAPES, shape())],
['Area', paneArea().toFixed(1) + ' sq ft'],
['Perimeter', perimeter().toFixed(1) + ' ft'],
['Glass', describeGlass()],
['Holes and extras', extras().join(', ') || 'none'],
['Pieces', String(pieces())],
['Delivery', nameOf(SERVICES, serviceChoice())]
];
section.addTable(['Item', 'Choice'], rows);
});
pdf.addSection('Price', section => {
const rows: string[][] = isShower()
? [
['Shower glass', money(showerGlassCost())],
['Hardware', money(hardware())],
['Installation', money(showerInstall())]
]
: [
['Glass', money(paneGlass() * pieces())],
['Edgework, holes and cutouts', money((edgeCost() + drilling()) * pieces())]
];
if (isShower()) {
if (removal() > 0) rows.push(['Removal of the old enclosure', money(removal())]);
if (coating() > 0) rows.push(['Protective glass coating', money(coating())]);
} else {
if (ledCost() + defogCost() > 0) rows.push(['LED and anti-fog', money((ledCost() + defogCost()) * pieces())]);
rows.push(['Installation', paneInstall() > 0 ? money(paneInstall()) : 'not included']);
rows.push(['Delivery', delivery() > 0 ? money(delivery()) : serviceChoice() === 'pickup' ? 'pickup' : 'free']);
}
if (quantityAmount() > 0) rows.push(['Discount for ' + pieces() + ' pieces (-' + quantityPercent() + '%)', '-' + money(quantityAmount())]);
if (promoAmount() > 0) rows.push([promotion.name + ' (-' + promotion.percent + '%)', '-' + money(promoAmount())]);
if (minimumApplied()) rows.push(['Minimum order', money(minimumAmount())]);
rows.push(['Total before sales tax', money(total())]);
section.addTable(['Item', 'Amount'], rows);
});
pdf.addSection('Contact', section => {
section.addRow(row => {
row.addField('Name', contactSection.textbox('name')?.value() || '');
row.addField('Phone', contactSection.textbox('phone')?.value() || '');
});
section.addRow(row => {
row.addField('Email', contactSection.email('email')?.value() || '');
row.addField('City and ZIP', contactSection.textbox('cityZip')?.value() || '-');
});
});
pdf.addSection('What happens next', section => {
if (isShower()) {
section.addText('We call you to book the free on-site measure within ' + timing.measureDays + ' business days.');
section.addText('The glass is tempered to the measured sizes and your shower is installed about ' + timing.showerDays + ' business days after the measure.');
section.addText('Leave the silicone 24 hours before the first shower.');
} else {
section.addText('We call you to confirm the size, or measure on site within ' + timing.measureDays + ' business days.');
section.addText('We cut and finish your glass within ' + timing.leadDays + ' business days of the confirmed size.');
}
});
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. Glass is cut only after the size is confirmed, and glass cut to size cannot be returned or recut.');
});
});
}