// Stats with animated count-up function useCountUp(target, inView, duration = 1400) { const [val, setVal] = React.useState(0); React.useEffect(() => { if (!inView) return; let raf; const start = performance.now(); const tick = (t) => { const p = Math.min(1, (t - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setVal(eased * target); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, target, duration]); return val; } function StatCard({ n, unit, line, note, src, inView }) { // Handle string targets like "8–12" / "8-12" as a fixed display const isRange = typeof n === 'string' && /[-–—]/.test(n); const numeric = isRange ? 0 : Number(n); const counted = useCountUp(numeric, inView); const display = isRange ? n : Math.round(counted); return (
{display} {unit}
{line}
{note}
{src}
); } function StatsBlock() { const [inView, setInView] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) setInView(true); }, { threshold: 0.3 }); if (ref.current) io.observe(ref.current); return () => io.disconnect(); }, []); const stats = [ { n: 62, unit: '%', line: "Of calls to small home-service businesses go unanswered.", note: "Every unanswered call is a hot lead calling the next name on the Google search.", src: 'Source · 411 Locals · 85-biz study, 2024' }, { n: 85, unit: '%', line: "Of callers who don't reach a person never call back.", note: "Voicemail isn't a safety net — only 1 in 5 leave a message. The rest just move on.", src: 'Source · PATLive / AIRA · 2026' }, { n: '8–12', unit: 'hr', line: "A week most tradies spend quoting, chasing and formatting admin.", note: "That's a full day a week on paperwork — before you've swung a hammer.", src: 'Source · ServiceScale · 2026' } ]; return (
The leak

Every day on the tools
is a day you can't chase leads.

The work you can't get to isn't a small percentage. It's the majority. Here's what the research keeps finding across Australian trades.

{stats.map((s, i) => )}
); } // Before/after with scrubber (synchronised) function BeforeAfter() { const manual = [ ['01','Customer fills in website form','9:04 am', 0], ['02',"Email sits in inbox · you're on a roof",'+6 hrs', 18], ['03','You read it at smoko, forget','3:12 pm', 35], ['04','Missus chases you that night to reply','7:40 pm', 55], ['05','You send rough quote over text','Day 2', 75], ['06',"Customer's already booked someone else",'Day 3', 100] ]; const auto = [ ['01','Customer fills in website form','9:04 am', 0], ['02','Bot qualifies: scope, site, access','9:04 am', 10], ['03','Auto-quote drafted from your rate card','9:05 am', 25], ['04','SMS + PDF sent, phone notification to you','9:06 am', 40], ['05','Customer accepts → pushed into your CRM','10:22 am', 70], ['06','Job card on your phone before lunch','11:48 am', 100] ]; const [pct, setPct] = React.useState(0); const [playing, setPlaying] = React.useState(true); const trackRef = React.useRef(null); React.useEffect(() => { if (!playing) return; const id = setInterval(() => { setPct(p => (p >= 100 ? 0 : p + 0.7)); }, 60); return () => clearInterval(id); }, [playing]); const activeManual = manual.findLastIndex(s => s[3] <= pct); const activeAuto = auto.findLastIndex(s => s[3] <= pct); const dayLabel = pct < 20 ? 'Morning →' : pct < 45 ? 'Midday' : pct < 70 ? 'Afternoon' : pct < 90 ? 'Evening' : 'Next day'; const onTrackClick = (e) => { setPlaying(false); const rect = trackRef.current.getBoundingClientRect(); const p = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)); setPct(p); }; const renderCol = (items, activeIdx, isAuto) => (
{isAuto ? 'Automated — with NORTHEDGE' : 'Manual — today'}

{isAuto ? <>Same enquiry. Booked by smoko. : <>Quote request lands. Then what?}

{isAuto ? 'Booked' : '$0'}
{isAuto ? '2h 44m · zero taps from you' : 'Job lost · a day of mental load for nothing'}
); return (
Before & After

Same enquiry. Different ending.

The same lead plays out twice — one loses, one lands.

{renderCol(manual, activeManual, false)} {renderCol(auto, activeAuto, true)}
); } // Services grid — 2-span cards, asymmetric feature card function ServicesGrid() { const services = [ ['S / 01','Missed-call text-back',"Every unanswered call fires an instant SMS with your name, a booking link, and an \"I'll call back after 4\" option. Recovers jobs you'd never know you lost."], ['S / 02','AI receptionist','A voice agent that picks up, takes the job brief, pencils the diary, and texts a confirmation. Trained on your services, your area, your rates.'], ['S / 03','Lead capture & follow-up',"Website form → qualify → assign → nudge. If a lead goes cold, they get a second touch at 24h, then a 7-day pipeline."], ['S / 04','Automated quoting','Form → our servers → classified as auto-quote or human-review → pushed to your CRM with line items scaled and priced. Same-day quotes without opening a laptop.'], ['S / 05','Booking & scheduling','Two-way calendar sync, drive-time aware, crew-aware. Customers self-book into real slots. You stop playing Tetris with your week.'], ['S / 06','Reporting dashboards','One screen: leads this week, quotes sent, avg response time, jobs scheduled, revenue by source. Numbers that match how the business actually runs.'] ]; return (
What we build

Six systems that run while you work.

Pick one. Pick the lot. Everything we build is custom, hosted by us, and wired into the tools you already use.

{services.map(([s, t, d]) => (
{s}

{t}

{d}

Get this
))}
); } Object.assign(window, { StatsBlock, BeforeAfter, ServicesGrid });