/* ============================================================================= Alpha Decking — Composite Cladding Calculator ----------------------------------------------------------------------------- Sister of deckingCalculator-v32.txt. Same architecture, same page contract conventions, same quote/share/rebuild machinery; only the product set, the calculation and the drawing differ. Read that file's header for the "why" behind the settle-loop, the quote state machine and the Webflow quirks — none of it is repeated here. HOW IT FITS TOGETHER CONFIG every price, product ID, dimension and business rule. readState reads the controls (walls, sides, openings, direction, colour) into a plain object. All control lookups live in SEL. calculate pure function: state in, quantities and prices out. No DOM. render* product lines, totals, info lines, the elevation SVG per wall. PAGE CONTRACT [data-calc-scope] wraps anything that should trigger recalculation [data-calc-wall="1".."6"] a wall block. Inside it, by CLASS (bare inputs in small HTML Embeds, ids stable per wall n): #w{n}n .wall-name optional name (text) #w{n}w .wall-width width in metres (number) #w{n}h .wall-height height in metres (number) input[name="w{n}L"] left side value corner|end|none (radio) input[name="w{n}R"] right side value corner|end|none (radio) input[name="w{n}T"] top edge value end|none (radio) input[name="w{n}B"] bottom edge value end|none (radio) [data-calc-opening] up to 3 opening rows, each with #w{n}o{k}w .op-width #w{n}o{k}h .op-height [data-calc-add-opening] reveals the next hidden opening row [data-calc-remove-opening] inside a row: clears + hides it [data-calc-remove-wall] clears + hides this wall block (walls 2–6) [data-calc-add-wall] reveals the next hidden wall block Wall blocks 2–6 and opening rows 2–3 start hidden: give them the attribute data-calc-hidden (the script toggles display, never classes). input[name="boardDirection"] value horizontal|vertical (radio) #ob1Checkbox add OB1 adhesive (checkbox) [data-swatch][data-name][data-hex] colour swatches; data-name must match the product Colour option text exactly [data-calc-line] a product block; role/range read from bound [data-calc-meta="role"/"range"] text inside it (Webflow can't bind a CMS field to an attribute). ONE board block; the script sets its Length dropdown to the job's panel length (v3). [data-line-price] price element within a product block [data-calc-placeholder] shown until at least one wall has a size [data-calc-subtotal] / #subtotal [data-calc-info="screws"] text: approx 35mm screw count (not sold) [data-calc-info="area"] text: area clad [data-calc-info-row] wrapper hidden while there is no wall [data-cladding-plans] empty div the elevations are injected into (any number of them) [data-calc-for="id"] makes ANY element act as the label for input #id [data-share-url] optional: the share link is written here [data-quote-email] [data-quote-consent] [data-quote-send] [data-quote-status] Roles (calc-role in the CMS): board cornerTrim endTrim ob1, and joistStandard (the shared Composite Joist — read as "batten" here). Range (calc-range in the CMS): juniper — omitted on joist and ob1. Product IDs and prices verified against live Webflow SKUs, 8 Sep 2026. No jQuery. Money is handled in pence as integers throughout. v1 (9 Sep 2026): first build, from mock-up v3. Quote webhook is EMPTY until the cladding Make.com scenario exists — the button reports "not set up yet". v2 (9 Sep 2026): the joist and OB1 products are shared with the decking calculator, whose page reads their existing CMS calc-role values (joistStandard / ob1). Rather than change those and break decking, this script accepts joistStandard as an alias for its batten role. Nothing else changed. v3 (9 Sep 2026): ONE panel length per job. Webflow's commerce script keeps every add-to-cart form for the same product in lockstep, so two board blocks (2.25m and 3.66m) can never hold different Length selections. The page now has a single board block; the calculator picks the length for the whole job — 2.25m only when every wall fits inside a 2.25m panel uncut, otherwise 3.66m throughout — and sets the block's Length dropdown to match. Per-metre prices are near-identical (£7.91 vs £7.92) so the customer pays the same per metre either way. Roles are now: board, cornerTrim, endTrim, ob1, joistStandard→batten. data-length on the block is no longer needed and is ignored. ============================================================================= */ (function () { 'use strict'; /* =========================================================================== CONFIG — everything adjustable lives here =========================================================================== */ const CONFIG = { /* --- Geometry (installation guide + CMS spec) ------------------------- */ coverWidth: 0.200, // CMS spec "200mm coverage" per 219mm tongue-and-groove panel; guide says // "200 mm per panel". Rows = ceil(across / 0.2). battenSpacing: 0.500, // Guide: "500 mm maximum centres". Battens run at 90° to the panels. minPieceLength: 0.50, // A cut piece must reach the next batten line at 500mm centres. Used only // to choose stagger offsets in the layout; it never changes panel counts. endGap: 0.005, // Guide: 5 mm between a panel end and a trim / corner. Taken off the run // once per trimmed end so a wall exactly one panel long is not called // "uncut" when it can't be. /* --- Waste (installation guide, verbatim) ----------------------------- */ wasteCut: 1.10, // "Add 10% for cuts" wasteCutOpenings: 1.15, // "15% if there are several windows and doors" // Applied to STAGGERED (cut) runs only. Where every row is one uncut panel // the offcut loss is exact and already counted. maxWalls: 6, maxOpenings: 3, /* --- Products ----------------------------------------------------------- */ products: { // Slatted Composite Cladding | Juniper Range — one product, two lengths. board225: { id: '6a82dda25e055ed0d0e976bb', length: 2.25, price: 1780, optionText: '2.25m' }, board366: { id: '6a82dda25e055ed0d0e976bb', length: 3.66, price: 2900, optionText: '3.66m' }, // Composite Joist 3.66m — the batten. batten: { id: '62daf105ecbbe48ef2f9ec34', length: 3.66, price: 1370 }, // Trims are 2.25m only, every colour £10.00. cornerTrim: { id: '6a832845e0ba0934acc6d16b', length: 2.25, price: 1000 }, endTrim: { id: '6a97df7bd2cbd18a4aa44f1c', length: 2.25, price: 1000 }, // OB1 300ml — optional, one tube per three trims (same rule as decking). ob1: { id: '6169ad68ad94d4602117efb1', price: 800, perTrims: 3 } }, /* --- Colours ------------------------------------------------------------ */ // Keys must match the product Colour option text EXACTLY (CMS says // "Black", not "Charcoal"). hex colours the elevation. colours: { 'Black': { hex: '#2b2b2b' }, 'Birch & Black': { hex: '#8a7a62' }, 'Silver Grey & Black': { hex: '#8d9296' }, 'Stone Grey & Black': { hex: '#6f6c66' } }, // Recognises a Colour dropdown in a product block (a select whose options // include at least two of these). colourNames: ['Black', 'Birch & Black', 'Silver Grey & Black', 'Stone Grey & Black'], /* --- Email me this quote ------------------------------------------------ */ quote: { webhookUrl: '', // TODO: cladding Make.com scenario (clone of 7169838) planImageWidth: 1600, cooldownMs: 8000, rateLimitMs: 10 * 60 * 1000, rateLimitBufferMs: 15 * 1000, storageKey: 'adCladQuoteSends', labels: { sending: 'Sending…', sent: '✓ Sent — check your inbox' } }, /* --- Elevation drawing --------------------------------------------------- */ plan: { bondSteps: 3, // third-bond stagger on cut runs maxDetailRows: 420, fallbackColour: '#2b2b2b', windowSill: 0.9, // metres — where an auto-placed window sits doorMinHeight: 1.85, // an opening at least this tall (and ≤ doorMaxWidth wide) draws as a door doorMaxWidth: 1.3 } }; /* =========================================================================== DOM CONTRACT — selectors =========================================================================== */ const SEL = { scope: '[data-calc-scope]', wall: '[data-calc-wall]', wallName: '.wall-name', wallWidth: '.wall-width', wallHeight: '.wall-height', opening: '[data-calc-opening]', opWidth: '.op-width', opHeight: '.op-height', addWall: '[data-calc-add-wall]', removeWall: '[data-calc-remove-wall]', addOpening: '[data-calc-add-opening]', removeOpening:'[data-calc-remove-opening]', direction: 'input[name="boardDirection"]', wantOb1: '#ob1Checkbox', line: '[data-calc-line]', qty: 'input[name="commerce-add-to-cart-quantity-input"]', linePrice: '[data-line-price]', placeholder: '[data-calc-placeholder]', subtotal: '#subtotal, [data-calc-subtotal]', infoScrews: '[data-calc-info="screws"]', infoArea: '[data-calc-info="area"]', infoRow: '[data-calc-info-row]', plans: '[data-cladding-plans]', swatch: '[data-swatch]', shareUrl: '[data-share-url]', quoteEmail: '[data-quote-email]', quoteConsent: '[data-quote-consent]', quoteSend: '[data-quote-send]', quoteStatus: '[data-quote-status]' }; const $ = (s, r) => { try { return (r || document).querySelector(s); } catch (e) { return null; } }; const $$ = (s, r) => { try { return Array.from((r || document).querySelectorAll(s)); } catch (e) { return []; } }; /* =========================================================================== HELPERS (as decking v32) =========================================================================== */ const money = p => '£' + (p / 100).toFixed(2); const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const norm = t => (t || '').trim().toLowerCase(); function setVisible(el, show) { if (!el) return; el.hidden = !show; if (!show) { el.style.display = 'none'; return; } el.style.display = el.dataset.calcDisplay || ''; if (getComputedStyle(el).display === 'none') { el.style.display = el.dataset.calcDisplay || 'block'; } } const isVisible = el => !!el && el.style.display !== 'none' && !el.hidden; function numOf(el) { if (!el) return 0; const v = parseFloat(el.value); return Number.isFinite(v) && v > 0 ? v : 0; } const isChecked = sel => { const el = $(sel); return !!(el && el.checked); }; function radio(name, fallback, root) { const el = $(name + ':checked', root); return el ? el.value : fallback; } function radioIn(root, name, fallback) { return radio('input[name="' + name + '"]', fallback, root); } /* =========================================================================== WALL BLOCKS — fixed blocks in the Designer, revealed on demand =========================================================================== */ const wallBlocks = () => $$(SEL.wall).sort((a, b) => (+a.dataset.calcWall) - (+b.dataset.calcWall)); const wallActive = el => !el.hasAttribute('data-calc-hidden'); function clearInputs(root) { $$('input', root).forEach(i => { if (i.type === 'radio' || i.type === 'checkbox') return; i.value = ''; }); } function showBlock(el) { el.removeAttribute('data-calc-hidden'); setVisible(el, true); } function hideBlock(el) { el.setAttribute('data-calc-hidden', ''); setVisible(el, false); clearInputs(el); } function addWall() { const next = wallBlocks().find(el => !wallActive(el)); if (next) showBlock(next); refreshAddButtons(); } function addOpening(wallEl) { const next = $$(SEL.opening, wallEl).find(el => el.hasAttribute('data-calc-hidden')); if (next) showBlock(next); refreshAddButtons(); } function refreshAddButtons() { const moreWalls = wallBlocks().some(el => !wallActive(el)); $$(SEL.addWall).forEach(b => setVisible(b, moreWalls)); wallBlocks().forEach(wallEl => { const more = $$(SEL.opening, wallEl).some(el => el.hasAttribute('data-calc-hidden')); $$(SEL.addOpening, wallEl).forEach(b => setVisible(b, more)); }); } function initBlocks() { // Anything marked hidden in the Designer starts hidden; wall 1 is always shown. wallBlocks().forEach((el, i) => { if (i === 0) showBlock(el); else if (el.hasAttribute('data-calc-hidden')) setVisible(el, false); $$(SEL.opening, el).forEach(o => { if (o.hasAttribute('data-calc-hidden')) setVisible(o, false); }); }); refreshAddButtons(); } /* =========================================================================== STATE =========================================================================== */ function readState() { const walls = wallBlocks().filter(wallActive).map(el => { const n = +el.dataset.calcWall; return { n, name: (($(SEL.wallName, el) || {}).value || '').trim().slice(0, 40), width: numOf($(SEL.wallWidth, el)), height: numOf($(SEL.wallHeight, el)), left: radioIn(el, 'w' + n + 'L', 'end'), right: radioIn(el, 'w' + n + 'R', 'end'), top: radioIn(el, 'w' + n + 'T', 'none'), bottom: radioIn(el, 'w' + n + 'B', 'none'), openings: $$(SEL.opening, el) .filter(o => !o.hasAttribute('data-calc-hidden')) .map(o => ({ w: numOf($(SEL.opWidth, o)), h: numOf($(SEL.opHeight, o)) })) .filter(o => o.w > 0 && o.h > 0) }; }); return { direction: radio(SEL.direction, 'horizontal'), wantOb1: isChecked(SEL.wantOb1), colour: selectedColourName, walls }; } /* =========================================================================== LAYOUT HELPERS (decking v32, minPiece 0.50) =========================================================================== */ function piecesAt(offset, run, L) { const out = []; let s = -offset; while (s < run - 1e-4) { const a = Math.max(0, s), b = Math.min(run, s + L); if (b - a > 1e-4) out.push([a, b]); s += L; } return out; } const offsetCache = {}; function layoutOffsets(run, L, steps, minPiece) { const key = [run, L, steps, minPiece].join('|'); if (offsetCache[key]) return offsetCache[key]; const valid = []; for (let o = 0; o < L - 1e-6; o += 0.01) { const shortest = piecesAt(o, run, L).reduce((m, p) => Math.min(m, p[1] - p[0]), Infinity); if (shortest >= minPiece - 1e-6) valid.push(+o.toFixed(3)); } let chosen = []; if (!valid.length) { for (let i = 0; i < steps; i++) chosen.push(i * (L / steps)); } else for (let i = 0; i < steps; i++) { const ideal = i * (L / steps); let best = null, bestD = Infinity; for (const o of valid) { if (chosen.some(c => Math.abs(c - o) < minPiece || Math.abs(Math.abs(c - o) - L) < minPiece)) continue; const d = Math.abs(o - ideal); if (d < bestD) { bestD = d; best = o; } } chosen.push(best === null ? valid[i % valid.length] : best); } offsetCache[key] = chosen; return chosen; } function rowPieces(r, run, L, mode) { if (mode === 'uncut') return [[0, run]]; const offs = layoutOffsets(run, L, CONFIG.plan.bondSteps, CONFIG.minPieceLength); return piecesAt(offs[r % offs.length], run, L); } /* =========================================================================== CALCULATE — pure. No DOM access in here. =========================================================================== */ function calcWall(w, direction, forceLen) { const P = CONFIG.products; const horizontal = direction === 'horizontal'; const run = horizontal ? w.width : w.height; // panels run along this const across = horizontal ? w.height : w.width; // rows stack along this const out = { n: w.n, name: w.name || ('Wall ' + w.n), run, across, horizontal, rows: 0, q225: 0, q366: 0, mode: 'none', L: 0, joints: 0, jointLines: 0, intermediate: 0, battenPieces: [], trims: { corner: 0, end: 0 }, screws: 0, openings: w.openings, sqmGross: 0, sqmNet: 0, waste: 1, usable: 0, sides: { left: w.left, right: w.right, top: w.top, bottom: w.bottom } }; if (run <= 0 || across <= 0) return out; // Ends of the RUN: horizontal → left/right, vertical → top/bottom. const endA = horizontal ? w.left : w.top, endB = horizontal ? w.right : w.bottom; const gaps = (endA !== 'none' ? 1 : 0) + (endB !== 'none' ? 1 : 0); const usable = run - gaps * CONFIG.endGap; out.usable = usable; out.rows = Math.ceil(across / CONFIG.coverWidth); const openArea = w.openings.reduce((a, o) => a + o.w * o.h, 0); out.sqmGross = run * across; out.sqmNet = Math.max(0, out.sqmGross - openArea); /* --- Panels --------------------------------------------------------- A run that fits inside one panel gets uncut panels: the shortest length that covers it, and several rows per panel where the run is short enough. Longer runs use 3.66m panels cut and staggered, with the guide's waste figure. Opening area only reduces cut runs — with uncut rows every row still needs a panel either side of the opening. */ if (!forceLen && usable <= P.board225.length) { out.mode = 'uncut'; out.L = P.board225.length; out.q225 = Math.ceil(out.rows / Math.max(1, Math.floor(P.board225.length / usable))); } else if (usable <= P.board366.length) { out.mode = 'uncut'; out.L = P.board366.length; out.q366 = Math.ceil(out.rows / Math.max(1, Math.floor(P.board366.length / usable))); } else { out.mode = 'cut'; out.L = P.board366.length; out.waste = w.openings.length ? CONFIG.wasteCutOpenings : CONFIG.wasteCut; const linear = out.rows * usable - openArea / CONFIG.coverWidth; out.q366 = Math.ceil(linear / P.board366.length * out.waste); // Distinct joint positions → twin batten lines (guide: "a twin at every butt joint"). const pos = new Set(); for (let r = 0; r < out.rows; r++) rowPieces(r, usable, out.L, 'cut').forEach(p => { if (p[1] < usable - 1e-4) { out.joints++; pos.add(Math.round(p[1] * 100)); } }); out.jointLines = pos.size; } /* --- Battens (guide: "run ÷ 0.5 m, plus one, plus the perimeter, plus a twin at every butt joint") ------------------------------------------- */ out.intermediate = Math.ceil(run / CONFIG.battenSpacing) + 1; for (let i = 0; i < out.intermediate + out.jointLines; i++) out.battenPieces.push(across); out.battenPieces.push(run, run); // the two perimeter battens out.screws = out.rows * out.intermediate; // one 35mm screw per panel per batten crossed /* --- Trims --------------------------------------------------------------- */ const H = w.height, W = w.width; out.trims.corner = (w.left === 'corner' ? H : 0) + (w.right === 'corner' ? H : 0); out.trims.end = (w.left === 'end' ? H : 0) + (w.right === 'end' ? H : 0) + (w.top === 'end' ? W : 0) + (w.bottom === 'end' ? W : 0) + w.openings.reduce((a, o) => a + 2 * (o.w + o.h), 0); return out; } /** Sticks of length L needed to cut the given pieces, pooled by identical length. */ function sticksFor(pieces, L) { const groups = {}; pieces.forEach(p => { const k = p.toFixed(3); groups[k] = (groups[k] || 0) + 1; }); let sticks = 0; Object.keys(groups).forEach(k => { const len = +k, n = groups[k]; sticks += len <= L ? Math.ceil(n / Math.floor(L / len)) : n * Math.ceil(len / L); }); return sticks; } function calculate(s) { const P = CONFIG.products; /* v3: one panel length for the whole job. Pass 1 lets each wall pick its own best length; if that yields a mix, pass 2 forces 3.66m everywhere. */ let walls = s.walls.map(w => calcWall(w, s.direction, null)); let live = walls.filter(w => w.run > 0); const sum = f => live.reduce((a, w) => a + f(w), 0); let panelLength = 2.25; if (live.some(w => w.q366 > 0)) { panelLength = 3.66; if (live.some(w => w.q225 > 0)) { walls = s.walls.map(w => calcWall(w, s.direction, 3.66)); live = walls.filter(w => w.run > 0); } } const boardCfg = panelLength === 2.25 ? P.board225 : P.board366; const boards = sum(w => w.q225 + w.q366); const battens = sticksFor([].concat(...live.map(w => w.battenPieces)), P.batten.length); const corner = Math.ceil(sum(w => w.trims.corner) / P.cornerTrim.length); const end = Math.ceil(sum(w => w.trims.end) / P.endTrim.length); const ob1 = s.wantOb1 && corner + end > 0 ? Math.ceil((corner + end) / P.ob1.perTrims) : 0; const has = live.length > 0; const lines = []; const add = (role, cfg, qty, visible) => lines.push({ role, id: cfg.id, qty: visible ? Math.max(0, qty) : 0, price: cfg.price, visible: !!visible && qty > 0 }); add('board', boardCfg, boards, has); add('batten', P.batten, battens, has); add('cornerTrim', P.cornerTrim, corner, has); add('endTrim', P.endTrim, end, has); add('ob1', P.ob1, ob1, has && s.wantOb1); return { lines, walls, has, panelLength, boards, screws: sum(w => w.screws), sqmGross: sum(w => w.sqmGross), sqmNet: sum(w => w.sqmNet), direction: s.direction, colour: s.colour }; } /* =========================================================================== RENDER =========================================================================== */ let selectedColourName = null; // set from swatches / share link / product panel let lastResult = null, lastTotals = null; function metaOf(el, which, attrValue) { if (attrValue) return attrValue.trim(); const node = el.querySelector('[data-calc-meta="' + which + '"]'); return node ? node.textContent.trim() : ''; } /** The effective role of a product block: the board product's two blocks are told apart by data-length="2.25" / "3.66" on the block. */ const ROLE_ALIASES = { joistStandard: 'batten' }; // v2: shared decking product function lineRole(el) { const r = metaOf(el, 'role', el.dataset.role); return ROLE_ALIASES[r] || r; } function findLines(role) { return $$(SEL.line).filter(el => lineRole(el) === role); } function render(result) { $$(SEL.placeholder).forEach(el => setVisible(el, !result.has)); let subtotal = 0; const totals = {}; result.lines.forEach(line => { const total = line.qty * line.price; totals[line.role] = total; subtotal += total; findLines(line.role).forEach(el => { setVisible(el, line.visible); const qtyInput = $(SEL.qty, el); if (qtyInput) { qtyInput.value = line.qty; qtyInput.dispatchEvent(new Event('change', { bubbles: true })); } const priceEl = $(SEL.linePrice, el); if (priceEl) priceEl.textContent = money(total); }); }); // Any product block with a role the calculator doesn't produce stays hidden. const known = result.lines.map(l => l.role); $$(SEL.line).forEach(el => { if (!known.includes(lineRole(el))) setVisible(el, false); }); $$(SEL.subtotal).forEach(el => { el.textContent = money(subtotal); }); $$(SEL.infoRow).forEach(el => setVisible(el, result.has)); $$(SEL.infoScrews).forEach(el => { el.textContent = 'approx. ' + result.screws; }); $$(SEL.infoArea).forEach(el => { el.textContent = result.sqmNet.toFixed(1) + ' m²' + (result.sqmNet < result.sqmGross - 1e-6 ? ' (' + result.sqmGross.toFixed(1) + ' m² before openings)' : ''); }); lastResult = result; lastTotals = totals; renderSwatches(); renderPlans(result); $$(SEL.shareUrl).forEach(el => { el.textContent = buildShareUrl(readState()); }); } function renderSwatches() { $$(SEL.swatch).forEach(el => el.classList.toggle('is-selected', norm(el.dataset.name) === norm(selectedColourName))); } /* =========================================================================== ELEVATION — the wall as it will look from outside: slatted panels, openings as windows/doors, trims on the edges, eaves and ground. Battens are behind the cladding so they are not drawn. Reads geometry from the calculation result only. Draws into EVERY [data-cladding-plans] host. =========================================================================== */ function ensurePlanStyles() { if (document.getElementById('calc-plan-css')) return; const st = document.createElement('style'); st.id = 'calc-plan-css'; st.textContent = '.calc-plan-wrap{position:relative;width:100%;margin-bottom:18px;}' + '.calc-plan-wrap svg{display:block;width:100%;height:auto;}' + '.calc-plan-wrap .pc:hover .body{fill:rgba(15,110,99,.35);stroke:#0f6e63;stroke-width:1.5;}' + '.calc-plan-tip{position:absolute;pointer-events:none;opacity:0;' + 'transform:translate(-50%,-145%);background:#0f6e63;color:#f6f4ef;' + 'padding:6px 10px;border-radius:3px;font-size:11.5px;white-space:nowrap;' + 'transition:opacity .1s;z-index:5;}' + '.calc-plan-tip small{display:block;opacity:.75;font-size:10px;}' + '.calc-plan-tip.on{opacity:1;}' + '.calc-plan-title{display:flex;justify-content:space-between;font-size:13px;color:#5a6b74;margin:0 0 4px;}' + '[data-calc-for]{cursor:pointer;}'; document.head.appendChild(st); } function lum(hex) { const n = parseInt(hex.slice(1), 16); return (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; } function shade(hex, amt) { const n = parseInt(hex.slice(1), 16); const c = i => Math.max(0, Math.min(255, ((n >> i) & 255) + amt)); return '#' + ((1 << 24) + (c(16) << 16) + (c(8) << 8) + c(0)).toString(16).slice(1); } function dimH(x1, x2, y, label) { return '' + '' + '' + label + ''; } function dimV(x, y1, y2, label) { return '' + '' + '' + label + ''; } function renderPlans(result) { $$(SEL.plans).forEach(host => drawPlansInto(host, result)); } function drawPlansInto(host, result) { if (!host) return; ensurePlanStyles(); const live = result.walls.filter(w => w.run > 0); const colour = (CONFIG.colours[result.colour] || {}).hex || CONFIG.plan.fallbackColour; const key = JSON.stringify([colour, live.map(w => [w.n, w.name, w.run, w.across, w.horizontal, w.mode, w.q225, w.q366, w.openings, w.sides])]); if (key === host.dataset.planKey) return; host.dataset.planKey = key; if (!live.length) { host.innerHTML = '
' + 'Enter a wall size to see its elevation
'; return; } host.innerHTML = live.map(w => '
' + esc(w.name) + ' — ' + (w.horizontal ? 'horizontal' : 'vertical') + ' panels, ' + (w.mode === 'uncut' ? 'uncut ' + w.L.toFixed(2) + 'm' : 'staggered 3.66m') + '' + '' + w.rows + ' rows · ' + (w.intermediate + w.jointLines) + ' battens behind
' + drawWall(w, colour)).join(''); if (!host.dataset.calcTipBound) { host.dataset.calcTipBound = '1'; host.addEventListener('mousemove', e => { const wrap = e.target.closest('.calc-plan-wrap'); if (!wrap) return; const tip = wrap.querySelector('.calc-plan-tip'); const pc = e.target.closest('.pc'); if (!tip) return; if (!pc) { tip.classList.remove('on'); return; } const r = wrap.getBoundingClientRect(); tip.style.left = (e.clientX - r.left) + 'px'; tip.style.top = (e.clientY - r.top) + 'px'; tip.innerHTML = pc.dataset.kind + ' — ' + pc.dataset.len + ' mRow ' + pc.dataset.row + ''; tip.classList.add('on'); }); host.addEventListener('mouseleave', () => $$('.calc-plan-tip', host).forEach(t => t.classList.remove('on'))); } } function drawWall(w, colour) { const W = w.horizontal ? w.run : w.across, H = w.horizontal ? w.across : w.run; const VW = 1600, mL = 120, mR = 70, mT = 150, mB = 190, aw = VW - mL - mR; const scale = Math.min(aw / W, 760 / H); const pw = W * scale, ph = H * scale, VH = Math.round(mT + ph + mB); const ox = mL + (aw - pw) / 2, oy = mT; const X = m => ox + m * scale, Y = m => oy + (H - m) * scale; const n = w.n, id = s => s + n; const cover = CONFIG.coverWidth * scale, slat = cover / 4; const dark = shade(colour, -48), hi = shade(colour, 18), trimC = shade(colour, -22); const P = []; P.push('' + '' + (w.horizontal ? '' + [0, 1, 2, 3].map(i => '').join('') + '' : '' + [0, 1, 2, 3].map(i => '').join('') + '') + '' + '' + ''); P.push(''); const gy = Y(0); P.push(''); const ovh = 0.25 * scale, fascia = 0.16 * scale, roofH = 0.42 * scale; P.push(''); P.push(''); P.push(''); P.push(''); // joints + hover pieces const usable = w.usable; const rowH = (w.horizontal ? ph : pw) / w.rows; if (w.rows <= CONFIG.plan.maxDetailRows) for (let r = 0; r < w.rows; r++) { rowPieces(r, usable, w.L, w.mode).forEach(p => { const Lm = p[1] - p[0], full = Lm >= w.L - 1e-4; let x, y, bw, bh; if (w.horizontal) { x = X(p[0]); bw = Lm * scale; y = Y(H) + r * rowH; bh = rowH; } else { y = Y(p[1]); bh = Lm * scale; x = X(0) + r * rowH; bw = rowH; } P.push('' + '' + '' + (full ? 'Full panel' : 'Cut piece') + ' — ' + Lm.toFixed(2) + 'm'); if (p[1] < usable - 1e-4) { const jw = Math.max(1.2, 0.005 * scale); if (w.horizontal) P.push(''); else P.push(''); } }); } // openings — auto-placed: doors to the ground, windows at the sill height if (w.openings.length) { const totalW = w.openings.reduce((a, o) => a + o.w, 0), gap = Math.max(0.25, (W - totalW) / (w.openings.length + 1)); const tw = Math.max(3, 0.0485 * scale), fw = Math.max(2.5, 0.06 * scale); let cx = gap; w.openings.forEach(o => { const isDoor = o.h >= CONFIG.plan.doorMinHeight && o.w <= CONFIG.plan.doorMaxWidth; const y0 = isDoor ? 0 : Math.max(0.1, Math.min(CONFIG.plan.windowSill, H - o.h - 0.15)); const x1 = X(cx), x2 = X(cx + o.w), yT = Y(y0 + o.h), yB = Y(y0), ow = x2 - x1, oh = yB - yT; P.push(''); P.push(''); if (isDoor) { P.push(''); P.push(''); P.push(''); } else { P.push(''); if (o.w > 0.9) P.push(''); P.push(''); } P.push('' + o.w.toFixed(2) + ' × ' + o.h.toFixed(2) + (isDoor ? ' door' : ' window') + ''); cx += o.w + gap; }); } // corner + end trims on the four sides const st = w.sides, cw = Math.max(4, 0.0485 * scale), ew = Math.max(3, 0.03 * scale); const strip = (x, y, wd, ht, kind) => kind === 'none' ? '' : '' + ''; const lw = st.left === 'corner' ? cw : ew, rw = st.right === 'corner' ? cw : ew; P.push(strip(X(0) - lw, Y(H), lw, ph, st.left), strip(X(W), Y(H), rw, ph, st.right), strip(X(0) - lw, Y(H) - ew, pw + lw + rw, ew, st.top), strip(X(0) - lw, Y(0), pw + lw + rw, ew, st.bottom)); P.push(''); P.push(dimH(X(0), X(W), Y(0) + 44, W.toFixed(2) + ' m'), dimV(ox - 56, Y(H), Y(0), H.toFixed(2) + ' m')); // title block + legend const tw2 = 640, th = 92, tx = VW - mR - tw2, ty = VH - 108; P.push('' + '' + 'ELEVATION — ' + esc(w.name).toUpperCase() + ''); const cells = [ ['SIZE', W.toFixed(2) + ' × ' + H.toFixed(2) + ' m'], ['AREA', w.sqmNet.toFixed(1) + ' m²'], ['PANELS', (w.q225 + w.q366) + ' @ ' + w.L.toFixed(2) + 'm'], ['JOINTS', w.joints], ['BATTENS', (w.intermediate + w.jointLines) + ' @ 500mm'] ]; const cwid = tw2 / cells.length; cells.forEach((c, i) => { const cx = tx + i * cwid; if (i) P.push(''); P.push('' + c[0] + '' + '' + c[1] + ''); }); const lx = mL, ly = VH - 64; const swb = (x, fill) => ''; P.push(swb(lx, colour) + 'Panel' + swb(lx + 95, shade(colour, -30)) + 'Corner trim' + swb(lx + 225, trimC) + 'End trim' + 'Butt joint' + 'Hover a panel for its cut length'); const aria = esc(w.name) + ': ' + w.rows + ' rows of ' + (w.horizontal ? 'horizontal' : 'vertical') + ' panels, ' + w.joints + ' butt joints.'; return '
' + P.join('') + '
'; } /* =========================================================================== PRODUCT PANEL SELECTS — colour + length, kept in step with the calculator =========================================================================== */ function isColourSelect(sel) { const names = CONFIG.colourNames.map(norm); let hits = 0; for (const o of sel.options) if (names.includes(norm(o.text))) hits++; return hits >= 2; } function isLengthSelect(sel) { for (const o of sel.options) if (/^\s*\d\.\d+\s*m\s*$/i.test(o.text)) return true; return false; } function pickOption(sel, matcher) { for (let i = 0; i < sel.options.length; i++) { if (matcher(sel.options[i])) { if (sel.selectedIndex !== i) { sel.selectedIndex = i; sel.dispatchEvent(new Event('change', { bubbles: true })); } return true; } } return false; } /** The colour currently chosen in any visible product block, if any. */ function readSelectedColourName() { for (const line of $$(SEL.line)) { if (!line.offsetParent) continue; for (const sel of $$('select', line)) { if (!isColourSelect(sel)) continue; const o = sel.options[sel.selectedIndex]; if (o && CONFIG.colourNames.map(norm).includes(norm(o.text))) return o.text.trim(); } } return null; } /** * Put every product panel's selects in step with the calculator: the chosen * colour in every Colour dropdown, the right length in each board block's * Length dropdown, and the only real option where there is just one. * Idempotent — pickOption only fires change when something differs — so * the restore guard can call it as often as it likes. */ function syncProductSelections() { $$(SEL.line).forEach(line => { const role = lineRole(line); $$('select', line).forEach(sel => { if (selectedColourName && isColourSelect(sel)) { pickOption(sel, o => norm(o.text) === norm(selectedColourName)); } else if (isLengthSelect(sel)) { const want = role === 'board' && lastResult ? (lastResult.panelLength === 2.25 ? CONFIG.products.board225 : CONFIG.products.board366).optionText : null; if (want) pickOption(sel, o => norm(o.text) === norm(want)); else if (sel.options.length === 2 && !sel.value) { sel.selectedIndex = 1; sel.dispatchEvent(new Event('change', { bubbles: true })); } } else if (sel.options.length === 2 && !sel.value) { sel.selectedIndex = 1; try { sel.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) { if (window.console) console.warn('Calculator: a change listener threw', e); } } }); }); } /* =========================================================================== SHARE LINK + REBUILD-CART LINK =========================================================================== */ const sideCode = { corner: 'c', end: 'e', none: 'n' }; const sideFromCode = { c: 'corner', e: 'end', n: 'none' }; function buildShareUrl(s) { const q = new URLSearchParams(); q.set('d', s.direction === 'vertical' ? 'v' : 'h'); s.walls.forEach(w => { if (!(w.width > 0 && w.height > 0)) return; q.set('w' + w.n, w.width + 'x' + w.height + ':' + [w.left, w.right, w.top, w.bottom].map(v => sideCode[v] || 'n').join('')); if (w.name) q.set('n' + w.n, w.name); if (w.openings.length) q.set('o' + w.n, w.openings.map(o => o.w + 'x' + o.h).join(',')); }); if (s.colour) q.set('colour', s.colour); if (s.wantOb1) q.set('ob1', '1'); const po = readProductOptions(); if (po.length) q.set('po', po.join('|')); return location.origin + location.pathname + '?' + q.toString(); } /** Every option chosen in a visible product block, as role:selectIndex:text. */ function readProductOptions() { const out = []; $$(SEL.line).forEach(line => { if (!line.offsetParent) return; const role = lineRole(line); if (!role) return; $$('select', line).forEach((sel, i) => { if (sel.selectedIndex > 0) out.push(role + ':' + i + ':' + sel.options[sel.selectedIndex].text.trim()); }); }); return out; } function applyProductOptions(entries) { entries.forEach(e => { const [role, idx, ...rest] = e.split(':'); const text = rest.join(':'); findLines(role).forEach(line => { const sel = $$('select', line)[+idx]; if (sel) pickOption(sel, o => norm(o.text) === norm(text)); }); }); } function buildRebuildUrl() { const r = lastResult; if (!r || !r.lines) return null; const items = []; r.lines.forEach(l => { if (!l.visible || l.qty <= 0 || !l.id) return; const opts = []; const el = findLines(l.role)[0]; if (el) { $$('select', el).forEach(sel => { if (sel.selectedIndex > 0) opts.push(sel.options[sel.selectedIndex].text.trim().replace(/[~;|]/g, ' ')); }); } items.push(l.id + '~' + l.qty + (opts.length ? '~' + opts.join('|') : '')); }); if (!items.length) return null; const subtotal = Object.values(lastTotals || {}).reduce((a, b) => a + b, 0); const q = new URLSearchParams(); q.set('rc', '1'); q.set('i', items.join(';')); if (subtotal > 0) q.set('t', String(subtotal)); q.set('src', 'quote'); q.set('rid', 'q_' + Date.now().toString(36)); return location.origin + '/your-cart?' + q.toString(); } /** Reverse of buildShareUrl: restores every control on load. */ function applyShareParams() { const q = new URLSearchParams(location.search); if (!q.has('w1')) return false; const setRadio = (root, name, v) => { const el = $('input[name="' + name + '"][value="' + v + '"]', root); if (el) el.checked = true; }; if (q.get('d') === 'v') { const el = $(SEL.direction + '[value="vertical"]'); if (el) el.checked = true; } wallBlocks().forEach(el => { const n = +el.dataset.calcWall; const v = q.get('w' + n); if (!v) { if (n > 1) hideBlock(el); return; } showBlock(el); const [dims, sides] = v.split(':'), [W, H] = dims.split('x'); const wI = $(SEL.wallWidth, el), hI = $(SEL.wallHeight, el), nI = $(SEL.wallName, el); if (wI) wI.value = W; if (hI) hI.value = H; if (nI && q.get('n' + n)) nI.value = q.get('n' + n).slice(0, 40); ['L', 'R', 'T', 'B'].forEach((k, i) => { const code = (sides || '')[i]; if (sideFromCode[code]) setRadio(el, 'w' + n + k, sideFromCode[code]); }); const ops = (q.get('o' + n) || '').split(',').filter(Boolean); $$(SEL.opening, el).forEach((row, k) => { if (k < ops.length) { showBlock(row); const [ow, oh] = ops[k].split('x'); const a = $(SEL.opWidth, row), b = $(SEL.opHeight, row); if (a) a.value = ow; if (b) b.value = oh; } else if (row.hasAttribute('data-calc-hidden')) setVisible(row, false); }); }); if (q.get('colour') && CONFIG.colours[q.get('colour')]) selectedColourName = q.get('colour'); if (q.get('ob1') === '1') { const el = $(SEL.wantOb1); if (el) el.checked = true; } refreshAddButtons(); return true; } /* =========================================================================== EMAIL ME THIS QUOTE (decking v31/v32 machinery, cladding payload) =========================================================================== */ const pounds = pence => Math.round(pence) / 100; /** Every wall elevation rendered to a PNG data URL (email clients strip SVG). */ function plansToPng() { const svgs = $$(SEL.plans + ' svg').filter(s => s.closest('.calc-plan-wrap') && s.closest('.calc-plan-wrap').dataset.planWall); return Promise.all(svgs.map(svg => new Promise(resolve => { const vb = (svg.getAttribute('viewBox') || '0 0 1600 1000').split(/\s+/).map(Number); const w = CONFIG.quote.planImageWidth, h = Math.round(w * vb[3] / vb[2]); const clone = svg.cloneNode(true); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', w); clone.setAttribute('height', h); clone.setAttribute('style', 'font-family:Poppins,Arial,sans-serif;background:#f6f4ef'); const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' })); const img = new Image(); img.onload = () => { try { const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d'); ctx.fillStyle = '#f6f4ef'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); resolve(c.toDataURL('image/png')); } catch (e) { resolve(null); } URL.revokeObjectURL(url); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(null); }; img.src = url; }))); } function quoteSnapshot(email, consent) { const r = lastResult, s = readState(); const lines = r.lines.filter(l => l.visible && l.qty > 0).map(l => { const el = findLines(l.role)[0]; const heading = el ? $('h1,h2,h3,h4,h5,h6', el) : null; const img = el ? $('img', el) : null; const link = el ? $('a[href]', el) : null; return { role: l.role, productId: l.id, name: heading ? heading.textContent.trim() : l.role, qty: l.qty, unitPrice: pounds(l.price), lineTotal: pounds(l.qty * l.price), unitPriceText: money(l.price), lineTotalText: money(l.qty * l.price), image: img ? (img.currentSrc || img.src) : null, url: link ? link.href : null }; }); const subtotal = Object.values(lastTotals || {}).reduce((a, b) => a + b, 0); const colourName = selectedColourName || readSelectedColourName(); return { customer: { email, consentToFollowUp: !!consent }, cladding: { direction: s.direction, colour: colourName, colourHex: (CONFIG.colours[colourName] || {}).hex || null, walls: r.walls.filter(w => w.run > 0).map(w => ({ n: w.n, name: w.name, width: +(w.horizontal ? w.run : w.across).toFixed(2), height: +(w.horizontal ? w.across : w.run).toFixed(2), sizeText: (w.horizontal ? w.run : w.across).toFixed(2) + ' x ' + (w.horizontal ? w.across : w.run).toFixed(2) + ' m', rows: w.rows, panels: w.q225 + w.q366, panelLength: w.L, layout: w.mode, joints: w.joints, battenLines: w.intermediate + w.jointLines, sides: w.sides, openings: w.openings, sqm: +w.sqmNet.toFixed(2) })), sqmNet: +r.sqmNet.toFixed(2), sqmGross: +r.sqmGross.toFixed(2), sqmText: r.sqmNet.toFixed(1) + ' m²', panels: r.boards, panelLength: r.panelLength, screwsApprox: r.screws, ob1: !!s.wantOb1 }, lines, totals: { subtotal: pounds(subtotal), subtotalText: money(subtotal) }, shareUrl: buildShareUrl(s), rebuildUrl: buildRebuildUrl(), meta: { sentAt: new Date().toISOString(), page: location.href, scriptVersion: 'v3', calculator: 'cladding' } }; } let quoteBusy = false, quoteIdleLabel = null, quoteCooldownTimer = null; function quoteSay(msg, state) { const status = $(SEL.quoteStatus); if (!status) return; status.textContent = msg; status.dataset.state = state || 'ok'; } function quoteSetState(state) { const btn = $(SEL.quoteSend); if (!btn) return; if (quoteIdleLabel === null) quoteIdleLabel = btn.textContent.trim(); btn.dataset.state = state; const labels = CONFIG.quote.labels || {}; btn.textContent = state === 'sending' ? (labels.sending || 'Sending…') : state === 'sent' ? (labels.sent || 'Sent — check your inbox') : quoteIdleLabel; if (state === 'sending') btn.setAttribute('aria-busy', 'true'); else btn.removeAttribute('aria-busy'); if (state === 'sending' || state === 'sent') btn.setAttribute('aria-disabled', 'true'); else btn.removeAttribute('aria-disabled'); } function quoteLoadSends() { try { return JSON.parse(localStorage.getItem(CONFIG.quote.storageKey) || '{}') || {}; } catch (e) { return {}; } } function quoteSaveSends(map) { try { localStorage.setItem(CONFIG.quote.storageKey, JSON.stringify(map)); } catch (e) { /* private mode etc. */ } } function quoteWindowMs() { return (CONFIG.quote.rateLimitMs || 0) + (CONFIG.quote.rateLimitBufferMs || 0); } function quotePruneSends() { const map = quoteLoadSends(), cutoff = Date.now() - quoteWindowMs(); let changed = false; Object.keys(map).forEach(k => { if (!(map[k] > cutoff)) { delete map[k]; changed = true; } }); if (changed) quoteSaveSends(map); return map; } function quoteRecordSend(email) { const map = quotePruneSends(); map[email.toLowerCase()] = Date.now(); quoteSaveSends(map); } function quoteCooldownRemaining(email) { const at = quotePruneSends()[email.toLowerCase()]; return at ? Math.max(0, at + quoteWindowMs() - Date.now()) : 0; } function quoteRemainingText(ms) { if (ms < 45000) return 'in under a minute'; const mins = Math.round(ms / 60000); return mins <= 1 ? 'in about a minute' : 'in about ' + mins + ' minutes'; } function refreshQuoteState() { if (quoteBusy) return; const btn = $(SEL.quoteSend); if (!btn) return; const emailEl = $(SEL.quoteEmail); const email = (emailEl && emailEl.value || '').trim(); const left = email ? quoteCooldownRemaining(email) : 0; clearTimeout(quoteCooldownTimer); if (left > 0) { quoteSetState('sent'); quoteSay('Sent to ' + email + '. You can send an updated quote to this address ' + quoteRemainingText(left) + '.', 'cooldown'); quoteCooldownTimer = setTimeout(refreshQuoteState, Math.min(left, 30000)); } else if (btn.dataset.state === 'sent') { quoteSetState('idle'); quoteSay('', 'ok'); } else if (!btn.dataset.state) { quoteSetState('idle'); } } async function sendQuote() { const btn = $(SEL.quoteSend); if (quoteBusy) return; if (btn && btn.getAttribute('aria-disabled') === 'true') return; const emailEl = $(SEL.quoteEmail); const email = (emailEl && emailEl.value || '').trim(); if (!lastResult || !lastResult.has) return quoteSay('Enter a wall size first.', 'error'); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return quoteSay('Please enter a valid email address.', 'error'); if (!CONFIG.quote.webhookUrl) return quoteSay('Quote emails are not set up yet.', 'error'); if (quoteCooldownRemaining(email) > 0) return refreshQuoteState(); quoteBusy = true; quoteSetState('sending'); quoteSay('Sending your quote…', 'ok'); let ok = false; try { const payload = quoteSnapshot(email, isChecked(SEL.quoteConsent)); const pngs = await plansToPng(); payload.planPngs = pngs; payload.planPng = pngs[0] || null; // the first wall, for templates that expect one image const body = JSON.stringify(payload); try { const res = await fetch(CONFIG.quote.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); ok = res.ok; } catch (e) { await fetch(CONFIG.quote.webhookUrl, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'text/plain' }, body }); ok = true; } } catch (e) { ok = false; } if (ok) { quoteRecordSend(email); quoteBusy = false; refreshQuoteState(); } else { quoteSetState('error'); quoteSay('Something went wrong. Please try again.', 'error'); setTimeout(() => { quoteBusy = false; quoteSetState('idle'); refreshQuoteState(); }, CONFIG.quote.cooldownMs); } } /* =========================================================================== WIRING — one listener, one update path =========================================================================== */ let frame = null; function scheduleUpdate() { if (frame) { cancelAnimationFrame(frame); clearTimeout(frame); frame = null; } frame = document.hidden ? setTimeout(update, 0) : requestAnimationFrame(update); } function update() { render(calculate(readState())); syncProductSelections(); } function onChange(e) { if (!e.target.closest(SEL.scope)) return; // A colour picked in a product panel drives the calculator too. if (e.target.tagName === 'SELECT' && e.target.closest(SEL.line) && isColourSelect(e.target)) { const o = e.target.options[e.target.selectedIndex]; if (o && CONFIG.colours[o.text.trim()]) selectedColourName = o.text.trim(); } scheduleUpdate(); } function wireLabels() { $$('[data-calc-for]').forEach((el, i) => { const id = el.dataset.calcFor; const input = document.getElementById(id); if (!input) return; if (el.tagName === 'LABEL') { el.setAttribute('for', id); return; } if (!el.id) el.id = 'calcLabel' + i; if (input.getAttribute('aria-labelledby') !== el.id) input.setAttribute('aria-labelledby', el.id); }); } function init() { initBlocks(); document.addEventListener('input', onChange); document.addEventListener('change', onChange); wireLabels(); // Default colour: the selected swatch, else the first swatch. const preset = $$(SEL.swatch).find(el => el.classList.contains('is-selected')) || $$(SEL.swatch)[0]; if (preset && preset.dataset.name) selectedColourName = preset.dataset.name; const fromShare = applyShareParams(); document.addEventListener('click', e => { const t = e.target; if (t.closest(SEL.quoteSend)) { e.preventDefault(); sendQuote(); return; } if (t.closest(SEL.addWall)) { e.preventDefault(); addWall(); scheduleUpdate(); return; } const rw = t.closest(SEL.removeWall); if (rw) { e.preventDefault(); const wallEl = rw.closest(SEL.wall); if (wallEl && +wallEl.dataset.calcWall > 1) { hideBlock(wallEl); refreshAddButtons(); scheduleUpdate(); } return; } const ao = t.closest(SEL.addOpening); if (ao) { e.preventDefault(); addOpening(ao.closest(SEL.wall)); return; } const ro = t.closest(SEL.removeOpening); if (ro) { e.preventDefault(); hideBlock(ro.closest(SEL.opening)); refreshAddButtons(); scheduleUpdate(); return; } const sw = t.closest(SEL.swatch); if (sw && sw.closest(SEL.scope)) { if (sw.tagName === 'A') e.preventDefault(); if (sw.dataset.name) selectedColourName = sw.dataset.name; scheduleUpdate(); return; } const trigger = t.closest('[data-calc-for]'); if (trigger && trigger.closest(SEL.scope)) { const input = document.getElementById(trigger.dataset.calcFor); if (!input || input.disabled) return; if (trigger.tagName === 'LABEL' && trigger.getAttribute('for')) return; if (input.type === 'checkbox') input.checked = !input.checked; else if (input.type === 'radio') input.checked = true; else { input.focus(); return; } input.dispatchEvent(new Event('change', { bubbles: true })); } }); document.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.matches(SEL.quoteEmail)) { e.preventDefault(); sendQuote(); } }); document.addEventListener('input', e => { if (e.target.matches(SEL.quoteEmail)) refreshQuoteState(); }); refreshQuoteState(); if ('ResizeObserver' in window) { const ro = new ResizeObserver(() => scheduleUpdate()); $$(SEL.plans).forEach(el => ro.observe(el)); } update(); /* Keep the product panels in step after Webflow's commerce script resets the variant selects — the v27–v30 guard from decking, unchanged in spirit: three heartbeats, idempotent body, stands down only when the visitor touches a control in a product panel. Runs on EVERY load (not just share links) because the board blocks' Length dropdowns must be pre-set for add-to-cart to work at all. */ const q = new URLSearchParams(location.search); const restore = () => { syncProductSelections(); if (fromShare && q.get('po')) applyProductOptions(q.get('po').split('|')); }; const started = Date.now(); const crumb = msg => { try { console.info('[calc-restore] ' + (Date.now() - started) + 'ms ' + msg); } catch (e) {} }; let userTookOver = false; const handsOff = e => { if (!e.isTrusted || !e.target.closest) return; if (!e.target.closest(SEL.line)) return; if (e.target.closest('select, input, button, label, [data-calc-for]')) { userTookOver = true; crumb('visitor took over (' + e.type + ' on ' + e.target.tagName + ')'); } }; document.addEventListener('pointerdown', handsOff, true); document.addEventListener('keydown', handsOff, true); const watched = () => $$(SEL.line + ' select').map(sel => sel.selectedIndex).join(','); crumb('guard armed; colour=' + (selectedColourName || 'none')); restore(); let lastState = watched(); let stopped = false, lastRun = 0, alive = { iv: false, raf: false, mo: false }; const stopAll = why => { if (stopped) return; stopped = true; crumb('guard stopped (' + why + ') → [' + watched() + ']'); clearInterval(iv); try { mo.disconnect(); } catch (e) {} document.removeEventListener('pointerdown', handsOff, true); document.removeEventListener('keydown', handsOff, true); }; const guardBody = via => { if (stopped) return; if (userTookOver) { stopAll('visitor took over'); return; } if (Date.now() - started > 600000) { stopAll('10-min cap reached'); return; } if (!alive[via]) { alive[via] = true; crumb('heartbeat "' + via + '" alive'); } if (Date.now() - lastRun < 300) return; lastRun = Date.now(); const before = watched(); if (before !== lastState) crumb('external change detected [' + lastState + '] → [' + before + '], re-applying via ' + via); restore(); lastState = watched(); }; const iv = setInterval(() => guardBody('iv'), 400); const rafLoop = () => { if (stopped) return; guardBody('raf'); try { requestAnimationFrame(rafLoop); } catch (e) { setTimeout(rafLoop, 500); } }; try { requestAnimationFrame(rafLoop); } catch (e) {} const mo = new MutationObserver(() => guardBody('mo')); try { mo.observe(document.body, { childList: true, subtree: true }); } catch (e) {} if (window.Webflow && typeof window.Webflow.push === 'function') window.Webflow.push(restore); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); // Exposed for the test harness only — nothing on the page depends on it. window.__claddingCalc = { calculate, readState, CONFIG }; })(); /* ============================================================================= PAGE WORK THIS SCRIPT EXPECTS (see PAGE CONTRACT above) ----------------------------------------------------------------------------- 1. Six wall blocks data-calc-wall="1".."6" (2–6 with data-calc-hidden), each with three opening rows (2–3 with data-calc-hidden) — bare inputs in HTML Embeds, ids as listed, labels with for= AND data-calc-for. 2. Product blocks: the board Collection List twice (data-length 2.25 / 3.66), plus joist, corner trim, end trim, OB1 — calc-role / calc-range bound into [data-calc-meta] text. 3. Colour swatches with data-swatch data-name="" data-hex. 4. An empty div with data-cladding-plans where the elevations go. 5. Quote block: data-quote-email / -consent / -send / -status. Webhook URL in CONFIG.quote once the cladding Make.com scenario exists. GO-LIVE is a slug swap, as with decking. ============================================================================= */