/* ============================================================================= Alpha Decking — Composite Decking Calculator ----------------------------------------------------------------------------- Replaces the previous calculator (fullCalculator*.txt) and its two obfuscated embed blocks — all already removed from the Aug 2026 page. HOW IT FITS TOGETHER CONFIG every price, product ID, dimension and business rule. Changing a price or adding a range should only ever touch this. readState reads the controls into a plain object. All control lookups live in SEL next to it. calculate pure function: state in, quantities and prices out. No DOM. render* writes results to the page: product lines, totals, the setting-out plan SVG, the timber comparison. toggleInputs enables/disables options that depend on the deck (no-joins availability, 2.2m boards, conditional length inputs). PAGE CONTRACT (all markup already in place on the Aug 2026 page) [data-calc-scope] wraps anything that should trigger recalculation [data-calc-line] a product block; role/range read from bound [data-calc-meta="role"/"range"] text inside it [data-line-price] the price element within a product block [data-deck-plan] empty div the plan SVG is injected into (any number of them; each sized independently) [data-nojoins-warning] layout guidance text, written by the script [data-calc-for="id"] makes ANY element act as the label for input #id .input-wrapper_bullnose bullnose length wrapper, shown while ticked [data-quote-email] [data-quote-consent] [data-quote-send] [data-quote-status] "Email me this quote" — posts a snapshot to Make.com [data-swatch][data-range][data-hex] colour swatches (data-hex colours the plan; swatches filter by range) Product IDs and prices verified against live Webflow data, 20 Aug 2026. No jQuery. Money is handled in pence as integers throughout. v26 (31 Aug 2026): the quote payload now carries what the cart-rebuild link needs — each line gains productId (the CONFIG id it was priced from), and the payload gains rebuildUrl alongside shareUrl: a /your-cart?rc=1&i=... link that re-adds every visible line to the Webflow cart (page + runner already live). Make.com should put rebuildUrl behind the "Rebuild my cart" button in the quote email and keep shareUrl behind "Reopen my quote". Nothing else changed. v27 (2 Sep 2026): share-link restore made reset-proof. Webflow's commerce script can reset the variant to its placeholder as it does so — and on a slow // connection that reset can land SECONDS in, after any "looks stable // now" heuristic has given up (the "colour fills in for a second, then // flips back to Select" symptom). So: keep re-applying on a short // interval for a full 20 seconds, and stop early only when the visitor // actually touches a product panel — never fight a real hand. // pickOption() only fires a change when a value actually differs, so // once things have settled the repeats are silent no-ops. const q = new URLSearchParams(location.search); if (q.has('l') && q.has('w')) { const colourName = q.get('colour'); const restore = () => restoreProductSelections( colourName, lastResult && lastResult.plan ? lastResult.plan.boardLength : null); 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 => { // Only a real interaction with a form CONTROL in a product panel // ends the guard. A touch on the panel's background is how touch // screens scroll — that must never count as taking over. 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=' + (colourName || 'none')); restore(); let lastState = watched(); crumb('first apply → [' + lastState + ']'); // Three independent heartbeats drive the same guard body: a plain // interval, a requestAnimationFrame chain, and a MutationObserver on // the page. Browser extensions that inject into the page can nerf // any ONE of these (a dead setInterval is exactly what we saw in the // wild); it is very unlikely anything kills all three. The body is // idempotent and self-throttled, so overlapping heartbeats are fine. 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; // self-throttle across heartbeats 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) {} // Also run once Webflow itself reports ready, if it exposes the hook. if (window.Webflow && typeof window.Webflow.push === 'function') { window.Webflow.push(restore); } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); /* ============================================================================= REMAINING BEFORE GO-LIVE (page work — the code is complete) ----------------------------------------------------------------------------- 1. Colour swatches: add data-swatch="true", data-range, data-hex to the five Core swatches; build five Ultimate swatches. Until then the plan draws in the fallback Anthracite whatever colour is picked. 2. Delete the redundant "Add 3.66m Composite Joists?" checkbox block (the 2.2m one is already hidden) — superseded by the joistType radio. 3. Style the [data-nojoins-warning] text block. It shows on every deck as guidance, so it should not look like an error. 4. Group each control with its label and lay out; keep data-calc-for intact. Do NOT style opacity on labels — the script uses inline opacity to grey out disabled options. 5. Duplicate the lead-times block per range. 6. Mobile pass: plan height (80vh may be too tall), title block density. GO-LIVE is a SLUG SWAP, not a script swap. The current live page has none of this markup — pointing it at this file yields a page of zeros. Rename the live page's slug away, give this page the live slug, untick Draft, remove the SEO exclusion, publish. The old page then remains as instant rollback. STILL OPEN (business questions, not code) ----------------------------------------------------------------------------- a. Installation guide: are expansion gaps required at butt joints or the deck perimeter? Not currently modelled; could change counts on long runs. b. Starter clips: first board only (current setting) or first AND last as the product page's "project specific" wording allows? starterClipEdges. c. Accessories (bullnose/fascia/trim/OB1) are excluded from the timber comparison on BOTH sides — deliberate, but confirm. ============================================================================= */