(function () { // // One lead flow for every form on the site. A form opts in with // data-lead-form="" (and optionally data-industry=""), and must // contain an input named "website" inside a . Step 1 is that one field: // a website, or the business's name and city. Submitting it opens a small dialog // that asks for a name, an email and/or a phone number, and the type of business. // // The dialog's form is a plain form the platform sends on its own: on a published // custom page every without an action posts its fields to the website's CRM // (rendererv2 CustomPage/customHtmlForms.ts), which saves it, emails the owner and // upserts the GHL contact. This script only validates, fills the hidden fields and // shows the result. Outside the platform (a local preview) it logs instead of sending. // // Fields sent: name, email, phone, preferred_contact (WhatsApp or Email), services_required, business (and business_name), // existing_website (and website), city, country, industry, phone_update_consent, plus what the // platform adds: form_name (the step-1 form's id, e.g. "seo-hero"), page_url, // referrer, utm_*. const LEADS = { whatsapp: '16507580199', // WhatsApp Business number, digits only with country code, e.g. '15551234567' }; (function initLeadForms() { const leadRoot = document.getElementById('sbm'); if (!leadRoot || leadRoot.dataset.leadReady) return; leadRoot.dataset.leadReady = '1'; const waLink = LEADS.whatsapp ? `https://wa.me/${LEADS.whatsapp}` : ''; document.querySelectorAll('[data-wa-link]').forEach(a => { if (waLink) a.href = waLink; else (a.closest('[data-wa-wrap]') || a).hidden = true; }); // What the lead asked for, in the words of the WhatsApp message they send us. const NEEDS = { 'SEO + ChatGPT visibility': 'improving my Google and ChatGPT visibility', 'A new website': 'getting a new website', 'Website + SEO + ChatGPT visibility': 'a new website and improving my Google and ChatGPT visibility', 'Online store': 'setting up my online store', }; const waMessage = (name, business, need) => `Hi Storebox, I'm ${name.trim().split(/\s+/)[0]}${business ? ` from ${business}` : ''}. I just submitted the form and would like to discuss ${NEEDS[need] || NEEDS['SEO + ChatGPT visibility']}.`; // Phones follow the wa.me link into the WhatsApp app. A laptop opens the WhatsApp desktop app (whatsapp://, which // keeps the typed message), and WhatsApp Web when that app is not installed (the page never lost focus). const openWhatsApp = (e, number, text) => { const ua = navigator.userAgent; if (/Android|iPhone|iPad|iPod|Mobile/i.test(ua) || (navigator.maxTouchPoints > 1 && /Mac/.test(navigator.platform))) return; e.preventDefault(); const q = `phone=${number}&text=${encodeURIComponent(text)}`; if (/Firefox\//.test(ua)) { window.open(`https://web.whatsapp.com/send?${q}`, '_blank', 'noopener'); return; } let away = false; const mark = () => { away = true; }; window.addEventListener('blur', mark, { once: true }); document.addEventListener('visibilitychange', mark, { once: true }); location.href = `whatsapp://send?${q}`; setTimeout(() => { if (!away && !document.hidden) location.href = `https://web.whatsapp.com/send?${q}`; }, 1600); }; const isEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v); const phoneDigits = v => v.replace(/[^\d]/g, ''); const isPhone = v => /^[+\d][\d\s().-]{6,}$/.test(v) && phoneDigits(v).length >= 7 && phoneDigits(v).length <= 15; // A link, an @handle, or a business name and city: anything we can start from. const isSite = v => v.trim().length >= 3; const isLink = v => /^(https?:\/\/)?(?:www\.)?[^\s.]+\.[^\s]{2,}/i.test(v.trim()); const esc = v => v.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const TYPES = ['Restaurant, café or food', 'Local service or trade', 'Shop or online store', 'Professional services or clinic', 'Other']; let current = null; // { form, business } that opened the dialog let ui = null; // The dialog is built on the first submit, not at load. The platform renders this page's HTML through // React, and a node added to its container before hydration finishes makes React throw the tree away // and render it again (minified error #418). A submit can only happen after hydration, and the // platform's own form handler is delegated on the container, so a dialog added later is still sent. function ensureDialog() { if (ui) return ui; // The dialog lives outside every page's scoped styles, so it carries its own. const css = document.createElement('style'); css.textContent = ` .lfd { margin: auto; border: 0; padding: 0; border-radius: 24px; width: min(620px, calc(100vw - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); overflow: auto; overscroll-behavior: contain; background: #fff; color: #0f172a; box-shadow: 0 40px 100px rgba(15,23,42,.35); font: 400 15px/1.5 Inter, system-ui, sans-serif; } .lfd::backdrop { background: rgba(11,18,36,.55); backdrop-filter: blur(4px); } html:has(dialog.lfd[open]) { overflow: hidden !important; scrollbar-gutter: stable; } .lfd form { padding: 26px; display: grid; gap: 12px; } .lfd .lfd-x { position: absolute; top: 14px; right: 14px; width: 34px; height: 34px; border: 0; border-radius: 50%; background: #f4f6f9; color: #64748b; font-size: 18px; cursor: pointer; } .lfd h3 { margin: 0 34px 0 0; font: 700 22px/1.2 "Plus Jakarta Sans", Inter, sans-serif; letter-spacing: -.02em; } .lfd h3 em { font-style: normal; color: #d4471b; } .lfd p { margin: 0; color: #64748b; font-size: 14.5px; } .lfd label { display: grid; gap: 5px; font-size: 13px; font-weight: 600; color: #334155; } .lfd input, .lfd select, .lfd textarea { width: 100%; box-sizing: border-box; padding: 12px 14px; border: 1px solid #dfe3ea; border-radius: 12px; font: inherit; font-size: 16px; color: #0f172a; background: #fff; } .lfd input:focus, .lfd select:focus, .lfd textarea:focus { outline: 0; border-color: #ff6a3d; box-shadow: 0 0 0 4px rgba(255,106,61,.15); } .lfd textarea { resize: vertical; min-height: 118px; } .lfd .lfd-optional { color:#64748b; font-weight:400; } .lfd .lfd-fine a { color:#d4471b; font-weight:600; text-decoration:underline; text-underline-offset:2px; } .lfd [hidden] { display:none !important; } .lfd .lfd-consent { display:flex;align-items:flex-start;gap:10px;font-weight:400; } .lfd .lfd-consent input { width:18px;height:18px;flex:none;margin-top:3px; } .lfd .lfd-two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .lfd .lfd-go { margin-top: 4px; padding: 15px; border: 0; border-radius: 999px; background: #16161a; color: #fff; font: 600 16px Inter, sans-serif; cursor: pointer; } .lfd .lfd-go:disabled { opacity: .7; cursor: default; } .lfd .lfd-err { min-height: 1em; color: #d4471b; font-size: 13.5px; } .lfd .lfd-fine { font-size: 12.5px; text-align: center; } .lfd .lfd-done { padding: 34px 26px; text-align: center; display: grid; gap: 10px; } .lfd .lfd-done b { font: 700 22px/1.2 "Plus Jakarta Sans", Inter, sans-serif; } .lfd .lfd-done a { color: #d4471b; font-weight: 600; } .lfd .lfd-pref { border:0; margin:0; padding:0; display:flex; flex-wrap:wrap; gap:6px 20px; font-size:13px; font-weight:600; color:#334155; } .lfd .lfd-pref legend { padding:0; margin-bottom:6px; width:100%; } .lfd .lfd-pref label { display:flex; align-items:center; gap:8px; font-weight:500; font-size:15px; } .lfd .lfd-optin { margin: -6px 0 0; font-size: 12.5px; color: #64748b; } .lfd .lfd-pref input { width:18px; height:18px; margin:0; padding:0; flex:none; accent-color:#d4471b; } .lfd .lfd-done { padding-top: 54px; } .lfd .lfd-done .lfd-wa { justify-self:center; display:inline-flex; align-items:center; gap:8px; margin-top:6px; padding:13px 24px; border-radius:999px; background:#25D366; color:#fff; text-decoration:none; font-weight:700; } @media (max-width: 480px) { .lfd [hidden] { display:none !important; } .lfd .lfd-consent { display:flex;align-items:flex-start;gap:10px;font-weight:400; } .lfd .lfd-consent input { width:18px;height:18px;flex:none;margin-top:3px; } .lfd .lfd-two { grid-template-columns: 1fr; gap: 10px; } .lfd form { padding: 20px; gap: 10px; } .lfd h3 { font-size: 20px; } .lfd input, .lfd select, .lfd textarea { padding: 10px 12px; } } `; document.head.appendChild(css); const dlg = document.createElement('dialog'); dlg.className = 'lfd'; dlg.innerHTML = ` × Get your free website draft + growth proposal Tell us about your business. We reply within 24 hours. Your name Business name Website (optional) Email Phone / WhatsApp City CountryChoose countryIndiaUnited StatesUnited KingdomUnited Arab EmiratesCanadaAustraliaSingaporeAfghanistanAlbaniaAlgeriaAmerican SamoaAndorraAngolaAnguillaAntarcticaAntigua and BarbudaArgentinaArmeniaArubaAustriaAzerbaijanBahamasBahrainBangladeshBarbadosBelarusBelgiumBelizeBeninBermudaBhutanBolivia, Plurinational State ofBonaire, Sint Eustatius and SabaBosnia and HerzegovinaBotswanaBouvet IslandBrazilBritish Indian Ocean TerritoryBrunei DarussalamBulgariaBurkina FasoBurundiCabo VerdeCambodiaCameroonCayman IslandsCentral African RepublicChadChileChinaChristmas IslandCocos (Keeling) IslandsColombiaComorosCongoCongo, The Democratic Republic of theCook IslandsCosta RicaCroatiaCubaCuraçaoCyprusCzechiaCôte d'IvoireDenmarkDjiboutiDominicaDominican RepublicEcuadorEgyptEl SalvadorEquatorial GuineaEritreaEstoniaEswatiniEthiopiaFalkland Islands (Malvinas)Faroe IslandsFijiFinlandFranceFrench GuianaFrench PolynesiaFrench Southern TerritoriesGabonGambiaGeorgiaGermanyGhanaGibraltarGreeceGreenlandGrenadaGuadeloupeGuamGuatemalaGuernseyGuineaGuinea-BissauGuyanaHaitiHeard Island and McDonald IslandsHoly See (Vatican City State)HondurasHong KongHungaryIcelandIndonesiaIran, Islamic Republic ofIraqIrelandIsle of ManIsraelItalyJamaicaJapanJerseyJordanKazakhstanKenyaKiribatiKorea, Democratic People's Republic ofKorea, Republic ofKuwaitKyrgyzstanLao People's Democratic RepublicLatviaLebanonLesothoLiberiaLibyaLiechtensteinLithuaniaLuxembourgMacaoMadagascarMalawiMalaysiaMaldivesMaliMaltaMarshall IslandsMartiniqueMauritaniaMauritiusMayotteMexicoMicronesia, Federated States ofMoldova, Republic ofMonacoMongoliaMontenegroMontserratMoroccoMozambiqueMyanmarNamibiaNauruNepalNetherlandsNew CaledoniaNew ZealandNicaraguaNigerNigeriaNiueNorfolk IslandNorth MacedoniaNorthern Mariana IslandsNorwayOmanPakistanPalauPalestine, State ofPanamaPapua New GuineaParaguayPeruPhilippinesPitcairnPolandPortugalPuerto RicoQatarRomaniaRussian FederationRwandaRéunionSaint BarthélemySaint Helena, Ascension and Tristan da CunhaSaint Kitts and NevisSaint LuciaSaint Martin (French part)Saint Pierre and MiquelonSaint Vincent and the GrenadinesSamoaSan MarinoSao Tome and PrincipeSaudi ArabiaSenegalSerbiaSeychellesSierra LeoneSint Maarten (Dutch part)SlovakiaSloveniaSolomon IslandsSomaliaSouth AfricaSouth Georgia and the South Sandwich IslandsSouth SudanSpainSri LankaSudanSurinameSvalbard and Jan MayenSwedenSwitzerlandSyrian Arab RepublicTaiwan, Province of ChinaTajikistanTanzania, United Republic ofThailandTimor-LesteTogoTokelauTongaTrinidad and TobagoTunisiaTurkmenistanTurks and Caicos IslandsTuvaluTürkiyeUgandaUkraineUnited States Minor Outlying IslandsUruguayUzbekistanVanuatuVenezuela, Bolivarian Republic ofViet NamVirgin Islands, BritishVirgin Islands, U.S.Wallis and FutunaWestern SaharaYemenZambiaZimbabweÅland Islands What do you need?Choose oneSEO + ChatGPT visibilityA new websiteWebsite + SEO + ChatGPT visibilityOnline store How should we contact you?WhatsAppEmail With WhatsApp, Storebox may message you on WhatsApp about this request. Get my free draft + proposal → No spam, free proposal. Privacy Policy `; // Inside the platform's page element, so its form is sent like any other on the page. const host = document.querySelector('form[data-lead-form]')?.closest('[data-sb-forms]') || document.querySelector('[data-sb-forms]'); (host || document.body).appendChild(dlg); const f = dlg.querySelector('form'), err = dlg.querySelector('.lfd-err'), go = dlg.querySelector('.lfd-go'), done = dlg.querySelector('.lfd-done'); dlg.querySelector('.lfd-x').addEventListener('click', () => dlg.close()); dlg.addEventListener('click', e => { if (e.target === dlg) dlg.close(); }); const finish = ok => { go.disabled = false; go.textContent = 'Get my free draft + proposal →'; if (!ok) { err.textContent = 'Something went wrong sending that. Please try again.'; return; } f.hidden = true; done.hidden = false; // The WhatsApp chat starts from the phone the lead just gave, so it threads to the same GHL contact. const text = waMessage(f.elements.name.value, f.elements.business.value.trim(), f.elements.services_required.value); done.innerHTML = `Thanks, ${esc(f.elements.name.value.trim().split(/\s+/)[0])}!We've received your information.` + (waLink ? `Click below to continue the conversation with our team on WhatsApp.Chat with us on WhatsApp` : ''); const wa = done.querySelector('a.lfd-wa'); if (wa) wa.addEventListener('click', e => openWhatsApp(e, LEADS.whatsapp, text)); document.querySelectorAll('form[data-lead-form]').forEach(x => x.classList.add('lf-sent')); document.querySelectorAll('.lf-note').forEach(n => { n.textContent = 'Thanks! Your free draft and proposal are on their way within 24 hours.'; }); }; // The platform reports back with these; cancelling them keeps its default thank-you out. f.addEventListener('sb-form:success', e => { e.preventDefault(); finish(true); }); f.addEventListener('sb-form:error', e => { e.preventDefault(); finish(false); }); f.addEventListener('submit', e => { const v = n => f.elements[n].value.trim(); // Cancelling the submit also tells the platform not to send it. const say = (msg, field) => { e.preventDefault(); err.textContent = msg; if (field) f.elements[field].focus(); }; if (!v('name')) return say('Add your name.', 'name'); if (!v('business')) return say('Add your business name.', 'business'); if (v('existing_website') && !isLink(v('existing_website'))) return say('Enter a valid website link.', 'existing_website'); if (!v('city')) return say('Add your city.', 'city'); if (!v('country')) return say('Choose your country.', 'country'); if (v('email') && !isEmail(v('email'))) return say("That email address doesn't look right.", 'email'); if (v('phone') && !isPhone(v('phone'))) return say('Enter the phone number with its country code, e.g. +1 305 555 0142.', 'phone'); const pref = f.elements.preferred_contact.value; if (pref === 'WhatsApp' && !v('phone')) return say('Add your WhatsApp number, or choose Email.', 'phone'); if (pref === 'Email' && !v('email')) return say('Add your email, or choose WhatsApp.', 'email'); if (!v('services_required')) return say('Choose what you need.', 'services_required'); err.textContent = ''; if (v('phone')) f.elements.phone.value = '+' + phoneDigits(v('phone')); // GHL's own Company and Website fields read business_name / website; the agent reads business / existing_website. f.elements.business_name.value = v('business'); f.elements.website.value = v('existing_website'); f.elements.phone_update_consent.value = pref === 'WhatsApp' && v('phone') ? 'yes' : ''; f.elements.industry.value = current.form.dataset.industry || ''; f.setAttribute('name', current.form.dataset.leadForm); go.disabled = true; go.textContent = 'Sending…'; if (!host) { e.preventDefault(); go.disabled = false; go.textContent = 'Get my free draft + proposal →'; err.textContent = 'Preview only: nothing was submitted.'; } }); ui = { dlg, f, err, go, done }; return ui; } // Any element with data-lead-open opens the same dialog, with the business field empty. document.addEventListener('click', e => { const b = e.target.closest('[data-lead-open]'); if (!b) return; e.preventDefault(); current = { form: { dataset: { leadForm: b.dataset.leadOpen || 'cta' } }, business: '' }; const { dlg, f, err, done } = ensureDialog(); f.reset(); f.hidden = false; done.hidden = true; err.textContent = ''; dlg.showModal(); f.elements.name.focus(); }); // Step 1 on every form: one field, then the dialog. document.querySelectorAll('form[data-lead-form]').forEach(form => { if (form.dataset.leadInit) return; // this script can run twice on the platform; wire each form once form.dataset.leadInit = '1'; const site = form.querySelector('input[name="website"]'); const note = document.createElement('p'); note.className = 'lf-note'; note.setAttribute('role', 'status'); form.after(note); form.noValidate = true; form.addEventListener('submit', e => { e.preventDefault(); if (!isSite(site.value)) { note.textContent = 'Share your website, or your business name and city.'; note.classList.add('bad'); site.focus(); return; } note.textContent = ''; note.classList.remove('bad'); current = { form, business: site.value.trim() }; const { dlg, f, err, done } = ensureDialog(); f.reset(); const seed = current.business; if (/^(https?:\/\/|www\.)/i.test(seed) || /^[^\s]+\.[a-z]{2,}/i.test(seed)) f.elements.existing_website.value = seed; else f.elements.business.value = seed; f.hidden = false; done.hidden = true; err.textContent = ''; dlg.showModal(); f.elements.name.focus(); }); site.addEventListener('input', () => { note.textContent = ''; note.classList.remove('bad'); }); }); })(); // })(); ; (function () { /* Scroll runtime: each [data-scene] gets its progress written to --p (0 → 1) and CSS does the rest. Pinned scenes are [data-pin=""] (data-pin-m on phones): a tall track around a sticky stage. Only on-screen scenes are measured, in one rAF loop; native scrolling is never hijacked. ?tier=static or prefers-reduced-motion shows every scene in its final, readable state. */ (function(){ const root = document.getElementById('sbm'); // The platform runs this script once while the HTML parses and again after it mounts. Whichever run // sees this element first sets it up; the flag lives on the element, so a re-rendered copy starts fresh. if (!root || root.dataset.ready) return; root.dataset.ready = '1'; const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v)); const constrained = (navigator.deviceMemory && navigator.deviceMemory <= 4) || (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4) || navigator.connection?.saveData; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || matchMedia('(max-width: 759px)').matches || constrained || new URLSearchParams(location.search).get('tier') === 'static'; root.dataset.tier = reduce ? 'static' : 'full'; root.classList.remove('no-js', 'booting'); root.classList.add('js'); const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const scenes = $$('[data-scene]'); const listeners = new Map(); const on = (el, fn) => { if (reduce) { fn(1); return; } (listeners.get(el) || listeners.set(el, []).get(el)).push(fn); }; function layout() { const phone = innerWidth < 760; scenes.forEach(el => { const steps = reduce ? 0 : parseFloat((phone && el.dataset.pinM !== undefined ? el.dataset.pinM : el.dataset.pin) || 0); if (steps > 0) { el.style.height = (steps + 1) * 100 + 'svh'; el.classList.add('is-pinned'); } else { el.style.height = ''; el.classList.remove('is-pinned'); } }); } layout(); scenes.forEach(el => el.style.setProperty('--p', reduce ? '1' : '0')); /* scroll-lit words: every word and logo in a [data-lit] block gets an index; CSS lights them from --p */ $$('[data-lit]').forEach(box => { let i = 0; (function walk(node, hl) { [...node.childNodes].forEach(n => { if (n.nodeType === 3) { const frag = document.createDocumentFragment(); n.textContent.split(/(\s+)/).forEach(t => { if (!t) return; if (/^\s+$/.test(t)) { frag.appendChild(document.createTextNode(t)); return; } const w = document.createElement('span'); w.className = 'w' + (hl ? ' hl' : ''); w.style.setProperty('--i', i++); w.textContent = t; frag.appendChild(w); }); n.replaceWith(frag); } else if (n.nodeType === 1) { if (n.classList.contains('ic')) n.style.setProperty('--i', i++); else walk(n, hl || n.classList.contains('last')); } }); })(box, false); box.closest('[data-scene]').style.setProperty('--n', i); }); if (!reduce) { const active = new Set(); const io = new IntersectionObserver(es => { es.forEach(e => e.isIntersecting ? active.add(e.target) : active.delete(e.target)); kick(); }, { rootMargin: '20% 0px 20% 0px' }); scenes.forEach(el => io.observe(el)); const progressOf = el => { const r = el.getBoundingClientRect(), vh = innerHeight; if (el.classList.contains('is-pinned')) return clamp(-r.top / Math.max(1, r.height - vh)); return clamp((vh - r.top) / (vh + r.height)); }; let ticking = false; function frame() { ticking = false; active.forEach(el => { const p = progressOf(el); if (el._p === undefined || Math.abs(el._p - p) > 0.0004) { el._p = p; el.style.setProperty('--p', p.toFixed(4)); (listeners.get(el) || []).forEach(f => f(p)); } }); } function kick() { if (!ticking) { ticking = true; requestAnimationFrame(frame); } } addEventListener('scroll', kick, { passive: true }); addEventListener('resize', () => { layout(); kick(); }); kick(); } /* one-shot reveals and count-ups */ const rio = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); rio.unobserve(e.target); } }), { threshold: .15 }); $$('[data-reveal]').forEach(e => reduce ? e.classList.add('in') : rio.observe(e)); const ease = t => (t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); $$('[data-count]').forEach(el => { const to = parseFloat(el.dataset.count), pre = el.dataset.pre || '', suf = el.dataset.suf || ''; if (reduce) { el.textContent = pre + to.toLocaleString('en-US') + suf; return; } el.textContent = pre + '0' + suf; new IntersectionObserver((es, o) => es.forEach(e => { if (!e.isIntersecting) return; o.disconnect(); const t0 = performance.now(); let lastPaint = 0, lastValue = -1; (function tick(now) { const k = ease(clamp((now - t0) / 1500)), value = Math.round(to * k); // Text changes trigger layout. Keep the count-up motion, but cap DOM // writes at 30fps so it cannot compete with touch scrolling on phones. if (k >= 1 || (now - lastPaint >= 32 && value !== lastValue)) { lastPaint = now; lastValue = value; el.textContent = pre + value.toLocaleString('en-US') + suf; } if (k < 1) requestAnimationFrame(tick); })(t0); }), { threshold: .6 }).observe(el); }); /* ring: hold each card at the front for a beat, then turn */ const ringScene = $('.ring-scene'), names = ['Website & hosting', 'SEO on Google', 'AI SEO', 'Readable by Meta Muse', 'Google Business Profile', 'Chat & WhatsApp', 'CRM & follow-ups', 'Reviews & referrals', 'Outbound email']; const N = names.length, rcs = $$('.rc'); let lastIdx = -1, target = 0, shown = 0, raf = 0; /* The rotation eases toward the scroll position instead of snapping to it, so a fast swipe still turns through every card in order. */ function showRing(r) { ringScene.style.setProperty('--r', r.toFixed(4)); const idx = Math.round(r); if (idx !== lastIdx) { lastIdx = idx; rcs.forEach((a, i) => a.classList.toggle('on', i === idx)); } } let lastRingTime = 0; function ringTick(now) { const dt = lastRingTime ? Math.min(50, now - lastRingTime) : 16.67; lastRingTime = now; raf = 0; const d = target - shown; if (Math.abs(d) < .003) shown = target; else { shown += d * (1 - Math.pow(.86, dt / 16.67)); raf = requestAnimationFrame(ringTick); } showRing(shown); } function setRing(p) { const x = clamp(p * (N - 1 + .3) - .15, 0, N - 1), k = Math.min(N - 2, Math.floor(x)), f = x - k, hold = .26; const g = ease(clamp((f - hold) / (1 - hold * 2))); target = x >= N - 1 ? N - 1 : k + g; if (!raf) { lastRingTime = 0; raf = requestAnimationFrame(ringTick); } } if (!reduce) { on(ringScene, setRing); const rect = ringScene.getBoundingClientRect(); setRing(clamp(-rect.top / Math.max(1, rect.height - innerHeight))); shown = target; showRing(shown); } else { rcs.forEach(a => a.classList.add('on')); } // Reserve the original layout, but do not paint half-initialized 3D cards or fallback fonts. const revealRing = () => requestAnimationFrame(() => requestAnimationFrame(() => { if (!ringScene.isConnected) return; if (!reduce) { const rect = ringScene.getBoundingClientRect(); setRing(clamp(-rect.top / Math.max(1, rect.height - innerHeight))); shown = target; showRing(shown); } ringScene.dataset.renderReady = '1'; })); if (document.fonts) { Promise.allSettled([ document.fonts.load('400 36px "Instrument Serif"'), document.fonts.load('400 16px Inter'), document.fonts.load('600 16px Inter') ]).then(revealRing); } else revealRing(); /* FAQ: the first few answers show; the button reveals the rest */ const faqBtn = $('[data-faq-toggle]'), faqMore = $('[data-faq-more]'); if (faqBtn && faqMore) faqBtn.addEventListener('click', () => { const open = faqMore.hidden; faqMore.hidden = !open; faqBtn.setAttribute('aria-expanded', String(open)); faqBtn.textContent = open ? 'Show fewer' : 'Read all FAQs'; }); /* how it works: the timeline fills as it scrolls into view */ const tlTrack = $('#tlTrack'), tlFill = $('#tlFill'), tlStops = $$('.tl-stop'); function howFrame() { if (!tlTrack) return; const r = tlTrack.getBoundingClientRect(); const p = reduce ? 1 : clamp((innerHeight * .8 - r.top) / (r.height + innerHeight * .2)); if (innerWidth < 820 || tlTrack.classList.contains('v')) { tlTrack.style.setProperty('--fill', p * 100 + '%'); tlFill.style.width = ''; } else tlFill.style.width = p * 100 + '%'; tlStops.forEach((st, i) => st.classList.toggle('on', p >= i / (tlStops.length - 1) - .02)); } // Static/mobile mode is already fully filled. Do that once instead of // forcing layout on every touch-scroll event for an off-screen timeline. if (reduce) howFrame(); else { addEventListener('scroll', howFrame, { passive: true }); addEventListener('resize', howFrame); howFrame(); } /* ?review=1 shows the yellow tags marking copy that still needs a real name, photo or permission */ if (new URLSearchParams(location.search).get('review')) root.classList.add('review'); /* customer cards: arrows scroll the track one card at a time */ const vt = $('[data-vtrack]'); if (vt) { const step = () => (vt.querySelector('.vc').offsetWidth + 18) * (innerWidth < 760 ? 1 : 1.5); $('[data-vprev]').addEventListener('click', () => vt.scrollBy({ left: -step(), behavior: 'smooth' })); $('[data-vnext]').addEventListener('click', () => vt.scrollBy({ left: step(), behavior: 'smooth' })); } /* header: solid once the hero starts moving, hides while scrolling down, returns on the way up */ const head = $('#head'); let lastY = scrollY, headFrame = 0, headSolid = null, headHidden = null; function onScroll() { if (headFrame) return; headFrame = requestAnimationFrame(() => { headFrame = 0; const y = scrollY; const solid = y > 40; let hidden = headHidden === null ? false : headHidden; if (y > innerHeight * .6 && y > lastY + 4) hidden = true; if (y < lastY - 4) hidden = false; if (head && solid !== headSolid) { headSolid = solid; head.classList.toggle('solid', solid); } if (hidden !== headHidden) { headHidden = hidden; root.classList.toggle('head-hidden', hidden); } lastY = y; }); } addEventListener('scroll', onScroll, { passive: true }); onScroll(); /* The page demos itself on the visitor's business: [data-tpl] elements hold a template whose {tokens} come from the chosen kind of business and the name typed in the hero. Typing updates every mock live; until the visitor interacts, the hero cycles through example businesses. */ const VERTS = { plumber: { biz: 'Lone Star Plumbing', noun: 'plumber', tag: '24/7 emergency', c: ['Pipe & Drain Co.', 'Capital Plumbing', 'Metro Plumbing', 'Alamo Rooter'], inq: 'Water heater is leaking. Can someone come today?', rep: 'Sorry about that! We have a plumber free tomorrow at 8am or 10am. Which works?' }, dentist: { biz: 'Bright Smile Dental', noun: 'dentist', tag: 'Gentle family care', c: ['City Dental Group', 'Oak Street Dentistry', 'Pearl Dental', 'Sunrise Dental'], inq: 'My tooth is killing me. Any openings today?', rep: "So sorry! We have a slot today at 4pm or tomorrow at 9am. Which works?" }, restaurant: { biz: 'Casa Verde Kitchen', noun: 'restaurant', tag: 'Open late', c: ['Harbor Grill', 'The Corner Table', 'Olive & Fire', 'Bistro 22'], inq: 'Table for 6 on Saturday around 7?', rep: 'Yes! We have 7:00 or 7:30 for six. Want me to hold one?' }, salon: { biz: 'Studio Luna Salon', noun: 'hair salon', tag: 'Walk-ins welcome', c: ['Glow Hair Studio', 'The Style Room', 'Velvet Salon', 'Salon Noir'], inq: 'Any balayage openings this weekend?', rep: 'Yes! Saturday at 10am or 2pm. Which works?' }, lawyer: { biz: 'Reyes Family Law', noun: 'family lawyer', tag: 'Free first consult', c: ['Harper & Cole LLP', 'Lakeside Legal', 'Union Law Group', 'Brooks Family Law'], inq: 'Can someone call me about a custody case?', rep: 'Of course. We have a free consult tomorrow at 10am or 1pm. Which works?' }, travel: { biz: 'Sunset Travel Co.', noun: 'travel agent', tag: 'Honeymoon specialists', c: ['Global Getaways', 'Roma Tours Direct', 'Wanderly Travel', 'Jetset Trips'], inq: 'Planning Italy for our honeymoon in June. Can you help?', rep: 'Congratulations! Want a 20-minute call tomorrow at 11am or 2pm?' }, }; const vertKeys = Object.keys(VERTS); const ctx = { vert: 'plumber', typed: '', city: 'Miami' }; const slug = s => s.toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '').slice(0, 24) || 'yourbusiness'; function tokens() { const v = VERTS[ctx.vert], typed = ctx.typed; const biz = typed || v.biz, city = ctx.city; return { biz, city, cityl: city.toLowerCase(), noun: v.noun, Noun: v.noun.replace(/^./, c => c.toUpperCase()), tag: v.tag, inq: v.inq, rep: v.rep, c1: v.c[0], c2: v.c[1], c3: v.c[2], c4: v.c[3] || 'Local Pros', c1slug: slug(v.c[0]), c2slug: slug(v.c[1]), c3slug: slug(v.c[2]), c4slug: slug(v.c[3] || 'Local Pros'), slug: slug(biz), rank: 14, q: 'best ' + v.noun + ' in ' + city.toLowerCase(), }; } const fill = (el, t) => { const tpl = el.dataset.tpl; const out = tpl.replace(/\{(\w+)\}/g, (m, k) => (k in t ? t[k] : m)); if (el.textContent !== out) { el.textContent = out; if (!reduce) { el.classList.remove('tick'); void el.offsetWidth; el.classList.add('tick'); } } }; const renderAll = () => { const t = tokens(); $$('[data-tpl]').forEach(el => fill(el, t)); }; function setVert(k) { ctx.vert = k; renderAll(); } // The free-draft form feeds the same tokens, so a business typed there shows in every mock. $$('form[data-lead-form] input[name="website"]').forEach(inp => inp.addEventListener('input', () => { ctx.typed = inp.value.trim().slice(0, 36); renderAll(); })); renderAll(); /* industries: hover opens a tile; a click shows that kind of business in the carousel */ const indTiles = $$('[data-ind]'); indTiles.forEach(t => { t.addEventListener('mouseenter', () => { indTiles.forEach(x => x.classList.toggle('on', x === t)); }); t.addEventListener('focus', () => { indTiles.forEach(x => x.classList.toggle('on', x === t)); }); t.addEventListener('click', () => { const any = t.dataset.ind === 'any'; if (!any) setVert(t.dataset.ind); const c = $(any ? '#free' : '#services'); (c || $('#free')).scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' }); }); }); })(); })(); ; (function(){ const root=document.getElementById('sbm'),section=root?.querySelector('#services'); if(!section||section.dataset.mobileScrollReady)return; section.dataset.mobileScrollReady='1'; const stage=section.querySelector('.scene-stage'),copy=section.querySelector('.ring-copy'); const row=copy.querySelector('.rcs'),cards=[...row.querySelectorAll('.rc')]; // Keep the existing offer beneath the pinned cards, not below an empty // scroll track. Move it back for desktop and non-pinned fallbacks. const price=root.querySelector('#price'),priceHome=document.createComment('pricing position'); if(price)price.before(priceHome); const query=matchMedia('(max-width:759px) and (min-height:600px) and (prefers-reduced-motion:no-preference)'); let enabled=false,index=-1,frame=0,measureFrame=0,sectionTop=0,stageTop=0,travel=1; const controller=new AbortController(); function setCard(next){ if(next===index)return; if(index>=0){const old=cards[index];old.classList.remove('on');old.setAttribute('aria-hidden','true');old.inert=true;} const card=cards[next];card.classList.add('on');card.setAttribute('aria-hidden','false');card.inert=false; index=next; copy.dataset.progress=String(index+1).padStart(2,'0')+' / '+String(cards.length).padStart(2,'0'); } function update(){ frame=0; if(!root.isConnected){controller.abort();return;} if(!enabled)return; // Geometry is cached outside the scroll path. This frame now reads only // scrollY and writes only when the visible card actually changes. const progress=Math.max(0,Math.min(1,(scrollY+stageTop-sectionTop)/travel)); const next=Math.min(cards.length-1,Math.floor(progress*cards.length)); setCard(next); } function schedule(){if(!frame)frame=requestAnimationFrame(update);} function measure(){ measureFrame=0; if(!enabled||!root.isConnected)return; const r=section.getBoundingClientRect(); sectionTop=r.top+scrollY; stageTop=parseFloat(getComputedStyle(stage).top)||0; travel=Math.max(1,section.offsetHeight-stage.offsetHeight); update(); } function scheduleMeasure(){if(!measureFrame)measureFrame=requestAnimationFrame(measure);} function configure(){ enabled=query.matches;index=-1; section.classList.toggle('mobile-scroll',enabled); if(enabled){ if(price)stage.append(price);row.setAttribute('aria-live','off');row.scrollLeft=0; cards.forEach(card=>{card.classList.remove('on');card.setAttribute('aria-hidden','true');card.inert=true;}); scheduleMeasure(); } else{if(price)priceHome.after(price);row.setAttribute('aria-live','polite');cards.forEach(card=>{card.removeAttribute('aria-hidden');card.inert=false;if(innerWidth<760)card.classList.add('on');});} } addEventListener('scroll',schedule,{passive:true,signal:controller.signal}); addEventListener('resize',scheduleMeasure,{passive:true,signal:controller.signal}); if(window.visualViewport)visualViewport.addEventListener('resize',scheduleMeasure,{passive:true,signal:controller.signal}); query.addEventListener('change',configure,{signal:controller.signal}); if(document.fonts)document.fonts.ready.then(scheduleMeasure); configure(); })(); ; (function(){ const root=document.getElementById('sbm'); if(!root||root.dataset.mobileMotionReady||!('IntersectionObserver' in window)||!Element.prototype.animate)return; root.dataset.mobileMotionReady='1'; const query=matchMedia('(max-width:759px) and (prefers-reduced-motion:no-preference)'); // Enhance the ordinary sections only: hero and pinned services have their // own motion, and the pricing offer must stay painted during the pin. const targets=[...root.querySelectorAll('[data-reveal],#proof .dp-card,#proof .race,#industries .ind-t,#voices .vc')] .filter(el=>!el.closest('.hero,#services,#price')); const seen=new WeakSet(),running=new Set(); const observer=new IntersectionObserver(entries=>{ if(!root.isConnected){observer.disconnect();running.forEach(a=>a.cancel());query.removeEventListener('change',configure);return;} entries.forEach(entry=>{ if(!query.matches||!entry.isIntersecting||seen.has(entry.target))return; const el=entry.target; seen.add(el);observer.unobserve(el); // One short compositor-friendly entrance, no scroll loop or hidden state. const animation=el.animate([ {opacity:.6,transform:'translate3d(0,16px,0)'}, {opacity:1,transform:'translate3d(0,0,0)'} ],{duration:520,easing:'cubic-bezier(.22,1,.36,1)',fill:'none'}); running.add(animation); animation.onfinish=animation.oncancel=()=>{running.delete(animation);}; if(el.matches('.ticket')){ const plane=el.querySelector('.tk-route .line i'); const distance=Math.max(0,(plane.parentElement.clientWidth-plane.offsetWidth)/2); const flight=plane.animate([ {transform:'translateX(0)'}, {transform:`translateX(${-distance}px)`,offset:.18}, {transform:`translateX(${distance}px)`,offset:.72}, {transform:'translateX(0)'} ],{duration:2600,delay:200,easing:'ease-in-out',iterations:2,fill:'none'}); running.add(flight); flight.onfinish=flight.oncancel=()=>{running.delete(flight);}; } }); },{threshold:.08,rootMargin:'0px 0px -5% 0px'}); function configure(){ observer.disconnect(); running.forEach(a=>a.cancel());running.clear(); if(query.matches)targets.forEach(el=>{if(!seen.has(el))observer.observe(el);}); } query.addEventListener('change',configure); configure(); })(); ; (function(){ var pictures=document.querySelectorAll('#sbm .dfr-ph[data-bg]'); var motion=document.querySelectorAll('#sbm .hero,#sbm .pf-card,#sbm .ticket'); function load(el){ if(el.classList.contains('bg-ready'))return; el.style.backgroundImage='url("'+el.dataset.bg.replace(/"/g,'\"')+'")'; el.classList.add('bg-ready'); } if(!('IntersectionObserver' in window)){pictures.forEach(load);return;} var observer=new IntersectionObserver(function(entries){ entries.forEach(function(entry){if(entry.isIntersecting){load(entry.target);observer.unobserve(entry.target);}}); },{rootMargin:'150% 0px'}); pictures.forEach(function(el){observer.observe(el);}); var motionObserver=new IntersectionObserver(function(entries){ entries.forEach(function(entry){entry.target.classList.toggle('perf-visible',entry.isIntersecting);}); },{rootMargin:'15% 0px'}); motion.forEach(function(el){motionObserver.observe(el);}); var flowObserver=new IntersectionObserver(function(entries){ entries.forEach(function(entry){entry.target.classList.toggle('flow-visible',entry.isIntersecting);}); }); document.querySelectorAll('#sbm .pf-card .flow').forEach(function(el){flowObserver.observe(el);}); })();
Tell us about your business. We reply within 24 hours.
With WhatsApp, Storebox may message you on WhatsApp about this request.
No spam, free proposal. Privacy Policy
We've received your information.
Click below to continue the conversation with our team on WhatsApp.