(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, business_type, business, industry, 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; }); 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; } @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 Existing website (optional) Social or business profile link (optional) Location (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 Where are your customers located? Choose one I am a remote / online business International Entire country Within 5 hours of my location Within 1 hour of my location Email Phone / WhatsApp Services required Choose a service Website only Website + growth (rank on Google, ChatGPT, etc.) Anything you would like us to know? (optional) I agree that Storebox may contact me at the phone number I provide with updates about my 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; done.innerHTML = `You're all set, ${esc(f.elements.name.value.trim().split(/\s+/)[0])}.Your website draft and growth proposal arrive within 24 hours.` + (waLink ? `Chat with us on WhatsApp now →` : ''); 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('social_link') && !isLink(v('social_link'))) return say('Enter a valid social or business profile link.', 'social_link'); if (!v('city')) return say('Add your city.', 'city'); if (!v('country')) return say('Choose your country.', 'country'); if (!v('location_type')) return say('Tell us where your customers are located.', 'location_type'); 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'); if (!v('email') && !v('phone')) return say('Add an email or a phone number, or both.', 'email'); if (!v('services_required')) return say('Choose the services you need.', 'services_required'); if (v('phone') && !f.elements.phone_update_consent.checked) return say('Please agree to phone updates, or remove your phone number and use email.', 'phone_update_consent'); err.textContent = ''; if (v('phone')) f.elements.phone.value = '+' + phoneDigits(v('phone')); 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 reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || matchMedia('(max-width: 999px)').matches || new URLSearchParams(location.search).get('tier') === 'static'; root.dataset.tier = reduce ? 'static' : 'full'; // The tier is chosen once, here. Crossing the 1000px line or flipping the reduced-motion setting while the page // is open would leave the wrong tier running, so the page reloads and chooses again. let retier = 0; const onTierChange = () => { clearTimeout(retier); retier = setTimeout(() => location.reload(), 250); }; matchMedia('(max-width: 999px)').addEventListener('change', onTierChange); matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', onTierChange); 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' })); } /* 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'); if(!root||root.dataset.mobileMotionReady||!('IntersectionObserver' in window)||!Element.prototype.animate)return; root.dataset.mobileMotionReady='1'; const query=matchMedia('(max-width:999px) 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);}); })(); ; /* One-shot enhancements. Each waits for its section to scroll into view and runs once; none listens to scroll. */ (function(){ const root=document.getElementById('sbm'); if(!root||root.dataset.enhanceReady)return; root.dataset.enhanceReady='1'; const reduced=matchMedia('(prefers-reduced-motion:reduce)').matches; const ease=t=>1-Math.pow(1-t,3); const once=(el,fn,threshold)=>{ if(!el)return; if(reduced||!('IntersectionObserver' in window)){fn(true);return;} const io=new IntersectionObserver(entries=>{if(entries[0].isIntersecting){io.disconnect();fn(false);}},{threshold:threshold||.5}); io.observe(el); }; // Proof: 1,000,000+ counts up and the comparison draws itself (strikethrough on "3 months", then "8 days"). const count=root.querySelector('.dp-n'); once(count,instant=>{ if(instant)return; const target=1000000,t0=performance.now(); (function tick(now){ const k=Math.min(1,(now-t0)/1600); count.textContent=k<1?Math.round(target*ease(k)).toLocaleString('en-US'):'1,000,000+'; if(k<1)requestAnimationFrame(tick); })(t0); }); const race=root.querySelector('.race'); once(race,()=>race.classList.add('go')); // Meridian: on phones the three module boxes become tabs, so the diagram is one box tall instead of three. const modules=root.querySelector('.modules'); if(modules){ const panes=[...modules.children],tabs=document.createElement('div'); tabs.className='mod-tabs';tabs.setAttribute('role','tablist'); const select=i=>{ panes.forEach((p,j)=>p.classList.toggle('on',i===j)); [...tabs.children].forEach((b,j)=>{b.classList.toggle('on',i===j);b.setAttribute('aria-selected',String(i===j));}); }; panes.forEach((p,i)=>{ const b=document.createElement('button'); b.type='button';b.setAttribute('role','tab');b.textContent=p.querySelector('small').textContent; b.addEventListener('click',()=>select(i)); tabs.appendChild(b); }); modules.before(tabs); select(0); } // Industries: dots under the swipe row show which tile is in view (phones only; hidden by CSS on desktop). const row=root.querySelector('#industries .ind'); if(row&&'IntersectionObserver' in window){ const tiles=[...row.children],dots=document.createElement('div'); dots.className='ind-dots';dots.setAttribute('aria-hidden','true'); tiles.forEach(()=>dots.appendChild(document.createElement('i'))); row.after(dots); dots.children[0].classList.add('on'); const io=new IntersectionObserver(entries=>entries.forEach(e=>{ if(!e.isIntersecting)return; const i=tiles.indexOf(e.target); [...dots.children].forEach((d,j)=>d.classList.toggle('on',i===j)); }),{root:row,threshold:.6}); tiles.forEach(t=>io.observe(t)); } })();
Tell us about your business. We reply within 24 hours.
No spam, free proposal. Privacy Policy
Your website draft and growth proposal arrive within 24 hours.