/* ================================================================ WEBHOOK — Replace this URL with your Zapier / Make.com webhook. The form will POST a full JSON object with all user selections. ================================================================ */ const WEBHOOK_URL = 'YOUR_WEBHOOK_URL_HERE'; // ← вставьте сюда ваш URL const MANAGER_EMAIL = 'topdom8@gmail.com'; /* ================================================================ CALENDAR — Replace this URL with your booking/calendar link (Calendly, Google Calendar, etc.) to activate the success-screen button. ================================================================ */ const CALENDAR_URL = 'YOUR_CALENDAR_LINK_HERE'; /* ================================================================ GOOGLE SHEETS — fill in SHEETS_URL to enable live pricing. Key-value sheet: column A = rate key, column B = numeric value. See DANZAR_Calculator_TZ_FINAL.md for full key list. ================================================================ */ const SHEETS_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vTDnAmTlTMbb39G2k7okcpTrniHSfrOI0WLwEIkf0W7OKmnY110eR4HfM7O_7YyQMOKHGUWXGWTR4-0/pub?output=csv'; const SHEET_URLS = [ SHEETS_URL, 'https://corsproxy.io/?url=' + encodeURIComponent(SHEETS_URL), ]; const CFG = { PRICE_VARIANCE: 0.07, WASTE_FACTOR: 1.10, RAILING_THRESHOLD: 2.5, STEP_RISE_IN: 7, }; /* lf of decking per sqft of deck surface (waste already included) */ const LF_PER_SQFT = { pine: 2.34, cedar: 2.34, timbertech_prime: 2.40, timbertech_landmark: 2.32, }; const FALLBACK = { structure: { footing: 0, post_per_lf: 0, beam: 0, joist: 0, ledger: 0, hardware: 0, structure_labor: 0, }, material: { pine: { mat_cost_lf: 0, fascia_lf: 0, finish_labor: 0 }, cedar: { mat_cost_lf: 0, fascia_lf: 0, finish_labor: 0 }, timbertech_prime: { mat_cost_lf: 0, fascia_lf: 0, finish_labor: 0 }, timbertech_landmark: { mat_cost_lf: 0, fascia_lf: 0, finish_labor: 0 }, }, fastener: { pine: { type: 'screws', cost: 0 }, cedar: { type: 'screws', cost: 0 }, timbertech_prime: { type: 'clips', cost: 0 }, timbertech_landmark: { type: 'clips', cost: 0 }, }, railing: { wood: { rate: 0 }, aluminum: { rate: 0 }, cable: { rate: 0 }, }, stairs: { pine: { mat_per_step: 0, labor_per_step: 0 }, cedar: { mat_per_step: 0, labor_per_step: 0 }, timbertech_prime: { mat_per_step: 0, labor_per_step: 0 }, timbertech_landmark: { mat_per_step: 0, labor_per_step: 0 }, }, services: { demolition_small: 0, demolition_medium: 0, demolition_large: 0, permit_small: 0, permit_large: 0, slope_slight: 0, slope_significant: 0, ledger_old: 0, disposal: 0, }, height_factors: { low: 1.0, medium: 1.0, high: 1.0 }, margin: { target_margin: 0, price_variance: 0, material_buffer: 0 }, }; /* ── STATE ── */ const STORE_KEY = 'danzar_deck_v2'; const DEFAULT = { step:1, shape:null, material:null, selectedColor:null, colorName:null, colorStepActive:false, width:'', depth:'', height:0, attached:true, railType:'wood', railingStyle:null, slope:null, demolition:null, photos:[], notes:'', name:'', phone:'', email:'', zip:'', livePrices:false, submitted:false, lighting:false, permitHandling:false, costBreakdown: null, }; let S = loadState(); let P = JSON.parse(JSON.stringify(FALLBACK)); let PRICES_STATE = 'loading'; // 'loading' | 'ready' | 'failed' function loadState() { try { const r = localStorage.getItem(STORE_KEY); if(r){ const p=JSON.parse(r); p.photos=[]; return {...DEFAULT,...p}; } } catch(_){} return {...DEFAULT}; } function save() { try { localStorage.setItem(STORE_KEY, JSON.stringify({...S, photos:[]})); } catch(_){} } /* ── STAIRS ── */ function calcStairs(h) { return (!h||h<=0) ? 0 : Math.ceil((h*12)/CFG.STEP_RISE_IN); } /* ── LIVE PRICING ── */ async function fetchPrices() { const labels = ['direct', 'proxy']; for (let i = 0; i < SHEET_URLS.length; i++) { try { const res = await fetch(SHEET_URLS[i], {cache:'no-cache'}); if (!res.ok) throw new Error('HTTP ' + res.status); const mapped = parseCSV(await res.text()); if (!mapped) throw new Error('parse null'); P = mapped; S.livePrices = true; PRICES_STATE = 'ready'; console.log('[NDS] ✅ prices via ' + labels[i], P); setDot(true); render(); return; } catch(e) { console.warn('[NDS] ' + labels[i] + ' failed:', e.message); } } PRICES_STATE = 'failed'; setDot(false); render(); } function parseCSV(text) { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); if (lines.length < 2) return null; const out = JSON.parse(JSON.stringify(FALLBACK)); let hits = 0; /* Google Sheet format: column A = rate key, column B = numeric value. Header row is skipped. Keys are case-insensitive. */ const MAP = { // Structure 'footing': v => out.structure.footing = v, 'post': v => out.structure.post_per_lf = v, 'beam': v => out.structure.beam = v, 'joist': v => out.structure.joist = v, 'ledger': v => out.structure.ledger = v, 'hardware': v => out.structure.hardware = v, 'structure_labor': v => out.structure.structure_labor = v, // Material — board cost per lf 'pine_mat': v => out.material.pine.mat_cost_lf = v, 'pine_fascia': v => out.material.pine.fascia_lf = v, 'pine_labor': v => out.material.pine.finish_labor = v, 'cedar_mat': v => out.material.cedar.mat_cost_lf = v, 'cedar_fascia': v => out.material.cedar.fascia_lf = v, 'cedar_labor': v => out.material.cedar.finish_labor = v, 'prime_mat': v => out.material.timbertech_prime.mat_cost_lf = v, 'prime_fascia': v => out.material.timbertech_prime.fascia_lf = v, 'prime_labor': v => out.material.timbertech_prime.finish_labor = v, 'landmark_mat': v => out.material.timbertech_landmark.mat_cost_lf = v, 'landmark_fascia': v => out.material.timbertech_landmark.fascia_lf = v, 'landmark_labor': v => out.material.timbertech_landmark.finish_labor = v, // Fasteners 'screws': v => { out.fastener.pine.cost = v; out.fastener.cedar.cost = v; }, 'clips_prime': v => out.fastener.timbertech_prime.cost = v, 'clips_landmark': v => out.fastener.timbertech_landmark.cost = v, // Railing — per lf 'wood_rail_mat': v => out.railing.wood.rate = v, 'wood_rail_labor': v => { /* ignored — all-in rate via wood_rail_rate */ }, 'wood_rail_rate': v => out.railing.wood.rate = v, 'alum_rail_mat': v => out.railing.aluminum.rate = v, 'alum_rail_labor': v => { /* ignored */ }, 'alum_rail_rate': v => out.railing.aluminum.rate = v, 'cable_rail_mat': v => out.railing.cable.rate = v, 'cable_rail_labor': v => { /* ignored */ }, 'cable_rail_rate': v => out.railing.cable.rate = v, // Stairs — per step 'pine_stair_mat': v => out.stairs.pine.mat_per_step = v, 'pine_stair_labor': v => out.stairs.pine.labor_per_step = v, 'cedar_stair_mat': v => out.stairs.cedar.mat_per_step = v, 'cedar_stair_labor': v => out.stairs.cedar.labor_per_step = v, 'prime_stair_mat': v => out.stairs.timbertech_prime.mat_per_step = v, 'prime_stair_labor': v => out.stairs.timbertech_prime.labor_per_step = v, 'landmark_stair_mat': v => out.stairs.timbertech_landmark.mat_per_step = v, 'landmark_stair_labor': v => out.stairs.timbertech_landmark.labor_per_step = v, // Services 'demo_small': v => out.services.demolition_small = v, 'demo_medium': v => out.services.demolition_medium = v, 'demo_large': v => out.services.demolition_large = v, 'permit_small': v => out.services.permit_small = v, 'permit_large': v => out.services.permit_large = v, 'slope_slight': v => out.services.slope_slight = v, 'slope_significant': v => out.services.slope_significant = v, 'ledger_old': v => out.services.ledger_old = v, 'disposal': v => out.services.disposal = v, // Height factors 'factor_medium': v => out.height_factors.medium = v, 'factor_high': v => out.height_factors.high = v, // Margin 'target_margin': v => out.margin.target_margin = v, 'price_variance': v => out.margin.price_variance = v, 'material_buffer': v => out.margin.material_buffer = v, }; for (let i = 1; i < lines.length; i++) { const cols = splitRow(lines[i]); const key = (cols[0] || '').trim().toLowerCase(); const val = parseFloat((cols[1] || '').replace(/[$,\s]/g, '')); if (!key || !isFinite(val) || val <= 0) continue; if (MAP[key]) { MAP[key](val); hits++; } } return hits >= 2 ? out : null; } function splitRow(line) { const cells=[]; let cur='', inQ=false; for (const ch of line) { if(ch==='"') inQ=!inQ; else if(ch===','&&!inQ){cells.push(cur);cur='';} else cur+=ch; } cells.push(cur); return cells; } function setDot(live) { const w=document.getElementById('pricing-dot-wrap'); if(!w)return; const d=w.querySelector('.pricing-dot'), l=w.querySelector('.pricing-lbl'); if(d) d.style.background=live?'var(--success)':'var(--warning)'; if(l) l.textContent=live?'Live pricing synced from Google Sheets':'Using current WA State market estimates'; } /* ── CALCULATION ── */ function calcEstimate() { const w = parseFloat(S.width) || 0; const d = parseFloat(S.depth) || 0; const mat = S.material || 'pine'; const shapeFactor = {rectangle:1.0, l_shape:1.20, multi_level:1.35, custom:1.50}[S.shape||'rectangle'] || 1.0; const sqFt = w * d; const sqFtEff = sqFt * shapeFactor; const perimFt = S.attached ? w + d * 2 : (w + d) * 2; const numSteps = calcStairs(parseFloat(S.height) || 0); // Rate sources: live P (from CSV) if available, else FALLBACK const R = { str: (k) => (P.structure?.[k] ?? FALLBACK.structure[k]), mat: (k) => (P.material?.[mat]?.[k] ?? FALLBACK.material[mat]?.[k] ?? FALLBACK.material.pine[k]), fast: (k) => (P.fastener?.[mat]?.[k] ?? FALLBACK.fastener[mat]?.[k] ?? 0), rail: (k) => (P.railing?.[S.railType]?.[k] ?? FALLBACK.railing[S.railType]?.[k] ?? 0), stair:(k) => (P.stairs?.[mat]?.[k] ?? FALLBACK.stairs[mat]?.[k] ?? FALLBACK.stairs.pine[k]), svc: (k) => (P.services?.[k] ?? FALLBACK.services[k]), hf: (k) => (P.height_factors?.[k] ?? FALLBACK.height_factors[k]), mg: (k) => (P.margin?.[k] ?? FALLBACK.margin[k]), }; // ── BLOCK 1: Structure ─────────────────────────────────────── const footings = R.str('footing'); // flat rate per project const postCost = perimFt * R.str('post_per_lf'); const beamCost = perimFt * R.str('beam'); const joistCost = sqFtEff * R.str('joist'); const ledgerCost = w * R.str('ledger'); const hardwareCost = sqFtEff * R.str('hardware'); const structLabor = sqFtEff * R.str('structure_labor'); const structureTotal = footings + postCost + beamCost + joistCost + ledgerCost + hardwareCost + structLabor; // ── BLOCK 2: Finish (decking boards + fascia + fasteners + finish labor) ── const lfPerSqFt = (typeof LF_PER_SQFT !== 'undefined' ? LF_PER_SQFT[mat] : null) || 2.34; const totalLf = sqFtEff * lfPerSqFt; // includes waste factor baked in LF_PER_SQFT const boardCost = totalLf * R.mat('mat_cost_lf'); const fasciaCost = perimFt * R.mat('fascia_lf'); const fastCost = totalLf * R.fast('cost'); const finishLabor = sqFtEff * R.mat('finish_labor'); const finishTotal = boardCost + fasciaCost + fastCost + finishLabor; // ── BLOCK 3: Stairs & Railings ─────────────────────────────── const stairMatCost = numSteps * R.stair('mat_per_step'); const stairLabor = numSteps * R.stair('labor_per_step'); const railCostTotal = S.railType === 'none' ? 0 : perimFt * R.rail('rate'); const railMatCost = railCostTotal; // kept for breakdown compatibility const railLabor = 0; const stairsRailTotal = stairMatCost + stairLabor + railCostTotal; // ── Additional services ────────────────────────────────────── const heightKey = S.height > 8 ? 'high' : S.height > 4 ? 'medium' : 'low'; const hFactor = R.hf(heightKey); const demoCost = S.demolition === 'yes_small' ? R.svc('demolition_small') : S.demolition === 'yes_medium' ? R.svc('demolition_medium') : S.demolition === 'yes_large' ? R.svc('demolition_large') : S.demolition === 'replace' ? R.svc('demolition_medium') // legacy : 0; const slopeCost = S.slope === 'significant' ? sqFtEff * R.svc('slope_significant') : S.slope === 'slight' ? sqFtEff * R.svc('slope_slight') : S.slope === 'steep' ? sqFtEff * R.svc('slope_significant') // legacy : S.slope === 'moderate' ? sqFtEff * R.svc('slope_slight') // legacy : 0; const permitCost = S.permitHandling ? (sqFtEff > 300 ? R.svc('permit_large') : R.svc('permit_small')) : 0; // ── Final pricing with height factor and margin ────────────── const subtotal = (structureTotal + finishTotal + stairsRailTotal) * hFactor + demoCost + slopeCost + permitCost; const margin = R.mg('target_margin'); const variance = R.mg('price_variance'); const base = subtotal / (1 - margin); const low = base * (1 - variance); const high = base * (1 + variance); // ── Store full breakdown for manager email ─────────────────── S.costBreakdown = { sqFt, sqFtEff, perimFt, numSteps, totalLf, structure: { footings, postCost, beamCost, joistCost, ledgerCost, hardwareCost, structLabor, total: structureTotal }, finish: { boardCost, fasciaCost, fastCost, finishLabor, total: finishTotal }, stairsRail:{ stairMatCost, stairLabor, railMatCost, railLabor, total: stairsRailTotal }, services: { demoCost, slopeCost, permitCost }, subtotal, margin, base, low, high, heightFactor: hFactor, mat, railType: S.railType, }; // Legacy keys kept for renderStep7 compatibility const matCost = finishTotal; const laborCost = structLabor + finishLabor; const railCost = railMatCost + railLabor; const stairCost = stairMatCost + stairLabor; return { sqFt, sqFtEff, perimFt, numSteps, matCost, laborCost, railCost, stairCost, demoCost, permitCost, slopeCost, base, low, high }; } const $$ = n => '$'+Math.round(n).toLocaleString('en-US'); function refreshBadge() { const badge = document.getElementById('est-badge'); const val = document.getElementById('est-badge-val'); const mbar = document.getElementById('est-badge-mobile'); const mval = document.getElementById('est-badge-mobile-val'); const hasData = S.width && S.depth && S.material; if (!hasData) { if (badge) badge.classList.remove('visible'); if (mbar) mbar.style.display = 'none'; return; } const E = calcEstimate(); if (E.base <= 0) { if (badge) badge.classList.remove('visible'); if (mbar) mbar.style.display = 'none'; return; } const text = `${$$(E.low)} – ${$$(E.high)}`; if (badge && val) { badge.classList.add('visible'); val.textContent = text; } if (mbar && mval) { mbar.style.display = 'flex'; mval.textContent = text; } } /* ── NAVIGATION ── */ function updateProgress() { for(let i=1;i<=7;i++){ const ind=document.getElementById('si'+i), bub=document.getElementById('sb'+i); if(!ind)continue; ind.className='step-ind'+(i
Loading pricing…
Fetching current rates from Google Sheets
`; return; } if (PRICES_STATE === 'failed') { if(pw) pw.classList.add('hidden'); app.innerHTML=`
⚠️
Our pricing system is currently updating.
Please call us: (425) 295-2131
or email hello@danzardecks.com
`; return; } if(pw) pw.classList.remove('hidden'); if(S.submitted){pw.classList.add('hidden'); app.innerHTML=renderSuccess(); renderDeckPreview(); return;} if(S.colorStepActive){ app.innerHTML=renderColorStep(); renderDeckPreview(); return; } const fns=[null,renderStep1,renderStep2,renderStep3,renderStep4,renderStep5,renderStep6,renderStep7]; app.innerHTML=(fns[S.step]||renderStep1)(); setDot(S.livePrices); syncSlider(); renderDeckPreview(); } function retryPrices() { PRICES_STATE = 'loading'; render(); fetchPrices(); } function syncSlider() { const sl=document.getElementById('height-slider'); if(!sl)return; const pct=(parseFloat(sl.value)/20)*100; sl.style.background=`linear-gradient(to right,var(--orange) ${pct}%,var(--gray-200) ${pct}%)`; } /* ── STEP 1 — DECK SHAPE ── */ function renderStep1() { const SHAPES = [ {id:'rectangle', icon:'▭', name:'Rectangle', tag:'Most Popular', tagClass:'value', desc:'Standard rectangular deck — the most common and cost-effective build.', factor:'1.0×'}, {id:'l_shape', icon:'⌐', name:'L-Shape', tag:'Complex Build', tagClass:'premium', desc:'Two connected sections forming an L. Great for wrapping around house corners.', factor:'1.20×'}, {id:'multi_level', icon:'⏫', name:'Multi-Level', tag:'Complex Build', tagClass:'premium', desc:'Two or more deck platforms at different heights. Requires additional framing.', factor:'1.35×'}, {id:'custom', icon:'✏️', name:'Custom Design', tag:'Complex Build', tagClass:'premium', desc:'Curved, angled, or fully custom layout. Final design scoped during consultation.', factor:'1.50×'}, ]; const showAlert = S.shape && S.shape !== 'rectangle'; return `
Step 1 of 7
Choose Your Deck Shape
This helps us estimate complexity and material requirements
${SHAPES.map(o=>`
${o.icon}
${o.name}
${o.tag}
${o.desc}
Complexity factor: ${o.factor}
`).join('')}
${showAlert?`
ℹ️
Final dimensions verified during consultation. Calculator shows estimated range.
`:''}
`; } function pickShape(id) { S.shape=id; save(); document.querySelectorAll('.mat-card').forEach(c=>c.classList.toggle('sel',c.getAttribute('onclick').includes(`'${id}'`))); const b=document.getElementById('s1-btn'); if(b)b.removeAttribute('disabled'); const alertWrap=document.getElementById('shape-alert'); if(alertWrap) alertWrap.innerHTML=id!=='rectangle'?`
ℹ️
Final dimensions verified during consultation. Calculator shows estimated range.
`:''; refreshBadge(); renderDeckPreview(); } /* ── GOOGLE DRIVE IMAGE PROXY ── */ function driveFileId(url) { if (!url) return null; // lh3.googleusercontent.com/d/FILE_ID or lh3.googleusercontent.com/d/FILE_ID=w800 const m = url.match(/\/d\/([a-zA-Z0-9_-]{10,})/); return m ? m[1] : null; } function proxyImg(url, alt, cssClass, style) { const id = driveFileId(url); if (!id) { const a = esc(url||''), b = esc(alt||''); return `${b}`; } const fmt1 = `https://lh3.googleusercontent.com/d/${id}=w800`; const fmt2 = `https://drive.google.com/thumbnail?id=${id}&sz=w800`; const fmt3 = `https://www.googleapis.com/drive/v3/files/${id}?alt=media&key=AIzaSyD-placeholder`; const onErr = `(function(img){` + `var t=img.dataset.t||'1';` + `if(t==='1'){img.dataset.t='2';img.src='${fmt2}'}` + `else if(t==='2'){img.dataset.t='3';img.src='${fmt3}'}` + `else{img.style.display='none'}` + `})(this)`; return `${alt||''}`; } /* ── STEP 2 — MATERIAL ── */ function renderStep2() { const MATS = ['pine','cedar','timbertech_prime','timbertech_landmark']; const cards = MATS.map(id => { const m = COLOR_DATA[id]; const sel = S.material === id; const photo = m.mainPhoto || ''; const hex = m.hex || (m.colors && m.colors[0] ? m.colors[0].hex : '#C4A265'); return `
🪵
${photo ? proxyImg(photo, m.label) : ''}
${m.tag}
${m.label}
${m.descriptionShort || m.description || ''}
${m.warranty ? `
✦ TimberTech® — ${m.warranty}
` : ''}
`; }).join(''); return `
Step 2 of 7
Choose Your Deck Material
All options are suited for the Pacific Northwest climate
${cards}
Loading live pricing…
`; } function pickMat(id) { if (S.material !== id) { S.selectedColor = null; S.colorName = null; } S.material = id; save(); document.querySelectorAll('.mat-photo-card').forEach(c => { c.classList.toggle('sel', c.getAttribute('onclick').includes(`'${id}'`)); }); const b = document.getElementById('s2-btn'); if(b) b.removeAttribute('disabled'); refreshBadge(); renderDeckPreview(); } function step2Next() { if (!S.material) return; const needsColor = S.material === 'timbertech_prime' || S.material === 'timbertech_landmark'; if (needsColor) { S.colorStepActive = true; save(); render(); updateProgress(); window.scrollTo({top:0, behavior:'smooth'}); } else { S.selectedColor = null; S.colorName = null; S.colorStepActive = false; gotoStep(3); } } function backFromColor() { S.colorStepActive = false; save(); render(); updateProgress(); window.scrollTo({top:0, behavior:'smooth'}); } function leaveColorStep() { S.colorStepActive = false; gotoStep(3); } function renderColorStep() { const matData = COLOR_DATA[S.material]; const colors = matData ? matData.colors || [] : []; const cols = colors.length <= 3 ? 'color-grid-3' : 'color-grid-4'; const cards = colors.map(c => { const sel = S.selectedColor === c.id; const escapedName = c.name.replace(/'/g, '''); return `
${c.mainPhoto ? proxyImg(c.mainPhoto, c.name) : ''} ${sel ? '
' : ''}
${c.name}
${c.descriptionShort}
`; }).join(''); return `
Step 2 of 7 — Color
Choose Your Color
${matData ? matData.label : ''}
${cards}
`; } function pickColor(id, name) { S.selectedColor = id; S.colorName = name; save(); document.querySelectorAll('.color-card').forEach(c => { const sel = c.getAttribute('onclick').includes(`'${id}'`); c.classList.toggle('sel', sel); const photoDiv = c.querySelector('.color-photo'); const existing = photoDiv ? photoDiv.querySelector('.color-check') : null; if (sel && photoDiv && !existing) { photoDiv.insertAdjacentHTML('beforeend', '
'); } else if (!sel && existing) { existing.remove(); } }); const btn = document.getElementById('color-btn'); if (btn) btn.removeAttribute('disabled'); renderDeckPreview(); } /* ── STEP 3 — DIMENSIONS ── */ let _step3NoDims = false; function renderStep3() { const dims=[]; for(let i=8;i<=60;i+=2)dims.push(i); const hft=parseFloat(S.height)||0, steps=calcStairs(hft); const sqFt=S.width&&S.depth?parseFloat(S.width)*parseFloat(S.depth):0; const perim=sqFt>0?(S.attached ? parseFloat(S.width)+parseFloat(S.depth)*2 : (parseFloat(S.width)+parseFloat(S.depth))*2):0; const perimLabel=S.attached?'3 sides (attached)':'4 sides (freestanding)'; const needRail=hft>CFG.RAILING_THRESHOLD; const noDimsForm = `
📐
No problem! Leave your contact info and our team will visit, measure your yard, and send you an accurate estimate.

We'll contact you within 24 hours to schedule

← I know my dimensions
`; const dimsForm = `

🏠 Attached to House

Ledger board along one side — perimeter = 3 sides

🌳 Freestanding

No house attachment — perimeter = 4 sides

${sqFt>0?`
📐 Area: ${sqFt.toLocaleString()} sq ft  ·  Perimeter (${perimLabel}): ${perim.toFixed(0)} ft  ·  With 10% waste: ${Math.ceil(sqFt*1.1).toLocaleString()} sq ft
`:''}
${hft%1===0?hft:hft.toFixed(1)} ft
${Math.round(hft*12)}" above grade
0 ft20 ft
${needRail?`
⚠️
WA State Code (IRC R312): Decks over 30" require guardrails. Required in Step 4.
`:hft>0?`
ℹ️
Under 30" — guardrails optional but recommended.
`:''}
${steps>0?`
🪜 Auto-calculated: ${steps} step${steps!==1?'s':''} = ceil(${Math.round(hft*12)}" ÷ 7") — shown in Step 4
`:''}
`; return `
Step 3 of 7
Deck Dimensions
Enter your planned deck size and height above grade
${_step3NoDims ? noDimsForm : dimsForm}
`; } function showNoDimsForm() { _step3NoDims=true; render(); syncSlider(); } function showDimsForm() { _step3NoDims=false; render(); syncSlider(); } function pickAttached(val) { S.attached = val; save(); render(); syncSlider(); } async function submitNoDims() { const nameEl=document.getElementById('nd-name'), phoneEl=document.getElementById('nd-phone'); let valid=true; const setErr=(id,msg)=>{const e=document.getElementById(id);if(e)e.textContent=msg;}; const name=(nameEl?nameEl.value:S.name)||''; const phone=(phoneEl?phoneEl.value:S.phone)||''; if(name.trim().length<2){nameEl?.classList.add('err');setErr('nd-e-name','Please enter your name.');valid=false;}else{nameEl?.classList.remove('err');setErr('nd-e-name','');} if(phone.replace(/\D/g,'').length<10){phoneEl?.classList.add('err');setErr('nd-e-phone','Please enter a valid phone number.');valid=false;}else{phoneEl?.classList.remove('err');setErr('nd-e-phone','');} if(!valid)return; S.name=name.trim(); S.phone=phone.trim(); save(); const btn=document.querySelector('#no-dims-form .btn-primary'); if(btn){btn.disabled=true;btn.innerHTML='⏳ Sending…';} const payload={timestamp:new Date().toISOString(),source:'DANZAR Decks & Outdoor Living Web Estimator',path:'no_dimensions',customer:{name:S.name,phone:S.phone,email:S.email}}; if(WEBHOOK_URL&&WEBHOOK_URL!=='YOUR_WEBHOOK_URL_HERE'){ try{await fetch(WEBHOOK_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});}catch(e){console.error('[NDS] Webhook error:',e.message);} } console.log('%c[NDS] NO-DIMS LEAD','color:#D97724;font-weight:bold'); console.log(JSON.stringify(payload,null,2)); const body=document.querySelector('#no-dims-form'); if(body) body.innerHTML=`
Thank you! Our team will reach out within 24 hours to schedule your free yard visit and measurement.
`; } function pickDim(key,val){ S[key]=val; save(); const b=document.getElementById('s3-btn'); if(b){if(S.width&&S.depth)b.removeAttribute('disabled');else b.setAttribute('disabled','');} refreshBadge(); renderDeckPreview(); if(S.width&&S.depth){const w=parseFloat(S.width),d=parseFloat(S.depth),sqFt=w*d,perim=S.attached?w+d*2:(w+d)*2,plbl=S.attached?'3 sides (attached)':'4 sides (freestanding)'; const p=document.getElementById('area-pill'); if(p)p.innerHTML=`📐 Area: ${sqFt.toLocaleString()} sq ft  ·  Perimeter (${plbl}): ${perim.toFixed(0)} ft  ·  With 10% waste: ${Math.ceil(sqFt*1.1).toLocaleString()} sq ft`;} } function onHeight(val){ S.height=parseFloat(val); save(); const hft=S.height, hIn=Math.round(hft*12), steps=calcStairs(hft); const d=document.getElementById('h-disp'), i=document.getElementById('h-in'); if(d)d.textContent=hft%1===0?hft:hft.toFixed(1); if(i)i.textContent=hIn+'" above grade'; syncSlider(); const a=document.getElementById('height-alert'); if(a)a.innerHTML=hft>CFG.RAILING_THRESHOLD?`
⚠️
WA State Code (IRC R312): Decks over 30" require guardrails.
`:hft>0?`
ℹ️
Under 30" — guardrails optional but recommended.
`:''; const sp=document.getElementById('stair-preview'); if(sp)sp.innerHTML=steps>0?`
🪜 Auto-calculated: ${steps} step${steps!==1?'s':''} = ceil(${hIn}" ÷ 7") — shown in Step 4
`:''; if(hft>CFG.RAILING_THRESHOLD&&S.railType==='none') S.railType='wood'; refreshBadge(); renderDeckPreview(); } function step3Next(){ if(!S.width||!S.depth)return; if(S.height>CFG.RAILING_THRESHOLD&&S.railType==='none') S.railType='wood'; save(); gotoStep(4); } /* ── STEP 4 — ACCESS & SAFETY ── */ function renderStep4(){ const hft=parseFloat(S.height)||0, steps=calcStairs(hft), reqRail=hft>CFG.RAILING_THRESHOLD; const activeR=reqRail&&S.railType==='none'?'wood':S.railType; const RAIL=[ {id:'none', icon:'✗', name:'No Railing', desc:'Open deck — no guardrails', locked:reqRail}, {id:'wood', icon:'🪵', name:'Wood Railing', desc:`~${$$(P.railing.wood.rate)}/ln ft · Classic` }, {id:'aluminum', icon:'⚙️', name:'Aluminum Railing', desc:`~${$$(P.railing.aluminum.rate)}/ln ft · Low maintenance` }, {id:'cable', icon:'🔗', name:'Cable Railing', desc:`~${$$(P.railing.cable.rate)}/ln ft · Premium views` }, ]; return `
Step 4 of 7
Access & Safety
Auto-calculated stairs and railing configuration
${steps>0?`
${steps}
Steps auto-calculated
ceil(${Math.round(hft*12)}" ÷ 7" rise) = ${steps} steps
Est. stair cost: ${$$((P.stairs[S.material].mat_per_step+P.stairs[S.material].labor_per_step)*steps)}
`:`
ℹ️ Ground-level deck — no stairs required
`}
${reqRail?`
⚠️
WA Code required: Your ${hft}-ft deck needs guardrails. "No Railing" is locked.
`:''}
${RAIL.map(o=>`

${o.icon} ${o.name}

${o.desc}

`).join('')}
${activeR==='wood' ? renderWoodSubOptions() : ''}
`; } function pickRail(id){ const wasWood = S.railType === 'wood'; S.railType = id; if (id !== 'wood') S.railingStyle = null; save(); // Re-render to show/hide wood sub-options if (id === 'wood' || wasWood) { render(); return; } document.querySelectorAll('.rail-opt').forEach(el=>el.classList.toggle('sel',el.getAttribute('onclick')?.includes(`'${id}'`))); refreshBadge(); renderDeckPreview(); } function renderWoodSubOptions() { const woodData = (typeof RAILING_DATA !== 'undefined') ? RAILING_DATA.find(r => r.id === 'wood') : null; const subs = woodData ? woodData.subOptions || [] : []; const currentPhoto = (() => { if (!S.railingStyle || !subs.length) return subs[0] ? subs[0].photo : ''; const found = subs.find(s => s.id === S.railingStyle); return found ? found.photo : (subs[0] ? subs[0].photo : ''); })(); const btnHtml = subs.map(s => ` `).join(''); return `
Choose Baluster Style
${btnHtml}
${currentPhoto ? `
${proxyImg(currentPhoto, 'Wood railing', '', 'width:100%;height:100%;object-fit:cover;display:block')}
` : ''}
`; } function pickRailingStyle(id) { S.railingStyle = id; save(); document.querySelectorAll('.rail-sub-btn').forEach(btn => btn.classList.toggle('sel', btn.getAttribute('onclick').includes(`'${id}'`)) ); const woodData = (typeof RAILING_DATA !== 'undefined') ? RAILING_DATA.find(r => r.id === 'wood') : null; if (woodData) { const sub = woodData.subOptions.find(s => s.id === id); if (sub && sub.photo) { const wrap = document.querySelector('.rail-sub-photo'); if (wrap) { const id = driveFileId(sub.photo); const newImg = document.createElement('img'); newImg.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block'; newImg.dataset.t = '1'; newImg.alt = sub.label; if (id) { const fmt2 = `https://drive.google.com/thumbnail?id=${id}&sz=w800`; const fmt3 = `https://www.googleapis.com/drive/v3/files/${id}?alt=media&key=AIzaSyD-placeholder`; newImg.src = `https://lh3.googleusercontent.com/d/${id}=w800`; newImg.onerror = function() { const t = this.dataset.t || '1'; if (t === '1') { this.dataset.t = '2'; this.src = fmt2; } else if (t === '2') { this.dataset.t = '3'; this.src = fmt3; } else { this.style.display = 'none'; } }; } else { newImg.src = sub.photo; newImg.onerror = function() { this.style.display = 'none'; }; } wrap.innerHTML = ''; wrap.appendChild(newImg); } } } } /* ── STEP 5 — SITE CONDITIONS ── */ function renderStep5(){ const SLOPE=[ {id:'flat', icon:'⬛',name:'Flat / Level', desc:'Standard foundation work'}, {id:'moderate', icon:'📐',name:'Moderate Slope', desc:`+${$$(P.services.slope_slight)}/sq ft`}, {id:'steep', icon:'⛰️',name:'Steep / Hillside',desc:`+${$$(P.services.slope_significant)}/sq ft`}, ]; const DEMO=[ {id:'new', icon:'✨',name:'New Build', desc:'No existing structure to remove'}, {id:'replace', icon:'🏗️',name:'Replace Existing Deck', desc:`+${$$(P.services.demolition_medium||850)} est.`}, {id:'extend', icon:'🔨',name:'Extend Existing Deck', desc:'+$425 est.'}, ]; const pc=S.photos.length, isFull=pc>=3; const photoGrid=`
${S.photos.map((p,i)=>`
Photo ${i+1}
`).join('')} ${Array.from({length:3-pc}).map((_,i)=>`
📷Slot ${pc+i+1}
`).join('')}
`; return `
Step 5 of 7
Site Conditions
Help us price your project accurately
${SLOPE.map(o=>`
${o.icon}

${o.name}

${o.desc}

`).join('')}
${DEMO.map(o=>`
${o.icon}

${o.name}

${o.desc}

`).join('')}
${!isFull?``:'' }
${isFull?'✅':pc>0?'📸':'🖼️'}
${isFull?'All 3 photos uploaded':pc>0?`${pc} of 3 photos added`:'Drag & drop site photos here'}
${isFull?'Remove a photo below to add another':`or click to browse · ${3-pc} slot${3-pc!==1?'s':''} remaining`}
${pc>0?photoGrid:''}
`; } function pickCond(key,val){ S[key]=val; save(); document.querySelectorAll('.cond-opt').forEach(el=>{if(!el.getAttribute('onclick')?.includes(`'${key}'`))return; el.classList.toggle('sel',el.getAttribute('onclick').includes(`'${key}','${val}'`));}); const b=document.getElementById('s5-btn'); if(b){if(S.slope&&S.demolition)b.removeAttribute('disabled');else b.setAttribute('disabled','');} refreshBadge(); renderDeckPreview(); } function step5Next(){ if(!S.slope||!S.demolition)return; gotoStep(6); } /* Photo handlers */ function onDragOver(e){ e.preventDefault(); document.getElementById('drop-zone')?.classList.add('over'); } function onDragLeave(){ document.getElementById('drop-zone')?.classList.remove('over'); } function onDrop(e){ e.preventDefault(); onDragLeave(); ingestPhotos(Array.from(e.dataTransfer.files).filter(f=>f.type.startsWith('image/'))); } function onFileInput(e){ ingestPhotos(Array.from(e.target.files)); } function ingestPhotos(files){ const slots=3-S.photos.length; if(slots<=0)return; let done=0; const batch=files.slice(0,slots); batch.forEach(file=>{const r=new FileReader(); r.onload=ev=>{S.photos.push({name:file.name,dataUrl:ev.target.result}); if(++done===batch.length)render();}; r.readAsDataURL(file);}); } function removePhoto(i){ S.photos.splice(i,1); render(); } /* ── STEP 6 — OPTIONS ── */ function renderStep6(){ return `
Step 6 of 7
Options
Customize your project with additional services
Lighting

Lighting options are customized individually — our team will discuss packages during consultation.

Permit

Permit cost varies by city and project scope — typically $600–$1,800, confirmed after site review.

`; } /* ── STEP 7 — ESTIMATE ── */ function renderStep7(){ const E=calcEstimate(); const matLabel={cedar:'Western Red Cedar',pine:'Pressure-Treated Pine',timbertech_prime:'Composite — Standard',timbertech_landmark:'Composite — Premium'}[S.material]||S.material; const rail={none:'None',wood:'Wood Railing',aluminum:'Aluminum Railing',cable:'Cable Railing'}[S.railType]||S.railType; const demoLabel={new:'New Build',replace:'Replace Existing Deck',extend:'Extend Existing Deck'}[S.demolition]||S.demolition; return `
Step 7 of 7
Your Deck Estimate
Based on current Washington State pricing · valid for 30 days
Estimated Project Investment
${$$(E.low)} – ${$$(E.high)}
${E.sqFt.toLocaleString()} sq ft · ${matLabel} · ±7% range · ${S.livePrices?'🟢 Live pricing':'📋 Market estimates'}
Materials — ${E.sqFt.toLocaleString()} sq ft + 10% waste${$$(E.matCost)}
Labor & Installation${$$(E.laborCost)}
${E.railCost>0?`
${rail} — ${E.perimFt.toFixed(0)} ln ft (${S.attached?'3 sides — attached':'4 sides — freestanding'})${$$(E.railCost)}
`:''} ${E.stairCost>0?`
Stairs — ${E.numSteps} steps${$$(E.stairCost)}
`:''} ${E.demoCost>0?`
Existing Structure — ${demoLabel}${$$(E.demoCost)}
`:''} ${E.slopeCost>0?`
Site Preparation — ${S.slope} slope${$$(E.slopeCost)}
`:''}
Permits & Inspections (WA State)${$$(E.permitCost)}
${S.lighting?`
Lighting — discussed at consultation
`:''} ${S.permitHandling?`
Permit Filing — TBDTBD
`:''}
Base Estimate${$$(E.base)}
Lock In Your Free Quote
A specialist contacts you within 24 hours to schedule a free on-site consultation.

🔒 Your information is private and never shared with third parties.

`; } /* ── PDF DOWNLOAD GATE ── */ /* Collects customer data from every possible source, in priority order: 1) main lead form (#l-name/#l-phone/#l-email/#l-city) 2) PDF gate modal (#pg-name/#pg-phone), only filling gaps left by (1) 3) app state S (covers the success screen and any persisted/no-dims values) Uses truthy checks rather than ?? so a field reset to value="" on re-render doesn't shadow a real value already stored in S. */ function collectPdfCustomerData(){ function domVal(id){ const el=document.getElementById(id); return el ? (el.value||'').trim() : ''; } let name = domVal('l-name'); let phone = domVal('l-phone'); let email = domVal('l-email'); let zip = domVal('l-zip'); if(!name) name = domVal('pg-name'); if(!phone) phone = domVal('pg-phone'); if(!name) name = (S.name || '').trim(); if(!phone) phone = (S.phone || '').trim(); if(!email) email = (S.email || '').trim(); if(!zip) zip = (S.zip || '').trim(); return { name, phone, email, zip }; } function leadFormFilled(){ const { name, phone, email } = collectPdfCustomerData(); return !!(name && phone && email); } function handlePdfClick(){ if (leadFormFilled()) { generatePDF(); return; } openPdfGateModal(); } function renderPdfGateModal(){ return `
Get Your Deck Estimate PDF
Enter your name and phone to download:
Skip and download directly
`; } function openPdfGateModal(){ let modal = document.getElementById('pdf-gate-overlay'); if (!modal) { document.body.insertAdjacentHTML('beforeend', renderPdfGateModal()); modal = document.getElementById('pdf-gate-overlay'); } const n = document.getElementById('pg-name'); if (n) { n.value=''; n.classList.remove('err'); } const p = document.getElementById('pg-phone'); if (p) { p.value=''; p.classList.remove('err'); } const errN = document.getElementById('pg-e-name'); if (errN) errN.textContent=''; const errP = document.getElementById('pg-e-phone'); if (errP) errP.textContent=''; modal.classList.add('visible'); } function closePdfGateModal(){ const modal = document.getElementById('pdf-gate-overlay'); if (modal) modal.classList.remove('visible'); } async function submitPdfGate(){ const nameEl = document.getElementById('pg-name'); const phoneEl = document.getElementById('pg-phone'); const errNameEl = document.getElementById('pg-e-name'); const errPhoneEl = document.getElementById('pg-e-phone'); const name = (nameEl ? nameEl.value : '').trim(); const phone = (phoneEl ? phoneEl.value : '').trim(); let valid = true; if (name.length < 2) { nameEl?.classList.add('err'); if (errNameEl) errNameEl.textContent = 'Please enter your name.'; valid = false; } else { nameEl?.classList.remove('err'); if (errNameEl) errNameEl.textContent = ''; } if (phone.replace(/\D/g,'').length < 10) { phoneEl?.classList.add('err'); if (errPhoneEl) errPhoneEl.textContent = 'Please enter a valid phone number.'; valid = false; } else { phoneEl?.classList.remove('err'); if (errPhoneEl) errPhoneEl.textContent = ''; } if (!valid) return; const payload = { timestamp: new Date().toISOString(), source: 'DANZAR Decks & Outdoor Living Web Estimator', tag: 'pdf_gate', customer: { name, phone }, }; if (WEBHOOK_URL && WEBHOOK_URL !== 'YOUR_WEBHOOK_URL_HERE') { try { await fetch(WEBHOOK_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); } catch(e){ console.error('[NDS] PDF gate webhook error:', e.message); } } console.log('%c[NDS] PDF GATE LEAD','color:#D97724;font-weight:bold'); console.log(JSON.stringify(payload,null,2)); closePdfGateModal(); generatePDF(); } function skipPdfGate(){ closePdfGateModal(); generatePDF(); } /* ── PDF GENERATION ── */ function generatePDF(){ if(!window.jspdf){alert('PDF library loading, please try again.');return;} const btn=document.querySelector('.btn-pdf'); if(btn){btn.disabled=true;btn.textContent='⏳ Generating…';} try{ const {jsPDF}=window.jspdf; const doc=new jsPDF({orientation:'portrait',unit:'mm',format:'a4'}); const E=calcEstimate(); const matLabel={cedar:'Western Red Cedar',pine:'Pressure-Treated Pine',timbertech_prime:'Composite — Standard',timbertech_landmark:'Composite — Premium'}[S.material]||S.material; const rail={none:'None',wood:'Wood Railing',aluminum:'Aluminum Railing',cable:'Cable Railing'}[S.railType]||S.railType; const shapeLabel={rectangle:'Rectangle',l_shape:'L-Shape',multi_level:'Multi-Level',custom:'Custom Design'}[S.shape]||S.shape||'—'; const demoLabel={new:'New Build',replace:'Replace Existing Deck',extend:'Extend Existing Deck'}[S.demolition]||S.demolition; const W=210, M=14, CW=W-M*2; const NAVY=[31,58,43], ORANGE=[217,119,36], WHITE=[255,255,255], CREAM=[250,248,245], LIGHT=[240,235,228], MUTED=[130,148,165]; /* Read lead-form fields directly from the DOM by their actual IDs (falls back to S if the form isn't on screen, e.g. success page) */ const { name: custName, phone: custPhone, email: custEmail, zip: custCity } = collectPdfCustomerData(); /* Header */ doc.setFillColor(...NAVY); doc.rect(0,0,W,44,'F'); doc.setFillColor(...ORANGE); doc.rect(0,44,W,3.5,'F'); doc.setFont('helvetica','bold'); doc.setFontSize(20); doc.setTextColor(...WHITE); doc.text('DANZAR DECKS & OUTDOOR LIVING',M,16); doc.setFont('helvetica','normal'); doc.setFontSize(10); doc.setTextColor(200,215,230); doc.text('Deck Estimate Summary · Washington State Licensed & Insured',M,25); doc.setFontSize(9); doc.setTextColor(160,180,200); doc.text('Generated: '+new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'}),M,33); doc.setFont('helvetica','bold'); doc.setFontSize(8); doc.setTextColor(...ORANGE); doc.text('ESTIMATE RANGE',W-M,15,{align:'right'}); doc.setFontSize(15); doc.setTextColor(255,190,80); doc.text(`${$$(E.low)} – ${$$(E.high)}`,W-M,26,{align:'right'}); doc.setFont('helvetica','normal'); doc.setFontSize(8); doc.setTextColor(160,180,200); doc.text('±7% project range',W-M,33,{align:'right'}); let y=54; const colW=(CW-8)/2, rCol=M+colW+8; /* Customer */ doc.setFont('helvetica','bold'); doc.setFontSize(8); doc.setTextColor(...ORANGE); doc.text('CUSTOMER INFORMATION',M,y); y+=5; [[' Name',custName||'—'],['Phone',custPhone||'—'],['Email',custEmail||'—'],['ZIP',custCity||'—']].forEach(([k,v])=>{ doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...NAVY); doc.text(k+':',M,y); doc.setFont('helvetica','normal'); doc.setTextColor(80,100,120); doc.text(v,M+16,y); y+=5.5; }); /* Specs */ let ry=54; doc.setFont('helvetica','bold'); doc.setFontSize(8); doc.setTextColor(...ORANGE); doc.text('PROJECT SPECIFICATIONS',rCol,ry); ry+=5; [['Shape',shapeLabel],['Material',matLabel],['Size',`${S.width||'—'}×${S.depth||'—'} ft (${E.sqFt.toLocaleString()} sq ft)`],['Perimeter',`${E.perimFt.toFixed(0)} ft (${S.attached?'3 sides — attached':'4 sides — freestanding'})`],['Height',`${S.height} ft above grade`],['Stairs',`${E.numSteps} steps (auto-calc)`],['Railing',rail],['Slope',S.slope||'—'],['Structure',demoLabel||'—']].forEach(([k,v])=>{ doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...NAVY); doc.text(k+':',rCol,ry); doc.setFont('helvetica','normal'); doc.setTextColor(80,100,120); doc.text(v,rCol+20,ry); ry+=5.5; }); y=Math.max(y,ry)+6; doc.setDrawColor(...LIGHT); doc.setLineWidth(0.4); doc.line(M,y,W-M,y); y+=7; /* Breakdown */ doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...NAVY); doc.text('COST BREAKDOWN',M,y); y+=4; doc.setFillColor(...NAVY); doc.rect(M,y,CW,8,'F'); doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...WHITE); doc.text('Line Item',M+3,y+5.5); doc.text('Amount',W-M-3,y+5.5,{align:'right'}); y+=8; const rows=[ [`Materials (${E.sqFt.toLocaleString()} sq ft + 10% waste = ${Math.ceil(E.sqFt*1.10).toLocaleString()} sq ft)`,E.matCost], ['Labor & Installation',E.laborCost], ]; if(E.railCost>0) rows.push([`${rail} — ${E.perimFt.toFixed(0)} ln ft (${S.attached?'3 sides — attached':'4 sides — freestanding'})`,E.railCost]); if(E.stairCost>0) rows.push([`Stairs — ${E.numSteps} steps @ ${$$((P.stairs[S.material].mat_per_step+P.stairs[S.material].labor_per_step))}/step`,E.stairCost]); if(E.demoCost>0) rows.push([`Existing Structure — ${demoLabel}`,E.demoCost]); if(E.slopeCost>0) rows.push([`Site Preparation — ${S.slope} slope`,E.slopeCost]); rows.push(['Permits & Inspections (WA State)',E.permitCost]); rows.forEach((row,idx)=>{ doc.setFillColor(...(idx%2===0?CREAM:WHITE)); doc.rect(M,y,CW,7,'F'); doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(70,90,110); doc.text(row[0],M+3,y+5); doc.setFont('helvetica','bold'); doc.setTextColor(...NAVY); doc.text($$(row[1]),W-M-3,y+5,{align:'right'}); y+=7; }); doc.setFillColor(...NAVY); doc.rect(M,y,CW,9,'F'); doc.setFont('helvetica','bold'); doc.setFontSize(10); doc.setTextColor(...WHITE); doc.text('Base Estimate',M+3,y+6.5); doc.text($$(E.base),W-M-3,y+6.5,{align:'right'}); y+=9+7; /* Orange range banner */ doc.setFillColor(...ORANGE); doc.rect(M,y,CW,24,'F'); doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...WHITE); doc.text('ESTIMATED PROJECT INVESTMENT (±7% RANGE)',W/2,y+8,{align:'center'}); doc.setFontSize(22); doc.text(`${$$(E.low)} – ${$$(E.high)}`,W/2,y+20,{align:'center'}); y+=24+7; /* Notes */ if(S.notes&&S.notes.trim()){ doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...ORANGE); doc.text('NOTES',M,y); y+=5; doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(80,100,120); const nl=doc.splitTextToSize(S.notes.trim(),CW); doc.text(nl,M,y); y+=nl.length*5+4; } /* Disclaimer */ doc.setFont('helvetica','italic'); doc.setFontSize(8); doc.setTextColor(...MUTED); doc.text('This estimate is valid for 30 days. Final price may vary based on conditions found during the free on-site inspection.',M,y,{maxWidth:CW}); /* Footer */ doc.setFillColor(...NAVY); doc.rect(0,278,W,19,'F'); doc.setFont('helvetica','bold'); doc.setFontSize(9.5); doc.setTextColor(...ORANGE); doc.text('DANZAR Decks & Outdoor Living',M,287); doc.setFont('helvetica','normal'); doc.setFontSize(8.5); doc.setTextColor(180,200,220); doc.text('Washington State Licensed & Insured · danzardecks.com',M,293); doc.setFont('helvetica','bold'); doc.setFontSize(9); doc.setTextColor(...WHITE); doc.text('(425) 295-2131',W-M,287,{align:'right'}); doc.setFont('helvetica','normal'); doc.setFontSize(8.5); doc.setTextColor(180,200,220); doc.text('hello@danzardecks.com',W-M,293,{align:'right'}); const safeName=(custName||'Estimate').replace(/[^a-z0-9]/gi,'-'); doc.save(`DanzarDeck-${safeName}-${new Date().toISOString().slice(0,10)}.pdf`); } catch(err){ console.error('[NDS] PDF error:',err); alert('PDF error: '+err.message); } finally { const btn=document.querySelector('.btn-pdf'); if(btn){btn.disabled=false;btn.innerHTML='📄 Download PDF Summary';} } } /* ── VALIDATION ── */ function validateLead(){ const RULES=[ {id:'l-name', err:'e-name', key:'name', lbl:'name', ok:v=>v.trim().length>=2}, {id:'l-phone',err:'e-phone',key:'phone', lbl:'phone number', ok:v=>v.replace(/\D/g,'').length>=10}, {id:'l-email',err:'e-email',key:'email', lbl:'email', ok:v=>/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)}, {id:'l-zip', err:'e-zip', key:'zip', lbl:'ZIP code', ok:v=>/^\d{5}$/.test(v.trim())}, ]; let valid=true; RULES.forEach(r=>{ const el=document.getElementById(r.id), err=document.getElementById(r.err), val=(el?el.value:S[r.key])||''; if(!r.ok(val)){el?.classList.add('err'); if(err)err.textContent=`Please enter a valid ${r.lbl}.`; valid=false;} else{el?.classList.remove('err'); if(err)err.textContent='';} }); return valid; } /* ── EMAIL BUILDERS ── */ function buildManagerEmail(E) { const bd = S.costBreakdown; const $n = v => '$' + Math.round(v).toLocaleString('en-US'); const mat = S.material || 'pine'; const matLabels = { pine:'PT Wood (Pine)', cedar:'Western Red Cedar', timbertech_prime:'TimberTech Prime+®', timbertech_landmark:'TimberTech Landmark®' }; const railLabels = { none:'None', wood:'Wood', aluminum:'Aluminum', cable:'Cable' }; const ref = 'DAZ-' + Date.now().toString(36).toUpperCase(); if (!bd) { return `

Cost breakdown unavailable — calcEstimate() was not called before submit.

`; } const totalMat = (bd.finish.boardCost + bd.finish.fasciaCost + bd.finish.fastCost) + bd.stairsRail.stairMatCost + bd.stairsRail.railMatCost + bd.structure.footings + bd.structure.postCost + bd.structure.beamCost + bd.structure.joistCost + bd.structure.ledgerCost + bd.structure.hardwareCost; const totalLabor = bd.structure.structLabor + bd.finish.finishLabor + bd.stairsRail.stairLabor; const grossProfit = bd.high - bd.subtotal; const marginPct = bd.high > 0 ? Math.round((grossProfit / bd.high) * 100) : 0; const row = (label, val, indent) => `${label}${val}`; const section = (title, color) => `${title}`; const divider = () => ``; return `
DANZAR Decks — New Lead
Ref: ${ref} · ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}
Customer
${row('Name', esc(S.name))} ${row('Phone', esc(S.phone))} ${row('Email', esc(S.email))} ${row('ZIP', esc(S.zip))}
Project
${row('Material', matLabels[mat] || mat)} ${row('Color', S.colorName ? `${S.colorName}` : '—')} ${row('Shape', {rectangle:'Rectangle',l_shape:'L-Shape',multi_level:'Multi-Level',custom:'Custom'}[S.shape]||'—')} ${row('Dimensions', `${S.width} × ${S.depth} ft`)} ${row('Area', `${Math.round(bd.sqFt).toLocaleString()} sq ft (effective ${Math.round(bd.sqFtEff).toLocaleString()} sq ft)`)} ${row('Perimeter', `${Math.round(bd.perimFt)} lf`)} ${row('Height', `${S.height} ft (${Math.round(S.height*12)} in)`)} ${row('Railing', railLabels[S.railType] || S.railType)} ${row('Stairs', `${bd.numSteps} steps`)} ${row('Slope', S.slope || 'none')} ${row('Demo', S.demolition || 'none')} ${row('Permit', S.permitHandling ? 'Yes (handling requested)' : 'No')}
Cost Breakdown
${section('Block 1 — Structure', '#1F3A2B')} ${row('Footings (flat)', $n(bd.structure.footings), true)} ${row('Posts', $n(bd.structure.postCost), true)} ${row('Beams', $n(bd.structure.beamCost), true)} ${row('Joists', $n(bd.structure.joistCost), true)} ${row('Ledger', $n(bd.structure.ledgerCost), true)} ${row('Hardware', $n(bd.structure.hardwareCost), true)} ${row('Structure Labor', $n(bd.structure.structLabor), true)} ${row('Block 1 Total', $n(bd.structure.total))} ${section('Block 2 — Finish', '#D97724')} ${row(`Decking Boards (${Math.round(bd.totalLf)} lf)`, $n(bd.finish.boardCost), true)} ${row(`Fascia (${Math.round(bd.perimFt)} lf)`, $n(bd.finish.fasciaCost), true)} ${row('Fasteners / Clips', $n(bd.finish.fastCost), true)} ${row('Finish Labor', $n(bd.finish.finishLabor), true)} ${row('Block 2 Total', $n(bd.finish.total))} ${section('Block 3 — Stairs & Railings', '#4A7C59')} ${row(`Stair Material (${bd.numSteps} steps)`, $n(bd.stairsRail.stairMatCost), true)} ${row('Stair Labor', $n(bd.stairsRail.stairLabor), true)} ${row(`Railing — all-in (${Math.round(bd.perimFt)} lf)`, $n(bd.stairsRail.railMatCost), true)} ${row('Block 3 Total', $n(bd.stairsRail.total))} ${section('Services & Adjustments', '#666')} ${row('Demo / Removal', $n(bd.services.demoCost), true)} ${row('Slope / Site Prep', $n(bd.services.slopeCost), true)} ${row('Permit Handling', $n(bd.services.permitCost), true)} ${row('Height Factor', `× ${bd.heightFactor.toFixed(2)}`)} ${divider()} ${row('Total Material Cost', $n(totalMat))} ${row('Total Labor Cost', $n(totalLabor))} ${divider()} ${row('Subtotal (cost)', $n(bd.subtotal))} ${row('Target Margin', Math.round(bd.margin * 100) + '%')} ${row('Sell Price (mid)', $n(bd.base))} ${row('Sell Price (low)', $n(bd.low))} ${row('Sell Price (high)', $n(bd.high))} ${divider()} ${row('Est. Gross Profit', $n(grossProfit))} ${row('Est. Gross Margin %', marginPct + '%')}
${S.notes ? `
Client Notes
${esc(S.notes)}
` : ''}
Pricing source: ${S.livePrices ? 'Live Google Sheets' : 'Market fallback rates'} · DANZAR Decks Web Estimator
`; } function buildClientEmail(E) { const matLabels = { pine:'Natural Wood — Pressure Treated', cedar:'Natural Wood — Western Red Cedar', timbertech_prime:'TimberTech Prime+® Composite', timbertech_landmark:'TimberTech Landmark® Premium PVC' }; const mat = S.material || 'pine'; const colorLine = S.colorName ? `Color${esc(S.colorName)}` : ''; return `
Your Deck Quote Request
DANZAR Decks & Outdoor Living

Hi ${esc(S.name)}, thanks for using our estimator! Here's a summary of your project and what to expect next.

Your Project
${colorLine}
Material${matLabels[mat] || mat}
Deck Size${S.width} × ${S.depth} ft (${Math.round(E.sqFt).toLocaleString()} sq ft)
Railing${{none:'None',wood:'Wood Railing',aluminum:'Aluminum Railing',cable:'Cable Railing'}[S.railType]||S.railType}
Stairs${E.numSteps > 0 ? E.numSteps + ' steps' : 'None'}
Estimated Investment
${$$(E.low)} – ${$$(E.high)}
Final price confirmed after free on-site consultation
What Happens Next
  1. Our team reviews your request (within 24 hours)
  2. We contact you to schedule a free on-site measurement
  3. You receive a detailed fixed-price proposal
  4. Work begins once you're ready

Questions? Reply to this email or call us directly.
— The DANZAR Decks Team

`; } /* ── SUBMIT LEAD → WEBHOOK ── */ async function submitLead(){ if(!validateLead())return; const btn=document.getElementById('submit-btn'); if(btn){btn.disabled=true;btn.innerHTML='⏳ Sending…';} const E=calcEstimate(); const leadPayload={ timestamp: new Date().toISOString(), source: 'DANZAR Decks & Outdoor Living Web Estimator', customer: {name:S.name.trim(), phone:S.phone.trim(), email:S.email.trim(), zip:S.zip.trim()}, project: {shape:S.shape, material:S.material, color:S.colorName||null, widthFt:parseFloat(S.width), depthFt:parseFloat(S.depth), sqFt:E.sqFt, heightFt:S.height, heightIn:Math.round(S.height*12), stairs:E.numSteps, railType:S.railType, railingStyle:S.railingStyle||null, slope:S.slope, existingStructure:S.demolition, perimeterFt:E.perimFt, notes:S.notes, photosCount:S.photos.length}, estimate: {low:Math.round(E.low), base:Math.round(E.base), high:Math.round(E.high), variancePct:7, pricingSource:S.livePrices?'live-google-sheets':'market-fallback'}, options: {lighting_requested:S.lighting, permit_handling:S.permitHandling}, emails: { manager: { to: MANAGER_EMAIL, subject: `[DANZAR Lead] ${S.name.trim()} — ${S.width}×${S.depth}ft ${(S.material||'').replace(/_/g,' ')} — ${$$(E.low)}–${$$(E.high)}`, html: buildManagerEmail(E), }, client: { to: S.email.trim(), subject: 'Your DANZAR Decks Quote Request — We\'ll Be in Touch!', html: buildClientEmail(E), }, }, }; let webhookOk = false; if(WEBHOOK_URL&&WEBHOOK_URL!=='YOUR_WEBHOOK_URL_HERE'){ try{ const res=await fetch(WEBHOOK_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(leadPayload)}); webhookOk = res.ok; console.log(res.ok?'[NDS] ✅ Webhook OK':'[NDS] ⚠️ Webhook status '+res.status); }catch(e){console.error('[NDS] Webhook error:',e.message);} } console.log('%c[NDS] LEAD','color:#D97724;font-weight:bold;font-size:14px'); console.log(JSON.stringify(leadPayload,null,2)); // Save to localStorage as backup regardless try { localStorage.setItem('danzar_last_lead', JSON.stringify(leadPayload)); } catch(_){} if(!webhookOk){ // Webhook is placeholder or failed — do NOT show success screen const btn2=document.getElementById('submit-btn'); if(btn2){btn2.disabled=false;btn2.innerHTML='📩 Send My Free Quote Request';} const app=document.getElementById('app'); if(app){ const existing=app.querySelector('.webhook-fallback'); if(!existing) app.insertAdjacentHTML('afterbegin',`
📋
Your request has been saved locally.
Our online system is temporarily unavailable. Please call us directly at (425) 295-2131 or email hello@danzardecks.com and we'll get your quote started right away.
`); } return; } S.submitted=true; save(); render(); updateProgress(); } /* ── SUCCESS ── */ function renderSuccess(){ const E=calcEstimate(); const matLabel={cedar:'Cedar',pine:'Pine',timbertech_prime:'Composite Std',timbertech_landmark:'Composite Prem'}[S.material]||S.material; return `
🎉
Request Received!
Thank you, ${esc(S.name)}! Your quote request is in. We'll reach out within 24 hours to schedule your free on-site consultation.
Your Estimated Investment
${$$(E.low)} – ${$$(E.high)}
Shape${{rectangle:'Rectangle',l_shape:'L-Shape',multi_level:'Multi-Level',custom:'Custom Design'}[S.shape]||'—'}
Project${S.width}×${S.depth} ft · ${matLabel}
Area${E.sqFt.toLocaleString()} sq ft
Railing${{none:'None',wood:'Wood',aluminum:'Aluminum',cable:'Cable'}[S.railType]}
Stairs${E.numSteps} steps
ZIP Code${esc(S.zip)}
Contact${esc(S.phone||S.email)}
Reference #DAZ-${Date.now().toString(36).toUpperCase()}
Ready to move forward?
📅 Schedule a Call or Visit

Our team will also contact you within 24 hours.

`; } function startOver(){ localStorage.removeItem(STORE_KEY); S={...DEFAULT}; document.getElementById('progress-wrap').classList.remove('hidden'); render(); updateProgress(); window.scrollTo({top:0,behavior:'smooth'}); } function esc(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } /* ================================================================ DECK PREVIEW — Isometric 3D SVG Visualization FUTURE: Replace SVG preview with photo-realistic render when real deck photos are available. Photo integration: img src="renders/deck-[material]-[railing].jpg" ================================================================ */ const PREVIEW_MAT = { pine: { fill:'#C8A96E', stroke:'#A07840', board:'#B8934E', sideL:'#9A7038', sideF:'#AA8448' }, cedar: { fill:'#B5673A', stroke:'#8B4513', board:'#9E5530', sideL:'#7A3D1E', sideF:'#8B4828' }, timbertech_prime: { fill:'#7A8B7F', stroke:'#5A6B5F', board:'#6A7B6F', sideL:'#4A5B4F', sideF:'#5A6B5F' }, timbertech_landmark: { fill:'#4A5568', stroke:'#2D3748', board:'#3A4558', sideL:'#1D2738', sideF:'#2A3448' }, }; function renderDeckPreview() { const panel = document.getElementById('deck-preview-panel'); if (!panel) return; const W = parseFloat(S.width) || 0; const D = parseFloat(S.depth) || 0; const H = parseFloat(S.height) || 0; const mat = S.material || 'pine'; const railType = S.railType || 'none'; const shape = S.shape || 'rectangle'; const numSteps = calcStairs(H); const matLabel = {cedar:'Western Red Cedar',pine:'Pressure-Treated Pine',timbertech_prime:'Composite — Standard',timbertech_landmark:'Composite — Premium'}[mat] || '—'; const railLabel = {none:'None',wood:'Wood Railing',aluminum:'Aluminum Railing',cable:'Cable Railing'}[railType] || '—'; const SVG_W = 360, SVG_H = 260; const cos30 = Math.cos(Math.PI / 6); // 0.866 const sin30 = 0.5; const ZS = 0.8; // z scale factor const mc = (typeof getPreviewColor === 'function') ? getPreviewColor(mat, S.selectedColor) : (PREVIEW_MAT[mat] || PREVIEW_MAT.pine); const hasDims = W > 0 && D > 0; // ── Scale & Origin ────────────────────────────────────────────── const railH = railType !== 'none' ? 3 : 0; const stairLen = numSteps * 0.9; // stairs go in +X from X=W let scale, cx, cy; if (hasDims) { const isoW = (W + D + stairLen) * cos30; const isoH = (W + Math.max(D, stairLen)) * sin30 + (H + railH) * ZS; scale = Math.min((SVG_W - 40) / Math.max(isoW, 0.1), (SVG_H - 40) / Math.max(isoH, 0.1), 8); cx = 20 + D * cos30 * scale; cy = 20 + (H + railH) * ZS * scale; } else { // ghost placeholder deck const gW = 12, gD = 10; const isoW = (gW + gD) * cos30; const isoH = (gW + gD) * sin30 + 0.4 * ZS; scale = Math.min((SVG_W - 80) / isoW, (SVG_H - 80) / isoH, 7); cx = 20 + gD * cos30 * scale; cy = 20 + 0.4 * ZS * scale; } // ── Projection helpers ───────────────────────────────────────── function iso(wx, wy, wz) { return { x: (wx - wy) * cos30 * scale + cx, y: (wx + wy) * sin30 * scale - wz * ZS * scale + cy }; } function pt(wx, wy, wz) { const c = iso(wx, wy, wz); return `${c.x.toFixed(1)},${c.y.toFixed(1)}`; } // ── SVG element builders ──────────────────────────────────────── const el = []; function poly(pts, fill, stroke='none', sw=0.5, extra='') { el.push(``); } function line(x1,y1,x2,y2, stroke, sw=1, extra='') { el.push(``); } function text(tx, ty, msg, size=9, fill='#8B7355', anchor='middle') { el.push(`${msg}`); } // ── Ghost placeholder ────────────────────────────────────────── if (!hasDims) { const gW = 12, gD = 10, gZ = 0.4; poly([pt(0,0,0),pt(gW,0,0),pt(gW,gD,0),pt(0,gD,0)], 'rgba(0,0,0,0.05)'); poly([pt(0,0,0),pt(0,gD,0),pt(0,gD,gZ),pt(0,0,gZ)], '#DEDAD4','#CCC8C0',0.5); poly([pt(0,0,0),pt(gW,0,0),pt(gW,0,gZ),pt(0,0,gZ)], '#E8E4DE','#CCC8C0',0.5); poly([pt(0,0,gZ),pt(gW,0,gZ),pt(gW,gD,gZ),pt(0,gD,gZ)], '#EDEAE5','#CCC8C0',0.5); const ctr = iso(gW/2, gD/2, gZ + 0.8); text(ctr.x, ctr.y, 'Choose dimensions', 10, '#BBBBAA'); const panel2 = document.getElementById('deck-preview-panel'); panel2.innerHTML = `
🏠 Your Deck Preview
${el.join('')}
Select dimensions to preview your deck
`; return; } // ── Draw full deck ───────────────────────────────────────────── const deckZ = H; const botZ = Math.max(H - 0.35, 0); const thick = deckZ - botZ; // Determine rectangles to render let mainW = W, mainD = D; let hasL = false, lW = 0, lD = 0; // L-shape second rect starting at (0, mainD) let hasUpper = false, uX = 0, uY = 0, uW = 0, uD = 0, uZ = 0; // multi-level upper if (shape === 'l_shape') { mainD = +(D * 0.55).toFixed(2); lW = +(W * 0.5).toFixed(2); lD = D; hasL = true; } else if (shape === 'multi_level') { uX = +(W * 0.42).toFixed(2); uY = +(D * 0.42).toFixed(2); uW = +(W * 0.56).toFixed(2); uD = +(D * 0.56).toFixed(2); uZ = deckZ + 1.5; hasUpper = true; } // ── Ground shadow ────────────────────────────────────────────── poly([pt(0,0,0),pt(mainW,0,0),pt(mainW,mainD,0),pt(0,mainD,0)], 'rgba(0,0,0,0.06)'); if (hasL) poly([pt(0,mainD,0),pt(lW,mainD,0),pt(lW,lD,0),pt(0,lD,0)], 'rgba(0,0,0,0.05)'); // ── Support posts ────────────────────────────────────────────── if (H > 0.15) { const ps = 0.22, pc = '#8B7355', pd = '#6B5535', pt2 = '#A08060'; const postXs = new Set([0, mainW]); const postYs = new Set([0, mainD]); for (let x = 8; x < mainW; x += 8) postXs.add(+x.toFixed(1)); for (let y = 8; y < mainD; y += 8) postYs.add(+y.toFixed(1)); const pxa = [...postXs].sort((a,b)=>b-a); // back to front: high X first const pya = [...postYs].sort((a,b)=>b-a); for (const px of pxa) { for (const py of pya) { if (px > 0 && px < mainW && py > 0 && py < mainD) continue; poly([pt(px,py,0),pt(px+ps,py,0),pt(px+ps,py,H),pt(px,py,H)], pd, '#5A4525', 0.3); poly([pt(px,py,0),pt(px,py+ps,0),pt(px,py+ps,H),pt(px,py,H)], pc, '#5A4525', 0.3); poly([pt(px,py,H),pt(px+ps,py,H),pt(px+ps,py+ps,H),pt(px,py+ps,H)], pt2); } } } // ── Draw a single deck rectangle ────────────────────────────── function drawRect(rW, rD, rDeckZ, rBotZ, mats, dashStyle='') { // Left face (X=0) poly([pt(0,0,rBotZ),pt(0,rD,rBotZ),pt(0,rD,rDeckZ),pt(0,0,rDeckZ)], mats.sideL, mats.stroke, 0.4); // Front face (Y=0) poly([pt(0,0,rBotZ),pt(rW,0,rBotZ),pt(rW,0,rDeckZ),pt(0,0,rDeckZ)], mats.sideF, mats.stroke, 0.4); // Top surface poly([pt(0,0,rDeckZ),pt(rW,0,rDeckZ),pt(rW,rD,rDeckZ),pt(0,rD,rDeckZ)], mats.fill, mats.stroke, 0.5, dashStyle); // Board lines const bs = 0.38; for (let bx = bs; bx < rW; bx += bs) { const a = iso(bx, 0, rDeckZ), b = iso(bx, rD, rDeckZ); line(a.x, a.y, b.x, b.y, mats.board, 0.7, 'opacity="0.55"'); } } if (shape === 'custom') { // Dashed outline with label poly([pt(0,0,deckZ),pt(W,0,deckZ),pt(W,D,deckZ),pt(0,D,deckZ)], 'rgba(200,175,140,0.25)', '#A07840', 1.5, 'stroke-dasharray="7,4"'); poly([pt(0,0,deckZ),pt(W,0,deckZ),pt(W,0,deckZ+0.35),pt(0,0,deckZ+0.35)], 'rgba(170,140,100,0.18)', '#A07840', 0.8, 'stroke-dasharray="5,3"'); const c1 = iso(W/2, D/2, deckZ + 0.7); const c2 = iso(W/2, D/2, deckZ + 0.1); text(c1.x, c1.y, 'Custom Shape', 9, '#7A5A30'); text(c2.x, c2.y + 12, 'Confirmed at Consultation', 7.5, '#9A7A50'); } else { drawRect(mainW, mainD, deckZ, botZ, mc); if (hasL) { // L-shape extra section: offset polygon from (0, mainD) to (lW, lD) const lBotZ = botZ; // Extra left face at y = mainD between x=0 and x=lW (this face opens toward viewer on left) poly([pt(0,mainD,lBotZ),pt(lW,mainD,lBotZ),pt(lW,mainD,deckZ),pt(0,mainD,deckZ)], mc.sideF, mc.stroke, 0.4); // Extra right face at x=lW from y=mainD to y=lD poly([pt(lW,mainD,lBotZ),pt(lW,lD,lBotZ),pt(lW,lD,deckZ),pt(lW,mainD,deckZ)], mc.sideL, mc.stroke, 0.4); // Extra top poly([pt(0,mainD,deckZ),pt(lW,mainD,deckZ),pt(lW,lD,deckZ),pt(0,lD,deckZ)], mc.fill, mc.stroke, 0.5); const bs = 0.38; for (let bx = bs; bx < lW; bx += bs) { const a = iso(bx, mainD, deckZ), b = iso(bx, lD, deckZ); line(a.x, a.y, b.x, b.y, mc.board, 0.7, 'opacity="0.55"'); } } if (hasUpper) { // Multi-level upper deck const uBotZ = deckZ; poly([pt(uX,uY,uBotZ),pt(uX,uY+uD,uBotZ),pt(uX,uY+uD,uZ),pt(uX,uY,uZ)], mc.sideL, mc.stroke, 0.4); poly([pt(uX,uY,uBotZ),pt(uX+uW,uY,uBotZ),pt(uX+uW,uY,uZ),pt(uX,uY,uZ)], mc.sideF, mc.stroke, 0.4); poly([pt(uX,uY,uZ),pt(uX+uW,uY,uZ),pt(uX+uW,uY+uD,uZ),pt(uX,uY+uD,uZ)], mc.fill, mc.stroke, 0.5); const bs = 0.38; for (let bx = bs; bx < uW; bx += bs) { const a = iso(uX+bx, uY, uZ), b = iso(uX+bx, uY+uD, uZ); line(a.x, a.y, b.x, b.y, mc.board, 0.7, 'opacity="0.55"'); } } } // ── Stairs (exit in +X direction from X=W, Y=0 face) ────────── if (numSteps > 0 && shape !== 'custom') { const stepD = 0.9; // ft per tread const stepH = (H * 12 / numSteps) / 12; // ft per riser const sW = Math.min(4.5, mainD * 0.4); const sY0 = (mainD - sW) / 2; // center stairs on the Y axis for (let i = 0; i < numSteps; i++) { const sZ = deckZ - (i + 1) * stepH; const sX0 = mainW + i * stepD; const sX1 = mainW + (i + 1) * stepD; // Riser face (X = sX1 side, visible as right face in iso) poly([pt(sX1,sY0,sZ),pt(sX1,sY0+sW,sZ),pt(sX1,sY0+sW,sZ+stepH),pt(sX1,sY0,sZ+stepH)], mc.sideL, mc.stroke, 0.4); // Front face (Y = sY0 side) poly([pt(sX0,sY0,sZ),pt(sX1,sY0,sZ),pt(sX1,sY0,sZ+stepH),pt(sX0,sY0,sZ+stepH)], mc.sideF, mc.stroke, 0.4); // Tread top poly([pt(sX0,sY0,sZ+stepH),pt(sX1,sY0,sZ+stepH),pt(sX1,sY0+sW,sZ+stepH),pt(sX0,sY0+sW,sZ+stepH)], mc.fill, mc.stroke, 0.5); } } // ── Railings ──────────────────────────────────────────────────── if (railType !== 'none' && shape !== 'custom') { const rH = 3; // railing height ft const topZ = deckZ + rH; const pSz = 0.18; let rTop, rPost, rBal; if (railType === 'wood') { rTop = '#8B6914'; rPost = '#7A5A10'; rBal = '#A07840'; } else if (railType === 'aluminum') { rTop = '#9CA3AF'; rPost = '#6B7280'; rBal = '#D1D5DB'; } else { rTop = '#9CA3AF'; rPost = '#6B7280'; rBal = '#9CA3AF'; } // cable // Front railing (Y=0 face, from X=0 to X=mainW) if (railType === 'cable') { poly([pt(0,0,deckZ),pt(pSz,0,deckZ),pt(pSz,0,topZ),pt(0,0,topZ)], rPost); poly([pt(mainW-pSz,0,deckZ),pt(mainW,0,deckZ),pt(mainW,0,topZ),pt(mainW-pSz,0,topZ)], rPost); for (let cz = deckZ + rH * 0.18; cz < topZ - 0.1; cz += rH * 0.22) { const a = iso(0, 0, cz), b = iso(mainW, 0, cz); line(a.x, a.y, b.x, b.y, rBal, 1); } } else { const bs = railType === 'wood' ? 0.32 : 0.42; for (let bx = bs; bx < mainW; bx += bs) { const a = iso(bx, 0, deckZ), b = iso(bx, 0, topZ - 0.25); line(a.x, a.y, b.x, b.y, rBal, 1.4, 'opacity="0.8"'); } } poly([pt(0,0,topZ-0.22),pt(mainW,0,topZ-0.22),pt(mainW,0,topZ),pt(0,0,topZ)], rTop); // Left railing (X=0 face, from Y=0 to Y=mainD) if (railType === 'cable') { poly([pt(0,0,deckZ),pt(0,pSz,deckZ),pt(0,pSz,topZ),pt(0,0,topZ)], rPost); poly([pt(0,mainD-pSz,deckZ),pt(0,mainD,deckZ),pt(0,mainD,topZ),pt(0,mainD-pSz,topZ)], rPost); for (let cz = deckZ + rH * 0.18; cz < topZ - 0.1; cz += rH * 0.22) { const a = iso(0, 0, cz), b = iso(0, mainD, cz); line(a.x, a.y, b.x, b.y, rBal, 1); } } else { const bs = railType === 'wood' ? 0.32 : 0.42; for (let by = bs; by < mainD; by += bs) { const a = iso(0, by, deckZ), b = iso(0, by, topZ - 0.25); line(a.x, a.y, b.x, b.y, rBal, 1.4, 'opacity="0.8"'); } } poly([pt(0,0,topZ-0.22),pt(0,mainD,topZ-0.22),pt(0,mainD,topZ),pt(0,0,topZ)], rTop); } // ── Info text ─────────────────────────────────────────────────── const sqFt = (W * D).toLocaleString(); const lines = [ `${W} ft × ${D} ft · ${sqFt} sq ft`, `Material: ${matLabel}`, ]; if (railType !== 'none') lines.push(`Railing: ${railLabel}`); if (numSteps > 0) lines.push(`Stairs: ${numSteps} steps`); const svgEl = `${el.join('')}`; panel.innerHTML = `
🏠 Your Deck Preview
${svgEl}
${lines.map((l,i)=>`
${l}
`).join('')}
`; } /* ── INIT ── */ async function init(){ render(); updateProgress(); renderDeckPreview(); await fetchPrices(); } init();