export function saunaCalculator(form: FormTs) {
// Price list in USD: starting prices by kind, surcharges, equipment, service.
// The heater is sized from the cubic feet, so the volume rules live here too.
const prices = {
// Indoor saunas: the starting price covers baseCuFt, every cu ft above costs perCuFt
traditionalBase: 14900,
combiBase: 16900,
infraredBase: 15900,
hybridBase: 19900,
baseCuFt: 180,
perCuFt: 22,
placementAlcove: 600,
placementCorner: 1200,
placementWall: 1800,
frontWood: 900,
frontGlass: 1600,
frontCornerGlass: 2400,
// Backyard saunas
cabinBase: 32900,
cabinCuFt: 210,
cabinPerCuFt: 26,
barrelBase: 13900,
barrelLengthFt: 8,
barrelPerFt: 1100,
steamer: 1400,
woodStove: 2900,
outdoorGlass: 3400,
outdoorCornerGlass: 5600,
sidingBlackPct: 6,
sidingThermoPct: 9,
// Wood (percent of the sauna price)
woodAspenPct: 8,
woodThermoAspenPct: 14,
// Glass and hardware
glassTint: 300,
hardwareBlack: 450,
hardwareBrass: 900,
// Heating
heaterTower: 2400,
heaterColumn: 4900,
kwIncluded: 8,
perKw: 180,
cuFtPerKw: 45,
glassCuFt: 4,
irPanelKw: 0.4,
irFullEach: 650,
wifiControl: 700,
// Lights and extras
ledBench: 900,
ledCove: 2400,
saltWall: 1900,
starCeiling: 2600,
audioBluetooth: 800,
audioMultiroom: 3200,
backrest: 1200,
headrestEach: 260,
waterDropper: 2900,
aroma: 450,
coldPlunge: 5900,
// Service
minimum: 9000,
installPct: 12,
stairs: 450,
basePad: 1400,
baseSlab: 3200,
assemblyOnSite: 2400,
electricalHookup: 950,
permit: 650,
permitWeeks: 3,
freeMiles: 60,
mileRate: 3.5,
freightRegion: 1450,
crewTravelRegion: 1800,
freightNational: 2600,
// Discounts and result
prepayPct: 3,
promoPct: 0,
promoText: 'Starry ceiling on us',
rangePct: 8,
validityDays: 30,
leadWeeks: 8,
visitDays: 7
};
// ---- helpers ------------------------------------------------------------------
const money = (value: number): string => {
const rounded = Math.round(Math.abs(value));
let digits = String(rounded);
let grouped = '';
while (digits.length > 3) {
grouped = ',' + digits.slice(-3) + grouped;
digits = digits.slice(0, -3);
}
return (value < 0 ? '-$' : '$') + digits + grouped;
};
const feetInches = (ft: number): string => {
const inches = Math.round(ft * 12);
const f = Math.floor(inches / 12);
const i = inches % 12;
return i === 0 ? f + ' ft' : f + ' ft ' + i + ' in';
};
const plural = (n: number, one: string, many: string) => n === 1 ? one : many;
// Option labels carry the surcharge; the tables keep plain names for the summary and the PDF
const priceTag = (price: number) => price > 0 ? ' (+' + money(price) + ')' : '';
const pctTag = (pct: number) => pct > 0 ? ' (+' + pct + '%)' : '';
// ---- tables -------------------------------------------------------------------
const INDOOR_KINDS: Record<string, { name: string; base: number; temp: number }> = {
traditional: { name: 'Traditional', base: prices.traditionalBase, temp: 190 },
combi: { name: 'Traditional with steam', base: prices.combiBase, temp: 140 },
infrared: { name: 'Infrared', base: prices.infraredBase, temp: 125 },
hybrid: { name: 'Hybrid (heater, steamer and infrared)', base: prices.hybridBase, temp: 190 }
};
const OUTDOOR_KINDS: Record<string, string> = { cabin: 'Backyard cabin', barrel: 'Barrel sauna' };
const PEOPLE: Record<string, { name: string; indoor: [number, number]; cabin: [number, number]; barrel: number }> = {
two: { name: '1-2 people', indoor: [5.5, 5.5], cabin: [6, 6], barrel: 6.5 },
four: { name: '3-4 people', indoor: [6.5, 6], cabin: [7, 6.5], barrel: 8 },
six: { name: '5-6 people', indoor: [8, 6.5], cabin: [8, 7], barrel: 10 },
more: { name: '7 and more', indoor: [10, 7], cabin: [10, 8], barrel: 13 }
};
const LAYOUTS: Record<string, string> = { two_level: 'Two levels', L: 'Corner L', facing: 'Face to face' };
const PLACEMENTS: Record<string, { name: string; price: number }> = {
room: { name: 'The whole room', price: 0 },
alcove: { name: 'In an alcove', price: prices.placementAlcove },
corner: { name: 'In a corner', price: prices.placementCorner },
wall: { name: 'Against one wall', price: prices.placementWall }
};
const WOODS: Record<string, { name: string; pct: number }> = {
cedar: { name: 'Western red cedar', pct: 0 },
hemlock: { name: 'Hemlock', pct: 0 },
aspen: { name: 'Aspen', pct: prices.woodAspenPct },
thermo_aspen: { name: 'Thermo-aspen', pct: prices.woodThermoAspenPct },
spruce: { name: 'Nordic spruce', pct: 0 }
};
const SIDINGS: Record<string, { name: string; pct: number }> = {
cedar: { name: 'Cedar', pct: 0 },
black: { name: 'Black stain', pct: prices.sidingBlackPct },
thermo: { name: 'Thermo-wood', pct: prices.sidingThermoPct }
};
const FRONTS_INDOOR: Record<string, { name: string; price: number }> = {
drywall: { name: 'Drywall front and glass door', price: 0 },
wood: { name: 'Wood front and glass door', price: prices.frontWood },
glass: { name: 'Glass front', price: prices.frontGlass },
glass_corner: { name: 'Glass front and side', price: prices.frontCornerGlass }
};
const FRONTS_CABIN: Record<string, { name: string; price: number }> = {
wood: { name: 'Cedar front and glass door', price: 0 },
glass: { name: 'Panoramic glass front', price: prices.outdoorGlass },
glass_corner: { name: 'Glass corner', price: prices.outdoorCornerGlass }
};
const FRONTS_BARREL: Record<string, { name: string; price: number }> = {
wood: { name: 'Wood wall and glass door', price: 0 },
glass: { name: 'Glass front wall', price: prices.outdoorGlass }
};
const GLASSES: Record<string, { name: string; price: number }> = {
clear: { name: 'Clear', price: 0 },
bronze: { name: 'Bronze', price: prices.glassTint },
gray: { name: 'Gray', price: prices.glassTint }
};
const HARDWARES: Record<string, { name: string; price: number }> = {
stainless: { name: 'Brushed stainless', price: 0 },
black: { name: 'Matte black', price: prices.hardwareBlack },
brass: { name: 'Brushed brass', price: prices.hardwareBrass }
};
const HEATERS: Record<string, { name: string; price: number; minutes: number }> = {
wall: { name: 'Wall heater', price: 0, minutes: 40 },
tower: { name: 'Rock tower', price: prices.heaterTower, minutes: 50 },
column: { name: 'Rock column', price: prices.heaterColumn, minutes: 60 },
wood: { name: 'Wood-burning stove with chimney', price: prices.woodStove, minutes: 55 }
};
const LIGHTING: Record<string, { name: string; price: number }> = {
under_bench: { name: 'LED under the benches', price: prices.ledBench },
cove: { name: 'LED cove round the ceiling', price: prices.ledCove },
salt: { name: 'Salt wall', price: prices.saltWall },
stars: { name: 'Starry ceiling', price: prices.starCeiling }
};
const COMFORTS: Record<string, { name: string; price: number }> = {
backrest: { name: 'Backlit backrest', price: prices.backrest },
headrests: { name: 'Headrests', price: prices.headrestEach },
dropper: { name: 'Water dropper over the rocks', price: prices.waterDropper },
aroma: { name: 'Aromatherapy', price: prices.aroma }
};
const AUDIOS: Record<string, { name: string; price: number }> = {
none: { name: 'No speakers', price: 0 },
bluetooth: { name: 'Bluetooth speakers', price: prices.audioBluetooth },
multiroom: { name: 'Whole-home audio', price: prices.audioMultiroom }
};
const BASES: Record<string, { name: string; price: number }> = {
existing: { name: 'I have a hard surface: patio, pavers or deck', price: 0 },
pad: { name: 'Gravel pad and footings', price: prices.basePad },
slab: { name: 'Concrete slab', price: prices.baseSlab }
};
const TIMINGS: Record<string, string> = { asap: 'As soon as possible', quarter: 'Within 3 months', later: 'In six months or later' };
// Delivery zones: the crew installs at home and in the region, the rest of the lower 48 gets a kit
const STATES: { id: string; name: string; zone: string }[] = [
{ id: 'MN', name: 'Minnesota', zone: 'home' },
{ id: 'WI', name: 'Wisconsin', zone: 'region' }, { id: 'IA', name: 'Iowa', zone: 'region' }, { id: 'ND', name: 'North Dakota', zone: 'region' },
{ id: 'SD', name: 'South Dakota', zone: 'region' }, { id: 'NE', name: 'Nebraska', zone: 'region' }, { id: 'IL', name: 'Illinois', zone: 'region' }, { id: 'MI', name: 'Michigan', zone: 'region' },
{ id: 'AL', name: 'Alabama', zone: 'national' }, { id: 'AZ', name: 'Arizona', zone: 'national' }, { id: 'AR', name: 'Arkansas', zone: 'national' },
{ id: 'CA', name: 'California', zone: 'national' }, { id: 'CO', name: 'Colorado', zone: 'national' }, { id: 'CT', name: 'Connecticut', zone: 'national' },
{ id: 'DE', name: 'Delaware', zone: 'national' }, { id: 'DC', name: 'District of Columbia', zone: 'national' }, { id: 'FL', name: 'Florida', zone: 'national' },
{ id: 'GA', name: 'Georgia', zone: 'national' }, { id: 'ID', name: 'Idaho', zone: 'national' }, { id: 'IN', name: 'Indiana', zone: 'national' },
{ id: 'KS', name: 'Kansas', zone: 'national' }, { id: 'KY', name: 'Kentucky', zone: 'national' }, { id: 'LA', name: 'Louisiana', zone: 'national' },
{ id: 'ME', name: 'Maine', zone: 'national' }, { id: 'MD', name: 'Maryland', zone: 'national' }, { id: 'MA', name: 'Massachusetts', zone: 'national' },
{ id: 'MS', name: 'Mississippi', zone: 'national' }, { id: 'MO', name: 'Missouri', zone: 'national' }, { id: 'MT', name: 'Montana', zone: 'national' },
{ id: 'NV', name: 'Nevada', zone: 'national' }, { id: 'NH', name: 'New Hampshire', zone: 'national' }, { id: 'NJ', name: 'New Jersey', zone: 'national' },
{ id: 'NM', name: 'New Mexico', zone: 'national' }, { id: 'NY', name: 'New York', zone: 'national' }, { id: 'NC', name: 'North Carolina', zone: 'national' },
{ id: 'OH', name: 'Ohio', zone: 'national' }, { id: 'OK', name: 'Oklahoma', zone: 'national' }, { id: 'OR', name: 'Oregon', zone: 'national' },
{ id: 'PA', name: 'Pennsylvania', zone: 'national' }, { id: 'RI', name: 'Rhode Island', zone: 'national' }, { id: 'SC', name: 'South Carolina', zone: 'national' },
{ id: 'TN', name: 'Tennessee', zone: 'national' }, { id: 'TX', name: 'Texas', zone: 'national' }, { id: 'UT', name: 'Utah', zone: 'national' },
{ id: 'VT', name: 'Vermont', zone: 'national' }, { id: 'VA', name: 'Virginia', zone: 'national' }, { id: 'WA', name: 'Washington', zone: 'national' },
{ id: 'WV', name: 'West Virginia', zone: 'national' }, { id: 'WY', name: 'Wyoming', zone: 'national' },
{ id: 'AK', name: 'Alaska', zone: 'none' }, { id: 'HI', name: 'Hawaii', zone: 'none' }
];
const STATE_BY_ID: Record<string, { name: string; zone: string }> = {};
for (const state of STATES) STATE_BY_ID[state.id] = { name: state.name, zone: state.zone };
// Heater powers made (kW) and breakers a 240 V circuit comes in (A)
const POWERS = [3, 4.5, 6, 8, 9, 10.5, 12, 15, 18];
const BREAKERS = [20, 30, 40, 50, 60, 70, 80, 90, 100];
const IR_120V_KW = 1.8;
const BARREL_AREA = 33.5; // inside cross-section of a barrel, sq ft (about 6 ft 6 in across)
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Custom Sauna Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Pick the place, the kind and the size. The heater and the 240 V circuit are sized from the cubic feet, and you see a price range at the end.',
customStyles: { 'font-size': '0.95rem', 'color': '#64748b' }
});
});
form.addSpacer({ height: 20 });
// ---- Your sauna ---------------------------------------------------------------
const saunaSection = form.addSubform('sauna', { title: 'Your Sauna' });
const place = () => saunaSection.radioButton('place')?.value() || 'indoor';
const outdoors = () => place() === 'outdoor';
const kind = () => {
const chosen = saunaSection.dropdown('kind')?.value() || '';
if (outdoors()) return chosen in OUTDOOR_KINDS ? chosen : 'cabin';
return chosen in INDOOR_KINDS ? chosen : 'traditional';
};
const barrel = () => outdoors() && kind() === 'barrel';
const heat = () => saunaSection.radioButton('heat')?.value() || 'traditional';
// The heat the heater follows: an outdoor sauna is traditional or with steam
const heatType = () => outdoors() ? heat() : kind();
const infrared = () => heatType() === 'infrared';
const withHeater = () => !infrared();
const withEmitters = () => heatType() === 'infrared' || heatType() === 'hybrid';
const kindName = () => outdoors() ? OUTDOOR_KINDS[kind()] || 'Backyard cabin' : (INDOOR_KINDS[kind()] || INDOOR_KINDS['traditional']!).name;
const applyPeople = (peopleId: string) => {
const p = PEOPLE[peopleId] || PEOPLE['four']!;
if (barrel()) {
sizeSection.slider('length')?.setValue(p.barrel);
return;
}
const dims = outdoors() ? p.cabin : p.indoor;
sizeSection.slider('width')?.setValue(dims[0]);
sizeSection.slider('depth')?.setValue(dims[1]);
};
saunaSection.addRow(row => {
row.addRadioButton('place', {
label: 'Where will the sauna go?',
options: [
{ id: 'indoor', name: 'In the house (bathroom, basement, home gym)' },
{ id: 'outdoor', name: 'In the backyard (cabin or barrel)' }
],
defaultValue: 'indoor',
orientation: 'vertical',
isRequired: true,
onValueChange: value => {
saunaSection.dropdown('kind')?.setValue(value === 'outdoor' ? 'cabin' : 'traditional');
applyPeople(saunaSection.radioButton('people')?.value() || 'four');
}
}, '1fr');
row.addDropdown('kind', {
label: () => outdoors() ? 'Which backyard sauna?' : 'Which sauna?',
options: () => outdoors()
? [
{ id: 'cabin', name: 'Backyard cabin - from ' + money(prices.cabinBase) },
{ id: 'barrel', name: 'Barrel sauna - from ' + money(prices.barrelBase) }
]
: [
{ id: 'traditional', name: 'Traditional (dry, 170-200°F) - from ' + money(prices.traditionalBase) },
{ id: 'combi', name: 'Traditional with steam - from ' + money(prices.combiBase) },
{ id: 'infrared', name: 'Infrared (120-140°F) - from ' + money(prices.infraredBase) },
{ id: 'hybrid', name: 'Hybrid: heater, steamer and infrared - from ' + money(prices.hybridBase) }
],
defaultValue: 'traditional',
isRequired: true,
onValueChange: () => applyPeople(saunaSection.radioButton('people')?.value() || 'four')
}, '1fr');
});
saunaSection.addRow(row => {
row.addRadioButton('heat', {
label: 'What kind of heat?',
options: [
{ id: 'traditional', name: 'Traditional, dry 170-200°F' },
{ id: 'combi', name: 'With steam: a steamer in the heater (+' + money(prices.steamer) + ')' }
],
defaultValue: 'traditional',
orientation: 'vertical',
isVisible: () => outdoors()
}, '1fr');
row.addRadioButton('people', {
label: 'How many people at once?',
tooltip: 'Sets the starting size; you can change it to the inch below',
options: Object.keys(PEOPLE).map(id => ({ id, name: PEOPLE[id]!.name })),
defaultValue: 'four',
orientation: 'vertical',
isRequired: true,
onValueChange: value => applyPeople(value || 'four')
}, '1fr');
});
// ---- Size ---------------------------------------------------------------------
const sizeSection = form.addSubform('size', { title: 'Size and Benches' });
sizeSection.addRow(row => {
row.addSlider('width', { label: 'Interior width (along the front with the door)', unit: 'ft', min: 5, max: 13, step: 0.5, defaultValue: 6.5, isVisible: () => !barrel() }, '1fr');
row.addSlider('depth', { label: 'Interior depth (front to back wall)', unit: 'ft', min: 5, max: 10, step: 0.5, defaultValue: 6, isVisible: () => !barrel() }, '1fr');
});
sizeSection.addRow(row => {
row.addSlider('length', { label: 'Barrel length (about 6 ft 6 in across)', unit: 'ft', min: 6, max: 13, step: 0.5, defaultValue: 8, isVisible: () => barrel() }, '1fr');
row.addSlider('height', { label: 'Ceiling height', unit: 'in', min: 76, max: 96, step: 2, defaultValue: 84, isVisible: () => !barrel() }, '1fr');
});
const widthFt = () => sizeSection.slider('width')?.value() || 6.5;
const depthFt = () => sizeSection.slider('depth')?.value() || 6;
const heightFt = () => (sizeSection.slider('height')?.value() || 84) / 12;
const lengthFt = () => sizeSection.slider('length')?.value() || 8;
// What each bench layout needs: an L its side bench, benches face to face room between them
const layoutPossible = (id: string): boolean => {
if (barrel()) return id === 'facing';
if (id === 'L') return widthFt() >= 6 && depthFt() >= 6;
if (id === 'facing') return widthFt() >= 6.5;
return true;
};
sizeSection.addRow(row => {
row.addDropdown('layout', {
label: 'Bench layout',
tooltip: 'A corner L needs 6 x 6 ft, benches face to face 6 ft 6 in of width',
options: () => Object.keys(LAYOUTS).filter(layoutPossible).map(id => ({ id, name: LAYOUTS[id]! })),
defaultValue: 'two_level',
isVisible: () => !barrel()
}, '1fr');
row.addDropdown('placement', {
label: 'Where in the house?',
tooltip: 'Walls that already stand are not built; the alcove, corner and wall options price the walls we add',
options: Object.keys(PLACEMENTS).map(id => ({ id, name: PLACEMENTS[id]!.name + priceTag(PLACEMENTS[id]!.price) })),
defaultValue: 'corner',
isVisible: () => !outdoors()
}, '1fr');
});
const layout = () => {
if (barrel()) return 'facing';
const chosen = sizeSection.dropdown('layout')?.value() || 'two_level';
return chosen in LAYOUTS && layoutPossible(chosen) ? chosen : 'two_level';
};
const placement = () => {
const chosen = sizeSection.dropdown('placement')?.value() || 'corner';
return chosen in PLACEMENTS ? chosen : 'corner';
};
const volume = () => barrel() ? BARREL_AREA * lengthFt() : widthFt() * depthFt() * heightFt();
// Seats every two feet along each bench: the upper bench, the lower one where there is room, an L's side bench, both sides of a face to face layout
const seatsOnBenches = (): { upper: number; lower: number } => {
const bench = (length: number) => Math.max(0, Math.floor(length / 2 + 0.001));
if (barrel()) return { upper: 2 * bench(lengthFt() - 3), lower: 0 };
const w = widthFt();
const d = depthFt();
if (layout() === 'facing') return { upper: 2 * bench(d), lower: w >= 9 ? 2 * bench(d) : 0 };
if (layout() === 'L') return { upper: bench(w) + bench(d - 4), lower: bench(w - 2) };
return { upper: bench(w), lower: d >= 5.5 ? bench(w) : 0 };
};
const seats = () => seatsOnBenches().upper + seatsOnBenches().lower;
const upperBenches = () => barrel() || layout() !== 'two_level' ? 2 : 1;
const dimsText = () => barrel()
? 'Barrel ' + feetInches(lengthFt()) + ' long, ' + Math.round(volume()) + ' cu ft'
: feetInches(widthFt()) + ' x ' + feetInches(depthFt()) + ' x ' + feetInches(heightFt()) + ', ' + Math.round(volume()) + ' cu ft';
sizeSection.addRow(row => {
row.addTextPanel('sizeInfo', {
computedValue: () => dimsText() + ' - ' + (barrel() ? 'benches face to face' : LAYOUTS[layout()]) + ': ' + seats() + ' ' + plural(seats(), 'seat', 'seats'),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// ---- Wood, front and glass -----------------------------------------------------
const finishSection = form.addSubform('finish', { title: 'Wood, Front and Glass' });
// A barrel is made of what bends into staves
const woodsAvailable = (): string[] => barrel() ? ['cedar', 'thermo_aspen', 'spruce'] : Object.keys(WOODS);
const fronts = () => !outdoors() ? FRONTS_INDOOR : barrel() ? FRONTS_BARREL : FRONTS_CABIN;
// A glass side needs a side in the open: in a corner or against one wall
const frontPossible = (id: string): boolean => outdoors() || id !== 'glass_corner' || placement() === 'corner' || placement() === 'wall';
finishSection.addRow(row => {
row.addRadioButton('wood', {
label: 'Interior wood (walls, ceiling and benches)',
options: () => woodsAvailable().map(id => ({ id, name: WOODS[id]!.name + pctTag(WOODS[id]!.pct) })),
defaultValue: 'cedar',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('siding', {
label: () => barrel() ? 'The barrel outside' : 'Cabin siding',
options: Object.keys(SIDINGS).map(id => ({ id, name: SIDINGS[id]!.name + pctTag(SIDINGS[id]!.pct) })),
defaultValue: 'cedar',
orientation: 'vertical',
isVisible: () => outdoors()
}, '1fr');
});
finishSection.addRow(row => {
row.addDropdown('front', {
label: 'Front',
options: () => Object.keys(fronts()).filter(frontPossible).map(id => ({ id, name: fronts()[id]!.name + priceTag(fronts()[id]!.price) })),
defaultValue: 'glass',
isRequired: true
});
});
finishSection.addRow(row => {
row.addDropdown('glass', {
label: 'Glass',
tooltip: 'Tinted glass gives more privacy and a warmer color inside',
options: Object.keys(GLASSES).map(id => ({ id, name: GLASSES[id]!.name + priceTag(GLASSES[id]!.price) })),
defaultValue: 'bronze'
}, '1fr');
row.addDropdown('hardware', {
label: 'Hardware and channels',
options: Object.keys(HARDWARES).map(id => ({ id, name: HARDWARES[id]!.name + priceTag(HARDWARES[id]!.price) })),
defaultValue: 'black'
}, '1fr');
});
const wood = () => {
const chosen = finishSection.radioButton('wood')?.value() || 'cedar';
return woodsAvailable().includes(chosen) ? chosen : 'cedar';
};
const siding = () => {
const chosen = finishSection.radioButton('siding')?.value() || 'cedar';
return chosen in SIDINGS ? chosen : 'cedar';
};
const front = () => {
const chosen = finishSection.dropdown('front')?.value() || 'glass';
return chosen in fronts() && frontPossible(chosen) ? chosen : 'glass';
};
const glass = () => {
const chosen = finishSection.dropdown('glass')?.value() || 'bronze';
return chosen in GLASSES ? chosen : 'bronze';
};
const hardware = () => {
const chosen = finishSection.dropdown('hardware')?.value() || 'black';
return chosen in HARDWARES ? chosen : 'black';
};
// ---- Heating ------------------------------------------------------------------
const heatSection = form.addSubform('heating', { title: 'Heating' });
const heatersAvailable = (): string[] => infrared() ? [] : outdoors() ? ['wall', 'tower', 'column', 'wood'] : ['wall', 'tower', 'column'];
heatSection.addRow(row => {
row.addRadioButton('heater', {
label: 'Heater',
options: () => heatersAvailable().map(id => ({ id, name: HEATERS[id]!.name + priceTag(HEATERS[id]!.price) })),
defaultValue: 'tower',
orientation: 'vertical',
isVisible: () => !infrared()
}, '1fr');
row.addRadioButton('emitters', {
label: () => 'Infrared panels (one behind every seat on the upper bench: ' + seatsOnBenches().upper + ')',
options: [
{ id: 'carbon', name: 'Carbon panels' },
{ id: 'full', name: 'Full spectrum (+' + money(prices.irFullEach) + ' each)' }
],
defaultValue: 'carbon',
orientation: 'vertical',
isVisible: () => withEmitters()
}, '1fr');
});
const heater = () => {
const chosen = heatSection.radioButton('heater')?.value() || 'tower';
return heatersAvailable().includes(chosen) ? chosen : 'tower';
};
const woodBurning = () => withHeater() && heater() === 'wood';
const emitters = () => heatSection.radioButton('emitters')?.value() === 'full' ? 'full' : 'carbon';
// Glass holds no heat like a wall does: its square feet count towards the volume the heater has to warm
const glassArea = () => {
const door = 2.3 * Math.min(6.2, (barrel() ? 5.9 : heightFt()) - 0.25);
if (barrel()) return front() === 'glass' ? BARREL_AREA : door;
if (front() === 'glass') return widthFt() * heightFt();
if (front() === 'glass_corner') return widthFt() * heightFt() + depthFt() * heightFt();
return door;
};
const volumeForHeater = () => volume() + glassArea() * prices.glassCuFt;
const powerByRule = () => volumeForHeater() / prices.cuFtPerKw;
const power = () => {
const needed = powerByRule();
for (const p of POWERS) if (p >= needed) return p;
return POWERS[POWERS.length - 1]!;
};
const heaterAmps = () => power() * 1000 / 240;
const breaker = () => {
const amps = heaterAmps();
for (const a of BREAKERS) if (a >= amps) return a;
return BREAKERS[BREAKERS.length - 1]!;
};
const emitterKw = () => withEmitters() ? seatsOnBenches().upper * prices.irPanelKw : 0;
// What the electrician has to run: nothing for a wood stove, 120 V for a small infrared cabin, 240 V for the rest
const circuit = (): string => {
if (woodBurning()) return 'none';
if (!withHeater()) return emitterKw() <= IR_120V_KW ? '120' : '240';
return '240';
};
const circuitText = () => circuit() === 'none' ? 'no power needed' : circuit() === '120' ? '120 V, 20 A circuit' : '240 V, ' + breaker() + ' A circuit';
const temperature = () => outdoors() ? (heat() === 'combi' ? 140 : 190) : (INDOOR_KINDS[kind()] || INDOOR_KINDS['traditional']!).temp;
const warmup = () => {
if (infrared()) return 15;
const base = HEATERS[heater()]!.minutes;
const margin = woodBurning() ? 1 : Math.min(1.25, Math.max(0.8, powerByRule() / power()));
return Math.round(base * margin / 5) * 5;
};
heatSection.addRow(row => {
row.addTextPanel('sizing', {
computedValue: () => {
const lines: string[] = ['Interior volume ' + Math.round(volume()) + ' cu ft'];
if (glassArea() > 0.01) lines.push(Math.round(glassArea()) + ' sq ft of glass counted as +' + Math.round(glassArea() * prices.glassCuFt) + ' cu ft');
if (withHeater() && !woodBurning()) lines.push('Heater ' + power() + ' kW (1 kW per ' + prices.cuFtPerKw + ' cu ft) on a dedicated 240 V, ' + breaker() + ' A circuit');
if (woodBurning()) lines.push('Wood-burning stove, no power needed, a chimney through the roof');
if (withEmitters()) lines.push(seatsOnBenches().upper + ' infrared panels, ' + (Math.round(emitterKw() * 10) / 10) + ' kW' + (withHeater() ? '' : ' on a ' + circuitText()));
lines.push('Ready in about ' + warmup() + ' min, to ' + temperature() + '°F');
return lines.join(' | ');
},
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
heatSection.addRow(row => {
row.addRadioButton('control', {
label: 'Control',
options: [
{ id: 'panel', name: 'Panel by the door' },
{ id: 'wifi', name: 'Panel and Wi-Fi app (+' + money(prices.wifiControl) + ')' }
],
defaultValue: 'panel',
orientation: 'vertical',
isVisible: () => !woodBurning()
}, '1fr');
});
const wifiControl = () => !woodBurning() && heatSection.radioButton('control')?.value() === 'wifi';
// ---- Lights and extras ---------------------------------------------------------
const extrasSection = form.addSubform('extras', { title: 'Lights and Extras' });
const lightingAvailable = () => Object.keys(LIGHTING).filter(id => !barrel() || id === 'under_bench' || id === 'salt');
const comfortAvailable = () => Object.keys(COMFORTS)
.filter(id => id !== 'dropper' || (withHeater() && !woodBurning()))
.filter(id => id !== 'aroma' || withHeater());
const comfortPrice = (id: string) => (id === 'headrests' ? upperBenches() : 1) * COMFORTS[id]!.price;
extrasSection.addRow(row => {
row.addCheckboxList('lighting', {
label: 'Lighting',
options: () => lightingAvailable().map(id => ({ id, name: LIGHTING[id]!.name + ' (+' + money(LIGHTING[id]!.price) + ')' })),
defaultValue: ['under_bench'],
orientation: 'vertical'
}, '1fr');
row.addCheckboxList('comfort', {
label: 'Comfort',
options: () => comfortAvailable().map(id => ({
id,
name: COMFORTS[id]!.name + (id === 'headrests' && upperBenches() > 1 ? ' x ' + upperBenches() : '') + ' (+' + money(comfortPrice(id)) + ')'
})),
defaultValue: [],
orientation: 'vertical'
}, '1fr');
});
extrasSection.addRow(row => {
row.addDropdown('audio', {
label: 'Sound',
options: Object.keys(AUDIOS).map(id => ({ id, name: AUDIOS[id]!.name + priceTag(AUDIOS[id]!.price) })),
defaultValue: 'none'
}, '1fr');
row.addCheckbox('plunge', {
label: 'Cold plunge tub beside the sauna (from ' + money(prices.coldPlunge) + ')',
defaultValue: false
}, '1fr');
});
const lighting = () => (extrasSection.checkboxList('lighting')?.value() || []).filter(id => lightingAvailable().includes(id));
const comfort = () => (extrasSection.checkboxList('comfort')?.value() || []).filter(id => comfortAvailable().includes(id));
const audio = () => {
const chosen = extrasSection.dropdown('audio')?.value() || 'none';
return chosen in AUDIOS ? chosen : 'none';
};
const plunge = () => extrasSection.checkbox('plunge')?.value() === true;
// ---- Installation and delivery -------------------------------------------------
const installSection = form.addSubform('installation', { title: 'Installation and Delivery' });
installSection.addRow(row => {
row.addDropdown('state', {
label: 'Which state is the sauna going to?',
tooltip: 'Our crew installs in Minnesota and the neighboring states; elsewhere the sauna ships as a kit',
options: STATES.map(s => ({ id: s.id, name: s.name })),
defaultValue: 'MN',
isRequired: true
}, '1fr');
row.addSlider('distance', {
label: 'Distance from our shop in Duluth (free up to ' + prices.freeMiles + ' mi, then $' + prices.mileRate + '/mi)',
unit: 'mi',
min: 0,
max: 400,
step: 10,
defaultValue: 40,
isVisible: () => zone() === 'home'
}, '1fr');
});
const state = () => {
const chosen = installSection.dropdown('state')?.value() || 'MN';
return chosen in STATE_BY_ID ? chosen : 'MN';
};
const zone = () => STATE_BY_ID[state()]!.zone;
const crewAvailable = () => zone() === 'home' || zone() === 'region';
installSection.addRow(row => {
row.addTextPanel('deliveryInfo', {
computedValue: () => {
if (zone() === 'national') return 'To ' + STATE_BY_ID[state()]!.name + ' the sauna ships as a pre-cut, labeled kit with a video call for the assembly. Crated freight ' + money(prices.freightNational) + ', curbside; a local electrician connects the heater.';
if (zone() === 'none') return 'Freight to ' + STATE_BY_ID[state()]!.name + ' goes through a freight forwarder and is quoted separately after the site visit.';
if (zone() === 'region') return 'Our crew drives to ' + STATE_BY_ID[state()]!.name + ': crated freight ' + money(prices.freightRegion) + ' and crew travel ' + money(prices.crewTravelRegion) + ' when we install.';
return 'Our crew installs in Minnesota. Delivery is free up to ' + prices.freeMiles + ' miles from Duluth.';
},
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
installSection.addRow(row => {
row.addRadioButton('floor', {
label: 'Which floor? (stairs: +' + money(prices.stairs) + ')',
options: [{ id: 'main', name: 'Main floor' }, { id: 'upstairs', name: 'Upstairs' }, { id: 'basement', name: 'Basement' }],
defaultValue: 'main',
orientation: 'horizontal',
isVisible: () => !outdoors()
}, '1fr');
row.addRadioButton('circuitThere', {
label: () => 'Is there a ' + (circuit() === '120' ? '120 V' : '240 V') + ' circuit by the sauna?',
options: [{ id: 'there', name: 'Already there' }, { id: 'run', name: 'Needs to be run' }, { id: 'unsure', name: 'Not sure' }],
defaultValue: 'unsure',
orientation: 'horizontal',
isVisible: () => circuit() !== 'none'
}, '1fr');
});
installSection.addRow(row => {
row.addRadioButton('base', {
label: 'What will the sauna stand on?',
options: Object.keys(BASES).map(id => ({ id, name: BASES[id]!.name + priceTag(BASES[id]!.price) })),
defaultValue: 'pad',
orientation: 'vertical',
isVisible: () => outdoors()
}, '1fr');
row.addSlider('access', {
label: 'Narrowest point on the way to the backyard (gate or side yard)',
unit: 'ft',
min: 2,
max: 16,
step: 0.5,
defaultValue: 10,
isVisible: () => outdoors() && crewAvailable()
}, '1fr');
});
// A cabin travels whole on a trailer, a barrel on its cradles: the gap each needs, in feet
const accessNeeded = () => Math.ceil((barrel() ? 7.5 : widthFt() + 2) * 2) / 2;
const access = () => installSection.slider('access')?.value() || 10;
const assembly = () => outdoors() && crewAvailable() && access() < accessNeeded();
installSection.addRow(row => {
row.addTextPanel('accessInfo', {
computedValue: () => assembly()
? 'Too narrow to come through in one piece: the sauna needs about ' + feetInches(accessNeeded()) + ' and the gap is ' + feetInches(access()) + '. We assemble it in the yard from panels (+' + money(prices.assemblyOnSite) + ').'
: 'The sauna comes through in one piece: it needs about ' + feetInches(accessNeeded()) + '. We bring it finished and set it with a crane or on rollers.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => outdoors() && crewAvailable()
});
});
installSection.addRow(row => {
row.addTextPanel('permitInfo', {
computedValue: () => barrel()
? 'A barrel usually needs no building permit: under 120 sq ft most cities ask only for the electrical permit, which comes with the hookup.'
: 'A backyard cabin usually needs a building permit: we prepare the drawings and file it, the city takes about ' + prices.permitWeeks + ' weeks (' + money(prices.permit) + ' with the inspections).',
customStyles: { 'font-size': '0.9rem', 'color': '#64748b' },
isVisible: () => outdoors()
});
});
installSection.addRow(row => {
row.addCheckbox('crew', {
label: () => 'Installed by our crew (' + prices.installPct + '% of the sauna and equipment)',
defaultValue: true,
isVisible: () => crewAvailable()
}, '1fr');
row.addCheckbox('hookup', {
label: () => 'Electrical hookup by our licensed electrician: a dedicated ' + circuitText() + ' with the permit (+' + money(prices.electricalHookup) + ')',
defaultValue: false,
isVisible: () => circuit() !== 'none' && crewAvailable()
}, '1fr');
});
installSection.addRow(row => {
row.addDropdown('timing', {
label: 'When do you want to use the sauna?',
options: Object.keys(TIMINGS).map(id => ({ id, name: TIMINGS[id]! })),
defaultValue: 'quarter'
}, '1fr');
row.addCheckbox('prepay', {
label: 'Pay in full up front (' + prices.prepayPct + '% discount)',
defaultValue: false
}, '1fr');
});
const distance = () => installSection.slider('distance')?.value() || 0;
const floor = () => installSection.radioButton('floor')?.value() || 'main';
const base = () => {
const chosen = installSection.radioButton('base')?.value() || 'pad';
return chosen in BASES ? chosen : 'pad';
};
const crewInstalls = () => crewAvailable() && installSection.checkbox('crew')?.value() !== false;
const hookup = () => circuit() !== 'none' && crewAvailable() && installSection.checkbox('hookup')?.value() === true;
const timing = () => installSection.dropdown('timing')?.value() || 'quarter';
const prepay = () => installSection.checkbox('prepay')?.value() === true;
// ---- Price logic ------------------------------------------------------------------
// The sauna itself: the kind's price up to its base size, and the rate for every cu ft or foot over it
const cabinAmount = () => {
if (barrel()) return prices.barrelBase + Math.max(0, lengthFt() - prices.barrelLengthFt) * prices.barrelPerFt;
if (outdoors()) return prices.cabinBase + Math.max(0, volume() - prices.cabinCuFt) * prices.cabinPerCuFt;
return (INDOOR_KINDS[kind()] || INDOOR_KINDS['traditional']!).base + Math.max(0, volume() - prices.baseCuFt) * prices.perCuFt;
};
const woodAmount = () => cabinAmount() * WOODS[wood()]!.pct / 100;
const sidingAmount = () => outdoors() ? cabinAmount() * SIDINGS[siding()]!.pct / 100 : 0;
const placementAmount = () => outdoors() ? 0 : PLACEMENTS[placement()]!.price;
const frontAmount = () => fronts()[front()]!.price;
const glassAmount = () => GLASSES[glass()]!.price;
const hardwareAmount = () => HARDWARES[hardware()]!.price;
const saunaAmount = () => cabinAmount() + woodAmount() + sidingAmount() + placementAmount() + frontAmount() + glassAmount() + hardwareAmount();
const heaterAmount = () => {
if (!withHeater()) return 0;
const over = woodBurning() ? 0 : Math.max(0, power() - prices.kwIncluded) * prices.perKw;
return HEATERS[heater()]!.price + over;
};
const emittersAmount = () => withEmitters() && emitters() === 'full' ? seatsOnBenches().upper * prices.irFullEach : 0;
const steamerAmount = () => outdoors() && heat() === 'combi' ? prices.steamer : 0;
const controlAmount = () => wifiControl() ? prices.wifiControl : 0;
const heatingAmount = () => heaterAmount() + emittersAmount() + controlAmount() + steamerAmount();
const lightingAmount = () => lighting().reduce((sum, id) => sum + LIGHTING[id]!.price, 0);
const comfortAmount = () => comfort().reduce((sum, id) => sum + comfortPrice(id), 0);
const audioAmount = () => AUDIOS[audio()]!.price;
const plungeAmount = () => plunge() ? prices.coldPlunge : 0;
const extrasAmount = () => lightingAmount() + comfortAmount() + audioAmount() + plungeAmount();
const installAmount = () => crewInstalls() ? (saunaAmount() + heatingAmount() + lightingAmount() + comfortAmount() + audioAmount()) * prices.installPct / 100 : 0;
const stairsAmount = () => !outdoors() && crewInstalls() && floor() !== 'main' ? prices.stairs : 0;
const baseAmount = () => outdoors() && crewInstalls() ? BASES[base()]!.price : 0;
const assemblyAmount = () => assembly() && crewInstalls() ? prices.assemblyOnSite : 0;
const hookupAmount = () => hookup() ? prices.electricalHookup : 0;
const permitAmount = () => outdoors() && !barrel() && crewInstalls() ? prices.permit : 0;
const crewTravelAmount = () => crewInstalls() && zone() === 'region' ? prices.crewTravelRegion : 0;
// Freight: free within the radius at home, per mile beyond, a flat crate to the region and the rest of the lower 48
const freight = () => {
if (zone() === 'home') return Math.max(0, distance() - prices.freeMiles) * prices.mileRate;
if (zone() === 'region') return prices.freightRegion;
if (zone() === 'national') return prices.freightNational;
return 0;
};
const serviceAmount = () => installAmount() + stairsAmount() + baseAmount() + assemblyAmount() + hookupAmount() + permitAmount() + crewTravelAmount() + freight();
const net = () => saunaAmount() + heatingAmount() + extrasAmount() + serviceAmount();
const prepayDiscount = () => prepay() ? net() * prices.prepayPct / 100 : 0;
const promo = () => (net() - prepayDiscount()) * prices.promoPct / 100;
// The minimum holds last: a promotion never takes a job below what the crew costs
const total = () => Math.max(Math.round(net() - prepayDiscount() - promo()), prices.minimum);
const priceFrom = () => Math.round(total() * (1 - prices.rangePct / 100) / 100) * 100;
const priceTo = () => Math.round(total() * (1 + prices.rangePct / 100) / 100) * 100;
const heatingText = () => {
const parts: string[] = [];
if (withHeater()) parts.push(HEATERS[heater()]!.name + (woodBurning() ? '' : ' ' + power() + ' kW'));
if (withEmitters()) parts.push(seatsOnBenches().upper + ' ' + (emitters() === 'full' ? 'full-spectrum' : 'carbon') + ' panels');
if (outdoors() && heat() === 'combi') parts.push('steamer');
if (wifiControl()) parts.push('Wi-Fi control');
return parts.join(', ');
};
const finishText = () => WOODS[wood()]!.name + ', ' + fronts()[front()]!.name.toLowerCase()
+ ', ' + GLASSES[glass()]!.name.toLowerCase() + ' glass, ' + HARDWARES[hardware()]!.name.toLowerCase() + ' hardware'
+ (outdoors() ? ', ' + SIDINGS[siding()]!.name.toLowerCase() + ' siding' : '');
const extrasText = () => {
const parts = lighting().map(id => LIGHTING[id]!.name).concat(comfort().map(id => COMFORTS[id]!.name));
if (audio() !== 'none') parts.push(AUDIOS[audio()]!.name);
if (plunge()) parts.push('Cold plunge tub');
return parts.join(', ') || 'None';
};
const deliveryText = () => {
if (zone() === 'home') return distance() + ' mi from Duluth';
if (zone() === 'region') return 'crated freight to ' + STATE_BY_ID[state()]!.name;
if (zone() === 'national') return 'kit, crated freight to ' + STATE_BY_ID[state()]!.name;
return 'freight to ' + STATE_BY_ID[state()]!.name + ' quoted separately';
};
form.addSpacer({ height: 20, showLine: true, lineStyle: 'dashed' });
// ---- Estimate ---------------------------------------------------------------------
const summarySection = form.addSubform('summary', { title: 'Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addPriceDisplay('saunaCost', { label: () => kindName() + ' (' + dimsText() + ')', computedValue: () => Math.round(saunaAmount()), decimals: 0, variant: 'default' }, '1fr');
row.addPriceDisplay('heatingCost', { label: 'Heating', computedValue: () => Math.round(heatingAmount()), decimals: 0, variant: 'default', prefix: '+' }, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('extrasCost', { label: 'Lights, comfort, sound, cold plunge', computedValue: () => Math.round(extrasAmount()), decimals: 0, variant: 'default', prefix: '+' }, '1fr');
row.addPriceDisplay('installCost', {
label: () => crewInstalls() ? 'Installation and site work' : zone() === 'national' || zone() === 'none' ? 'Installation (kit, assembled by you)' : 'Installation (on your side)',
computedValue: () => Math.round(serviceAmount() - freight()),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('deliveryCost', {
label: () => 'Delivery (' + deliveryText() + ')',
computedValue: () => Math.round(freight()),
decimals: 0,
variant: 'default',
prefix: '+'
}, '1fr');
row.addPriceDisplay('discounts', {
label: () => promo() > 0 ? 'Discounts (prepayment, ' + prices.promoText + ')' : 'Prepayment discount',
computedValue: () => -Math.round(prepayDiscount() + promo()),
decimals: 0,
variant: 'success',
isVisible: () => prepayDiscount() + promo() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('minimumNote', {
computedValue: () => 'The estimate was raised to the minimum order of ' + money(prices.minimum) + '.',
customStyles: { 'font-size': '0.85rem', 'color': '#b45309' },
isVisible: () => total() > Math.round(net() - prepayDiscount() - promo())
});
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('priceFrom', { label: () => (crewInstalls() ? 'Sauna, installed: from' : 'Sauna, delivered: from'), computedValue: () => priceFrom(), decimals: 0, variant: 'large' }, '1fr');
row.addPriceDisplay('priceTo', { label: 'to', computedValue: () => priceTo(), decimals: 0, variant: 'large' }, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('rangeNote', {
computedValue: () => 'Range of ±' + prices.rangePct + '%, sales tax as applicable. ' + (crewAvailable()
? 'Within ' + prices.visitDays + ' business days we come out to measure the space and check the panel, then you get the drawings and the final quote.'
: 'Within ' + prices.visitDays + ' business days we walk through the space and the panel with you on a video call, then you get the drawings, the final quote and the assembly guide.')
+ ' Build time ' + prices.leadWeeks + ' weeks from the signed contract.',
customStyles: { 'font-size': '0.85rem', 'color': '#64748b', 'font-style': 'italic' }
});
});
// ---- Sticky total ---------------------------------------------------------------
const finalSection = form.addSubform('final', { title: 'Summary', isCollapsible: false, sticky: 'bottom' });
finalSection.addRow(row => {
row.addPriceDisplay('totalEstimate', {
label: () => 'Estimated total (about ' + money(priceFrom()) + ' - ' + money(priceTo()) + ')',
computedValue: () => total(),
decimals: 0,
variant: 'large'
});
});
// ---- Contact -------------------------------------------------------------------
const contactSection = form.addSubform('contact', { title: () => crewAvailable() ? 'Book the Site Visit' : 'Book the Call' });
contactSection.addRow(row => {
row.addTextbox('name', { label: 'Full name', placeholder: 'John Smith', isRequired: true }, '1fr');
row.addTextbox('phone', { label: 'Phone', placeholder: '(555) 123-4567', isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addEmail('email', { label: 'Email (where we send the PDF with your configuration)', placeholder: 'john@example.com', isRequired: true }, '1fr');
row.addTextbox('zip', { label: () => crewAvailable() ? 'ZIP code (where we come for the site visit)' : 'ZIP code (where the sauna ships to)', placeholder: '55802', isRequired: true }, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else we should know?',
placeholder: () => outdoors()
? 'E.g. the sauna goes by the lake, we want an outdoor shower next to it, power is in the garage'
: 'E.g. the bathroom is still being designed, there will be a shower next to it, the room has a window',
rows: 3,
maxLength: 1500
});
});
// ---- PDF -----------------------------------------------------------------------
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'custom-sauna-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the configuration as PDF',
header: { title: 'Sauna estimate and configuration', subtitle: kindName() + ', ' + dimsText() },
footer: { text: 'Estimate based on the configuration from the form, valid for ' + prices.validityDays + ' days. A binding quote follows the site visit within ' + prices.visitDays + ' business days.', showPageNumbers: true }
});
pdf.addSection('Sauna', section => {
section.addRow(row => {
row.addField('Kind', kindName() + (outdoors() ? ', ' + (heat() === 'combi' ? 'with steam' : 'traditional') + ' heat' : ''));
row.addField('Interior', dimsText());
row.addField('Place', outdoors() ? 'backyard' : PLACEMENTS[placement()]!.name + ', ' + floor());
});
section.addRow(row => {
row.addField('Benches', (barrel() ? 'face to face' : LAYOUTS[layout()]!) + ': ' + seats() + ' ' + plural(seats(), 'seat', 'seats'));
row.addField('Finish', finishText());
});
if (outdoors()) {
section.addRow(row => {
row.addField('Base', crewInstalls() ? BASES[base()]!.name : 'on your side');
row.addField('Access', crewAvailable() ? feetInches(access()) + (assembly() ? ', assembled in the yard' : ', in one piece') : 'kit');
row.addField('Permit', barrel() ? 'usually none for a barrel' : 'building permit for the cabin, about ' + prices.permitWeeks + ' weeks');
});
}
});
pdf.addSection('Heating and extras', section => {
section.addRow(row => {
row.addField('Heating', heatingText());
row.addField('Volume for sizing', Math.round(volumeForHeater()) + ' cu ft (with ' + Math.round(glassArea()) + ' sq ft of glass)');
row.addField('Power', circuitText() + (hookup() ? ', hooked up by our electrician' : ''));
});
section.addRow(row => {
row.addField('Ready in', 'about ' + warmup() + ' min, to ' + temperature() + '°F');
row.addField('Extras', extrasText());
});
});
pdf.addSection('Estimate', section => {
const rows: string[][] = [
[kindName(), dimsText() + ', ' + WOODS[wood()]!.name.toLowerCase(), money(cabinAmount() + woodAmount() + sidingAmount())]
];
if (placementAmount() > 0) rows.push([PLACEMENTS[placement()]!.name, 'walls we build', money(placementAmount())]);
rows.push([fronts()[front()]!.name, GLASSES[glass()]!.name.toLowerCase() + ' glass, ' + HARDWARES[hardware()]!.name.toLowerCase() + ' hardware', money(frontAmount() + glassAmount() + hardwareAmount())]);
rows.push(['Heating', heatingText(), money(heatingAmount())]);
if (extrasAmount() > 0) rows.push(['Lights and extras', extrasText(), money(extrasAmount())]);
rows.push(['Installation', crewInstalls() ? prices.installPct + '% of the sauna with its equipment, with the site surcharges' : zone() === 'national' || zone() === 'none' ? 'kit with the assembly guide and a video call' : 'on your side', money(serviceAmount() - freight())]);
rows.push(['Delivery', deliveryText(), zone() === 'none' ? 'quoted separately' : money(freight())]);
rows.push(['Total before discounts', '', money(net())]);
if (prepayDiscount() > 0) rows.push(['Prepayment discount', prices.prepayPct + '%', '-' + money(prepayDiscount())]);
if (promo() > 0) rows.push([prices.promoText, prices.promoPct + '%', '-' + money(promo())]);
rows.push(['Estimate', 'range ±' + prices.rangePct + '%, sales tax as applicable', money(priceFrom()) + ' - ' + money(priceTo())]);
section.addTable(['Item', 'Details', 'Amount'], rows);
if (total() > Math.round(net() - prepayDiscount() - promo())) {
section.addText('The estimate was raised to the minimum order of ' + money(prices.minimum) + '.');
}
});
pdf.addSection('What happens next', section => {
section.addText(crewAvailable()
? '1. Site visit within ' + prices.visitDays + ' business days: we measure the space, check the panel' + (outdoors() ? ', the base and the access.' : ' and the ventilation.')
: '1. A video call within ' + prices.visitDays + ' business days: we walk through the space and the panel with you.');
section.addText('2. You receive the drawings, the final quote and the contract; until it is signed you can change the wood, the glass and the equipment.');
section.addText('3. Build ' + prices.leadWeeks + ' weeks from the signed contract, ' + (crewInstalls() ? 'installation and the first heat-up included.' : 'crated and shipped with the assembly guide.'));
});
const notes = contactSection.textarea('notes')?.value();
if (notes) {
pdf.addSection('Notes', section => section.addText(notes));
}
});
form.configureSubmitButton({ label: () => crewAvailable() ? 'Send and book the site visit' : 'Send and book the call' });
form.configureCompletionScreen({
type: 'text',
title: 'We have your request',
message: 'We will call to schedule the site visit. The size, the wood and the equipment from the form are the starting point for the design of your sauna.'
});
}