1document.addEventListener('DOMContentLoaded', () => { 2 const toggle = document.querySelector('.nav-toggle'); 3 const nav = document.querySelector('.site-nav'); 4 const navBackdrop = document.querySelector('.nav-backdrop'); 5 const desktopQuery = window.matchMedia('(min-width: 1040px)'); 6 7 if (toggle && nav) { 8 // Left slide-in drawer for mobile/tablet nav. Uses a manual focus trap 9 // (Tab/Shift+Tab wrap) plus `inert` while closed, since (unlike the 10 // consultation <dialog>) this is a plain fixed-position panel, not a 11 // native dialog. 12 function openDrawer() { 13 nav.classList.add('is-open'); 14 if (navBackdrop) { navBackdrop.hidden = false; requestAnimationFrame(() => navBackdrop.classList.add('is-open')); } 15 document.documentElement.classList.add('nav-open'); 16 toggle.setAttribute('aria-expanded', 'true'); 17 nav.removeAttribute('inert'); 18 nav._returnFocus = document.activeElement; 19 const closeBtn = nav.querySelector('.nav-close'); 20 if (closeBtn) closeBtn.focus(); 21 } 22 23 function closeDrawer() { 24 nav.classList.remove('is-open'); 25 if (navBackdrop) navBackdrop.classList.remove('is-open'); 26 document.documentElement.classList.remove('nav-open'); 27 toggle.setAttribute('aria-expanded', 'false'); 28 if (!desktopQuery.matches) nav.setAttribute('inert', ''); 29 if (navBackdrop) setTimeout(() => { if (!nav.classList.contains('is-open')) navBackdrop.hidden = true; }, 300); 30 const returnFocus = nav._returnFocus; 31 if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); 32 else toggle.focus(); 33 } 34 35 toggle.addEventListener('click', () => { 36 if (nav.classList.contains('is-open')) closeDrawer(); else openDrawer(); 37 }); 38 39 document.addEventListener('click', (event) => { 40 if (nav.classList.contains('is-open') && event.target.closest('[data-nav-close]')) closeDrawer(); 41 }); 42 43 document.addEventListener('keydown', (event) => { 44 if (!nav.classList.contains('is-open')) return; 45 if (event.key === 'Escape') { closeDrawer(); return; } 46 if (event.key !== 'Tab') return; 47 const focusable = nav.querySelectorAll('a[href], button:not([disabled])'); 48 if (!focusable.length) return; 49 const first = focusable[0]; 50 const last = focusable[focusable.length - 1]; 51 if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } 52 else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } 53 }); 54 55 desktopQuery.addEventListener('change', (event) => { 56 if (event.matches) { nav.classList.remove('is-open'); if (navBackdrop) { navBackdrop.classList.remove('is-open'); navBackdrop.hidden = true; } document.documentElement.classList.remove('nav-open'); toggle.setAttribute('aria-expanded', 'false'); nav.removeAttribute('inert'); } 57 else if (!nav.classList.contains('is-open')) { nav.setAttribute('inert', ''); } 58 }); 59 60 if (!desktopQuery.matches) nav.setAttribute('inert', ''); 61 } 62 63 document.querySelectorAll('.nav-trigger').forEach((trigger) => { 64 trigger.addEventListener('click', () => { 65 if (desktopQuery.matches) return; 66 const group = trigger.closest('.nav-group'); 67 if (!group) return; 68 const open = group.classList.toggle('is-expanded'); 69 trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); 70 }); 71 }); 72 73 const appRows = document.querySelector('#appRows'); 74 const addRow = document.querySelector('#addAppRow'); 75 const summary = document.querySelector('#appSummary'); 76 77 function rowTemplate() { 78 const div = document.createElement('div'); 79 div.className = 'calculator-row'; 80 div.innerHTML = '<input name="app_name[]" placeholder="Bundle app"><input name="app_cost[]" type="number" min="0" placeholder="49"><input name="app_feature[]" placeholder="Product bundles"><select name="app_slow[]"><option value="no">Not slow</option><option value="yes">Slowing store</option></select><select name="app_critical[]"><option value="no">Not critical</option><option value="yes">Business-critical</option></select><button class="btn btn-light remove-row" type="button">Remove</button>'; 81 return div; 82 } 83 84 function updateCalculator() { 85 if (!appRows || !summary) return; 86 let monthly = 0; 87 let keep = 0; 88 let review = 0; 89 let replace = 0; 90 let savings = 0; 91 92 appRows.querySelectorAll('.calculator-row').forEach((row) => { 93 const cost = Number(row.querySelector('[name="app_cost[]"]').value || 0
93); 94 const slow = row.querySelector('[name="app_slow[]"]').value === 'yes'; 95 const critical = row.querySelector('[name="app_critical[]"]').value === 'yes'; 96 monthly += cost; 97 if (critical) keep += 1; 98 else if (slow || cost <= 50) { 99 replace += 1; 100 savings += cost; 101 } else { 102 review += 1; 103 } 104 }); 105 106 summary.innerHTML = `<div class="eyebrow">Your app stack cost</div><div class="stat-number">$${monthly.toLocaleString()}</div><p>/month | $${(monthly * 12).toLocaleString()} per year</p><div class="summary-stack"><div class="summary-line"><span>Keep</span><strong>${keep} apps</strong></div><div class="summary-line review"><span>Review</span><strong>${review} apps</strong></div><div class="summary-line replace"><span>Likely replaceable</span><strong>${replace} apps</strong></div></div>${savings > 0 ? `<div class="summary-saving">Potential saving if replaced: <strong>$${savings.toLocaleString()}/mo</strong> | $${(savings * 12).toLocaleString()}/yr</div>` : ''}`; 107 } 108 109 if (appRows) { 110 appRows.addEventListener('input', updateCalculator); 111 appRows.addEventListener('change', updateCalculator); 112 appRows.addEventListener('click', (event) => { 113 if (event.target.classList.contains('remove-row')) { 114 event.target.closest('.calculator-row').remove(); 115 updateCalculator(); 116 } 117 }); 118 updateCalculator(); 119 } 120 if (addRow && appRows) { 121 addRow.addEventListener('click', () => { 122 appRows.appendChild(rowTemplate()); 123 updateCalculator(); 124 }); 125 } 126 127 const consultModal = document.querySelector('#consultation-modal'); 128 if (consultModal && typeof consultModal.showModal === 'function') { 129 const body = consultModal.querySelector('[data-consult-body]'); 130 let bodyDefaultHTML = body.innerHTML; 131 let modalTokenRequest = null; 132 133 function escapeAttr(value) { 134 return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); 135 } 136 137 function hydrateConsultModal(data) { 138 if (!data || !data.token || !data.captcha) return; 139 const safeToken = escapeAttr(data.token); 140 const safeCaptcha = escapeAttr(data.captcha); 141 bodyDefaultHTML = bodyDefaultHTML 142 .replace(/name="public_form_token" value="[^"]*"/, `name="public_form_token" value="${safeToken}"`) 143 .replace(/<span data-captcha-question>.*?<\/span>/, `<span data-captcha-question>${safeCaptcha}</span>`); 144 const tokenInput = body.querySelector('[name="public_form_token"]'); 145 const captchaQuestion = body.querySelector('[data-captcha-question]'); 146 if (tokenInput) tokenInput.value = data.token; 147 if (captchaQuestion) captchaQuestion.textContent = data.captcha; 148 } 149 150 async function ensureConsultModalToken() { 151 const tokenInput = body.querySelector('[name="public_form_token"]'); 152 const captchaQuestion = body.querySelector('[data-captcha-question]'); 153 const hasToken = tokenInput && tokenInput.value; 154 const hasCaptcha = captchaQuestion && captchaQuestion.textContent !== 'loading...'; 155 if (hasToken && hasCaptcha) return; 156 if (!modalTokenRequest) { 157 modalTokenRequest = fetch('book-consultation.php?modal_token=1', { 158 headers: { 'X-Requested-With': 'XMLHttpRequest' }, 159 }).then((response) => { 160 if (!response.ok) throw new Error('Could not prepare the consultation form'); 161 return response.json(); 162 }).then((data) => { 163 hydrateConsultModal(data); 164 return data; 165 }).finally(() => { 166 modalTokenRequest = null; 167 }); 168 } 169 await modalTokenRequest; 170 } 171 172 async function openConsultModal(trigger) { 173 if (consultModal.open) return; 174 body.innerHTML = bodyDefaultHTML; 175 document.documentElement.classList.add('modal-open');
176 try { await ensureConsultModalToken(); } catch (err) {} 177 consultModal.showModal(); 178 consultModal._returnFocus = trigger || document.activeElement; 179 const firstField = consultModal.querySelector('input, textarea, select'); 180 if (firstField) firstField.focus(); 181 } 182 183 function closeConsultModal() { 184 if (!consultModal.open) return; 185 consultModal.close(); 186 } 187 188 consultModal.addEventListener('close', () => { 189 document.documentElement.classList.remove('modal-open'); 190 const returnFocus = consultModal._returnFocus; 191 if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); 192 }); 193 194 // Clicking the backdrop (the click target is the <dialog> itself, not a 195 // descendant) closes the modal, same as pressing Escape (native). 196 consultModal.addEventListener('click', (event) => { 197 if (event.target === consultModal) closeConsultModal(); 198 }); 199 200 consultModal.addEventListener('click', (event) => { 201 if (event.target.closest('[data-consult-close]')) closeConsultModal(); 202 }); 203 204 consultModal.addEventListener('submit', async (event) => { 205 const submitForm = event.target.closest('#consultation-modal-form'); 206 if (!submitForm) return; 207 event.preventDefault(); 208 const errorEl = submitForm.querySelector('[data-consult-error]'); 209 if (errorEl) errorEl.hidden = true; 210 const submitBtn = submitForm.querySelector('button[type="submit"]'); 211 if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Sending...'; } 212 try { 213 const response = await fetch(submitForm.action, { 214 method: 'POST', 215 headers: { 'X-Requested-With': 'XMLHttpRequest' }, 216 body: new FormData(submitForm), 217 }); 218 const data = await response.json(); 219 if (!response.ok || !data.ok) throw new Error(data.error || 'Request failed'); 220 hydrateConsultModal(data); 221 body.innerHTML = '<div class="success-message"><div class="success-icon">OK</div><h2>Consultation request received.</h2><p>We will review your details and confirm a time by email or WhatsApp.</p><button class="btn btn-dark" type="button" data-consult-close>Close</button></div>'; 222 const firstFocusable = body.querySelector('button, a'); 223 if (firstFocusable) firstFocusable.focus(); 224 } catch (err) { 225 const currentError = submitForm.querySelector('[data-consult-error]'); 226 if (currentError) currentError.hidden = false; 227 if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Request a Consultation'; } 228 } 229 }); 230 231 // Any link across the site that points at book-consultation.php opens the 232 // modal instead of navigating away, while staying a real link (works 233 // without JS, opens normally on ctrl/cmd/middle-click or target="_blank"). 234 document.addEventListener('click', (event) => { 235 if (event.defaultPrevented || event.button !== 0) return; 236 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 237 const link = event.target.closest('a[href]'); 238 if (!link || link.target === '_blank') return; 239 let url; 240 try { url = new URL(link.href, window.location.href); } catch (e) { return; } 241 if (url.pathname.replace(/^.*\//, '') !== 'book-consultation.php' || url.hash) return; 242 event.preventDefault(); 243 openConsultModal(link); 244 }); 245 } 246 247 const revealItems = document.querySelectorAll('.reveal-on-scroll, .reveal-child'); 248 if (revealItems.length) { 249 if (!('IntersectionObserver' in window) || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 250 revealItems.forEach((item) => item.classList.add('is-visible')); 251 return; 252 } 253 254 const observer = new IntersectionObserver((entries) => { 255 entries.forEach((entry) => { 256 if (entry.isIntersecting) { 257 entry.target.classList.add('is-visible'); 258 observer.unobserve(entry.target); 259 } 260 }); 261 }, { threshold: 0.14, rootMargin: '0px 0px -8% 0px' }); 262 263 revealItems.forEach((item) => observer.observe(item)); 264 } 265 266 document.querySelectorAll('[data-plan-control]').forEach((control)
266=> { 267 const tabs = Array.from(control.querySelectorAll('[data-plan-tab]')); 268 const select = control.querySelector('[data-plan-select]'); 269 const section = control.closest('.product-capability-section'); 270 if (!section) return; 271 const grid = section.querySelector('[data-capability-grid]'); 272 const context = section.querySelector('[data-plan-context]'); 273 if (!grid || !context) return; 274 const cards = Array.from(grid.querySelectorAll('[data-card-plans]')); 275 const copy = { 276 nologin: 'Free Scan - no login selected. Public Shopify/headless Shopify scanning and single-product CSV move to the front.', 277 login: 'Login Free selected. Account context, search, filter, selected CSV, and currently allowed free sources move to the front.', 278 csv: '$1 CSV Source selected. CSV-capable features and supported source workflows move to the front. Direct import remains muted.', 279 direct: '$3 Direct Import selected. Draft Shopify import, queued bulk import, progress tracking, and assigned-store features move to the front.', 280 none: 'No plan selected. All cards are shown in grayscale until you choose a plan.', 281 }; 282 283 function flipSort(activePlan) { 284 const first = new Map(cards.map((card) => [card, card.getBoundingClientRect()])); 285 const sorted = cards.slice().sort((a, b) => { 286 const aActive = activePlan && a.dataset.cardPlans.split(',').includes(activePlan); 287 const bActive = activePlan && b.dataset.cardPlans.split(',').includes(activePlan); 288 if (aActive !== bActive) return aActive ? -1 : 1; 289 return Number(a.style.getPropertyValue('--card-index') || 0) - Number(b.style.getPropertyValue('--card-index') || 0); 290 }); 291 sorted.forEach((card) => grid.appendChild(card)); 292 sorted.forEach((card) => { 293 const last = card.getBoundingClientRect(); 294 const start = first.get(card); 295 const dx = start.left - last.left; 296 const dy = start.top - last.top; 297 card.animate([ 298 { transform: `translate(${dx}px, ${dy}px) rotate(var(--tilt))` }, 299 { transform: 'translate(0, 0) rotate(var(--tilt))' }, 300 ], { duration: 420, easing: 'cubic-bezier(.2,.7,.3,1)' }); 301 }); 302 } 303 304 function setPlan(plan) { 305 const activePlan = plan || '';
306 tabs.forEach((tab) => tab.classList.toggle('is-active', tab.dataset.planTab === activePlan)); 307 if (select) select.value = activePlan; 308 context.textContent = copy[activePlan || 'none']; 309 cards.forEach((card) => { 310 const isActive = activePlan && card.dataset.cardPlans.split(',').includes(activePlan); 311 card.classList.toggle('is-active', Boolean(isActive)); 312 card.classList.toggle('is-muted', !isActive); 313 }); 314 flipSort(activePlan); 315 } 316 317 tabs.forEach((tab) => tab.addEventListener('click', () => setPlan(tab.dataset.planTab))); 318 if (select) select.addEventListener('change', () => setPlan(select.value)); 319 setPlan(''); 320 }); 321});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.