This commit is contained in:
2026-05-26 23:30:22 +12:00
parent 135a5a3b83
commit 91b22c6d60
27 changed files with 2401 additions and 88 deletions
+186 -19
View File
@@ -1,6 +1,7 @@
type GtagFn = (...args: unknown[]) => void;
const TEXT_LIMIT = 80;
const EVENT_NAME_LIMIT = 40;
function gtag(): GtagFn | null {
if (typeof window === 'undefined') return null;
@@ -8,10 +9,156 @@ function gtag(): GtagFn | null {
return typeof fn === 'function' ? fn : null;
}
export function trackPageView(path: string, title: string): void {
function sanitizeValue(value: string): string {
return value.trim().replace(/\s+/g, ' ').slice(0, TEXT_LIMIT);
}
function sanitizeEventName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, EVENT_NAME_LIMIT);
}
const JOURNEY_STORAGE_KEY = 'goodwalk:journey:v1';
const JOURNEY_BUFFER_LIMIT = 30;
type JourneyEntry = {
name: string;
page_path: string;
label: string;
ts: number;
};
function pushJourneyBuffer(entry: JourneyEntry): void {
if (typeof sessionStorage === 'undefined') return;
try {
const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);
const list: JourneyEntry[] = raw ? JSON.parse(raw) : [];
list.push(entry);
if (list.length > JOURNEY_BUFFER_LIMIT) list.splice(0, list.length - JOURNEY_BUFFER_LIMIT);
sessionStorage.setItem(JOURNEY_STORAGE_KEY, JSON.stringify(list));
} catch {
// sessionStorage can throw on quota / private mode — ignore.
}
}
function readJourneyBuffer(): JourneyEntry[] {
if (typeof sessionStorage === 'undefined') return [];
try {
const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);
return raw ? (JSON.parse(raw) as JourneyEntry[]) : [];
} catch {
return [];
}
}
function clearJourneyBuffer(): void {
if (typeof sessionStorage === 'undefined') return;
try {
sessionStorage.removeItem(JOURNEY_STORAGE_KEY);
} catch {
// ignore
}
}
function sendToServerTrack(payload: Record<string, unknown>): void {
if (typeof navigator === 'undefined') return;
const body = JSON.stringify(payload);
// sendBeacon survives page unloads (link clicks, form submits) where a
// regular fetch would be cancelled. Falls back to fetch+keepalive.
if (typeof navigator.sendBeacon === 'function') {
const blob = new Blob([body], { type: 'application/json' });
if (navigator.sendBeacon('/api/track', blob)) return;
}
try {
fetch('/api/track', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true
}).catch(() => {});
} catch {
// analytics must never throw into the UI
}
}
export function trackEvent(eventName: string, params: Record<string, unknown> = {}): void {
const normalizedName = sanitizeEventName(eventName);
if (!normalizedName) return;
const enriched: Record<string, unknown> = {
page_path: window.location.pathname + window.location.search,
page_location: window.location.href,
...params
};
const send = gtag();
if (!send) return;
send('event', 'page_view', {
const gtagMissing = !send;
if (send) {
send('event', normalizedName, enriched);
}
// Always fire to /api/track for two reasons:
// 1. Persists into session_events for journey reconstruction on submit.
// 2. When gtag is missing (ad blocker), `forward_ga4: true` tells the
// server to also forward to GA4 so we don't lose the hit.
// De-dup: when gtag IS present, GA4 receives the event exactly once
// (client). When gtag is missing, GA4 receives it exactly once (server).
sendToServerTrack({
name: normalizedName,
params: enriched,
forward_ga4: gtagMissing
});
// Client-side rolling buffer used as a fallback inside /api/track/promote
// for environments where the server-side write was also blocked.
pushJourneyBuffer({
name: normalizedName,
page_path: typeof enriched.page_path === 'string' ? enriched.page_path : '',
label: typeof enriched.label === 'string' ? enriched.label : '',
ts: Date.now()
});
}
/**
* Called from the booking form on successful submission to link the
* visitor's recent browsing journey to their submitted email. See
* docs/server-side-analytics.md for the full flow.
*/
export function promoteJourney(email: string): void {
if (typeof navigator === 'undefined') return;
const trimmed = email.trim().toLowerCase();
if (!trimmed) return;
const payload = JSON.stringify({
email: trimmed,
client_events: readJourneyBuffer()
});
if (typeof navigator.sendBeacon === 'function') {
const blob = new Blob([payload], { type: 'application/json' });
if (navigator.sendBeacon('/api/track/promote', blob)) {
clearJourneyBuffer();
return;
}
}
try {
fetch('/api/track/promote', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: payload,
keepalive: true
})
.then(() => clearJourneyBuffer())
.catch(() => {});
} catch {
// ignore
}
}
export function trackPageView(path: string, title: string): void {
trackEvent('page_view', {
page_path: path,
page_title: title,
page_location: window.location.href
@@ -20,11 +167,11 @@ export function trackPageView(path: string, title: string): void {
function getLabel(el: HTMLElement): string {
const aria = el.getAttribute('aria-label');
if (aria) return aria.trim().slice(0, TEXT_LIMIT);
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
if (text) return text.slice(0, TEXT_LIMIT);
if (aria) return sanitizeValue(aria);
const text = sanitizeValue(el.textContent ?? '');
if (text) return text;
const title = el.getAttribute('title');
if (title) return title.trim().slice(0, TEXT_LIMIT);
if (title) return sanitizeValue(title);
return '';
}
@@ -41,38 +188,58 @@ function getLocation(el: HTMLElement): string {
return 'body';
}
function getTextParam(el: HTMLElement): string {
const explicit = el.dataset.trackText;
if (explicit) return sanitizeValue(explicit);
return getLabel(el);
}
function getHrefParams(href: string) {
try {
const url = new URL(href, window.location.href);
return {
link_url: href,
destination_path: `${url.pathname}${url.search}${url.hash}`,
destination_host: url.hostname,
outbound: url.hostname !== window.location.hostname
};
} catch {
return {
link_url: href,
outbound: false
};
}
}
export function initClickTracking(): () => void {
if (typeof window === 'undefined') return () => {};
const handler = (event: MouseEvent) => {
const send = gtag();
if (!send) return;
const target = event.target;
if (!(target instanceof Element)) return;
const interactive = target.closest<HTMLElement>('a, button, [role="button"]');
if (!interactive) return;
const isLink = interactive.tagName === 'A';
const label = getLabel(interactive);
const eventName = interactive.dataset.trackEvent || (isLink ? 'link_click' : 'button_click');
const label = interactive.dataset.trackLabel ? sanitizeValue(interactive.dataset.trackLabel) : getLabel(interactive);
const location = getLocation(interactive);
const params: Record<string, unknown> = {
element: isLink ? 'link' : 'button',
label,
location,
page_path: window.location.pathname
text: getTextParam(interactive)
};
if (interactive.dataset.trackType) params.track_type = sanitizeValue(interactive.dataset.trackType);
if (interactive.dataset.trackContext) params.track_context = sanitizeValue(interactive.dataset.trackContext);
if (interactive.dataset.trackVariant) params.track_variant = sanitizeValue(interactive.dataset.trackVariant);
if (isLink) {
const href = (interactive as HTMLAnchorElement).href;
if (href) {
params.link_url = href;
try {
const url = new URL(href, window.location.href);
params.outbound = url.hostname !== window.location.hostname;
} catch {
params.outbound = false;
}
Object.assign(params, getHrefParams(href));
}
} else {
const btn = interactive as HTMLButtonElement;
@@ -80,7 +247,7 @@ export function initClickTracking(): () => void {
if (btn.name) params.button_name = btn.name;
}
send('event', isLink ? 'link_click' : 'button_click', params);
trackEvent(eventName, params);
};
document.addEventListener('click', handler, { capture: true });
+45
View File
@@ -4,6 +4,7 @@
import Icon from '$lib/components/Icon.svelte';
import { reveal } from '$lib/actions/reveal';
import type { BookingContent } from '$lib/types';
import { promoteJourney, trackEvent } from '$lib/analytics';
import { trackAb, type AbContext } from '$lib/ab';
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
@@ -59,6 +60,7 @@
let sendClickedAt = 0;
let stepChanges = 0;
let journey: string[] = [];
let trackedFormStart = false;
let errors: Record<string, string> = {};
let submitting = false;
@@ -164,6 +166,14 @@
function noteInteraction() {
if (!firstInteractionAt) firstInteractionAt = Date.now();
if (!trackedFormStart) {
trackedFormStart = true;
trackEvent('lead_form_start', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
step: step,
page_path: typeof window !== 'undefined' ? window.location.pathname : pagePath || ''
});
}
}
function clearError(field: string) {
@@ -175,6 +185,12 @@
selectedServices = selectedServices.includes(service)
? selectedServices.filter((s) => s !== service)
: [...selectedServices, service];
trackEvent('service_interest_select', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
selected_service: service,
selection_state: selectedServices.includes(service) ? 'removed' : 'added',
step
});
clearError('services');
}
@@ -226,6 +242,11 @@
function goNext() {
noteInteraction();
if (!validateStep(step)) return;
trackEvent('lead_form_step_complete', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
step,
next_step: step + 1
});
step += 1;
stepChanges += 1;
}
@@ -233,6 +254,11 @@
function goBack() {
noteInteraction();
if (step > 1) {
trackEvent('lead_form_step_back', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
step,
previous_step: step - 1
});
step -= 1;
stepChanges += 1;
errors = {};
@@ -249,6 +275,12 @@
sendClickedAt = Date.now();
submitErrorDetail = '';
showErrorModal = false;
trackEvent('lead_form_submit_attempt', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
selected_services: selectedServices.join(' | '),
step,
step_changes: stepChanges
});
try {
const res = await fetch('/api/submit', {
@@ -287,10 +319,23 @@
}
submitted = true;
trackEvent('lead_form_submit_success', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
selected_services: selectedServices.join(' | '),
step_changes: stepChanges
});
// Link the visitor's session_events journey to the submitted email so
// the owner can review it from the CP dashboard. Without this call,
// session_events expire after 24h. See docs/server-side-analytics.md.
promoteJourney(email);
if (ab) trackAb({ ...ab, event_type: 'conversion', meta: { surface: 'booking_submit' } });
} catch (err: unknown) {
submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true;
trackEvent('lead_form_submit_error', {
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
error_detail: submitErrorDetail.slice(0, 120)
});
} finally {
submitting = false;
}
+16 -13
View File
@@ -35,7 +35,7 @@
$: navigationLinks = withAboutLink(footer.navigationLinks);
</script>
<footer>
<footer data-track-location="footer">
<div class="footer-inner">
<div class="footer-brand">
<enhanced:img
@@ -52,13 +52,13 @@
<Icon name="fas fa-arrow-turn-down" className="footer-social-invite-arrow" />
</div>
<div class="social-links">
<a href={socialLinks[0].href} target="_blank" rel="noopener" aria-label="Instagram">
<a href={socialLinks[0].href} target="_blank" rel="noopener" aria-label="Instagram" data-track-event="social_click" data-track-type="footer_social" data-track-label="Footer Instagram">
<Icon name="fab fa-instagram" />
</a>
<a href={socialLinks[1].href} target="_blank" rel="noopener" aria-label="Facebook">
<a href={socialLinks[1].href} target="_blank" rel="noopener" aria-label="Facebook" data-track-event="social_click" data-track-type="footer_social" data-track-label="Footer Facebook">
<Icon name="fab fa-facebook-f" />
</a>
<a href={socialLinks[2].href} target="_blank" rel="noopener" aria-label="Google">
<a href={socialLinks[2].href} target="_blank" rel="noopener" aria-label="Google" data-track-event="social_click" data-track-type="footer_social" data-track-label="Footer Google">
<Icon name="fab fa-google" />
</a>
</div>
@@ -67,13 +67,13 @@
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<a href="mailto:{footer.email}" class="footer-contact-link" data-track-event="contact_click" data-track-type="email" data-track-label="Footer email">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link" data-track-event="contact_click" data-track-type="phone" data-track-label="Footer phone">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
@@ -82,7 +82,7 @@
{/if}
</div>
<div class="footer-explore">
<div class="footer-explore" data-track-location="footer_explore">
<p class="footer-col-label">Explore Goodwalk</p>
<ul class="footer-nav">
{#each navigationLinks as link}
@@ -91,6 +91,9 @@
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noopener' : undefined}
data-track-event="nav_click"
data-track-type="footer_nav"
data-track-label={link.label}
>
{link.label}
</a>
@@ -99,13 +102,13 @@
</ul>
</div>
<div class="footer-locations">
<div class="footer-locations" data-track-location="footer_locations">
<p class="footer-col-label">
<a href="/locations">Areas we serve</a>
</p>
<ul class="footer-nav">
{#each locationPages as loc}
<li><a href="/locations/{loc.slug}">{loc.suburb}</a></li>
<li><a href="/locations/{loc.slug}" data-track-event="nav_click" data-track-type="footer_location" data-track-label={loc.suburb}>{loc.suburb}</a></li>
{/each}
</ul>
</div>
@@ -113,11 +116,11 @@
<div class="footer-bottom">
<span>{footer.copyright}</span>
<nav class="footer-legal">
<a href="/terms-and-conditions">Terms &amp; Conditions</a>
<a href="/privacy-policy">Privacy Policy</a>
<nav class="footer-legal" data-track-location="footer_legal">
<a href="/terms-and-conditions" data-track-event="nav_click" data-track-type="footer_legal" data-track-label="Terms and Conditions">Terms &amp; Conditions</a>
<a href="/privacy-policy" data-track-event="nav_click" data-track-type="footer_legal" data-track-label="Privacy Policy">Privacy Policy</a>
</nav>
<button type="button" class="footer-back-top" aria-label="Back to top" on:click={scrollToTop}>
<button type="button" class="footer-back-top" aria-label="Back to top" on:click={scrollToTop} data-track-event="nav_click" data-track-type="footer_back_to_top" data-track-label="Back to top">
↑ Back to top
</button>
</div>
@@ -22,7 +22,7 @@
$: founderStoryEnhanced = getEnhancedImage(founderStory.imageUrl);
</script>
<section id="promise" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block">
<section id="promise" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block" data-track-location="founder_story">
<div class="founder-inner">
<article class="founder-note">
<div class="founder-intro fade-up">
@@ -55,12 +55,12 @@
</p>
<div class="founder-actions fade-up">
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk">
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk" data-track-event="contact_click" data-track-type="founder_email" data-track-label="Founder email note">
<span class="founder-contact-wave" aria-hidden="true">👋</span>
<span>If you are unsure about anything, feel free to email, call, or send me an Instagram DM anytime.</span>
</a>
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta cta-shimmer">
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta cta-shimmer" data-track-event="cta_click" data-track-type="founder_story_primary" data-track-label={founderStory.cta.label}>
{founderStory.cta.label}
<Icon name="fas fa-arrow-right" />
</a>
+42 -4
View File
@@ -133,8 +133,8 @@
});
</script>
<header bind:this={headerElement}>
<nav>
<header bind:this={headerElement} data-track-location="header">
<nav data-track-location="header_nav">
<ul class="nav-links nav-links-left">
{#each leftLinks as link, i}
<li class:has-mega={i === 0 && navigation.megaMenuServices?.length}>
@@ -144,6 +144,9 @@
rel={linkRel(link.external)}
aria-current={ariaCurrent(link.href)}
class:nav-link-active={isActiveLink(link.href, i === 0 && Boolean(navigation.megaMenuServices?.length))}
data-track-event="nav_click"
data-track-type="desktop_primary"
data-track-label={link.label}
>
{link.label}
{#if i === 0 && navigation.megaMenuServices?.length}
@@ -162,6 +165,9 @@
rel={linkRel(service.href.startsWith('http'))}
aria-current={ariaCurrent(service.href)}
class="mega-service"
data-track-event="nav_click"
data-track-type="mega_menu_service"
data-track-label={service.label}
>
<div class="mega-icon">
<Icon name={service.icon} />
@@ -179,6 +185,9 @@
target={linkTarget(navigation.megaMenuFooter.external)}
rel={linkRel(navigation.megaMenuFooter.external)}
class="mega-menu-footer"
data-track-event="nav_click"
data-track-type="mega_menu_footer"
data-track-label={navigation.megaMenuFooter.label}
>
<span>{navigation.megaMenuFooter.label}</span>
<Icon name="fas fa-arrow-right" className="mega-menu-footer-arrow" />
@@ -191,7 +200,14 @@
{/each}
</ul>
<a href="/" class="logo" aria-label="Goodwalk Auckland Dog Walking, home">
<a
href="/"
class="logo"
aria-label="Goodwalk Auckland Dog Walking, home"
data-track-event="nav_click"
data-track-type="logo"
data-track-label="Goodwalk home"
>
<picture>
{#if mobile.sources?.webp}
<source type="image/webp" srcset={mobile.sources.webp} />
@@ -216,6 +232,9 @@
rel={linkRel(link.external)}
aria-current={ariaCurrent(link.href)}
class:nav-link-active={isActiveLink(link.href)}
data-track-event="nav_click"
data-track-type="desktop_secondary"
data-track-label={link.label}
>
{link.label}
</a>
@@ -224,7 +243,14 @@
</ul>
<div class="nav-right">
<a href={`tel:${mobilePhoneHref}`} class="mobile-phone" aria-label={`Call Goodwalk on ${mobilePhoneDisplay}`}>
<a
href={`tel:${mobilePhoneHref}`}
class="mobile-phone"
aria-label={`Call Goodwalk on ${mobilePhoneDisplay}`}
data-track-event="contact_click"
data-track-type="phone"
data-track-label="Header phone"
>
<Icon name="fas fa-phone" />
</a>
{#if navigation.instagram}
@@ -234,6 +260,9 @@
rel={linkRel(navigation.instagram.external)}
class="instagram-icon"
aria-label="Instagram"
data-track-event="social_click"
data-track-type="instagram"
data-track-label="Header Instagram"
>
<Icon name="fab fa-instagram" />
</a>
@@ -241,6 +270,9 @@
<a
href={navigation.cta.href}
class="btn btn-yellow"
data-track-event="cta_click"
data-track-type="header_primary"
data-track-label={navigation.cta.label}
>{navigation.cta.label}</a>
</div>
</div>
@@ -252,6 +284,9 @@
aria-controls="mobile-menu"
aria-label="Toggle menu"
on:click={toggleMenu}
data-track-event="nav_click"
data-track-type="mobile_menu_toggle"
data-track-label="Toggle menu"
>
<Icon name={mobileMenuOpen ? 'fas fa-xmark' : 'fas fa-bars'} />
</button>
@@ -284,6 +319,9 @@
aria-current={ariaCurrent(link.href)}
class:mobile-link-active={isActiveLink(link.href)}
on:click={closeMenu}
data-track-event="nav_click"
data-track-type="mobile_menu_link"
data-track-label={link.label}
>
<span class="mobile-menu-link-icon">
<Icon name={mobileLinkIcon(link.href)} />
+10 -1
View File
@@ -54,7 +54,7 @@
}
</script>
<section id="hero">
<section id="hero" data-track-location="hero">
<!-- hero-img is a direct child of #hero so it can be absolutely
positioned relative to the section on mobile without being
constrained by hero-inner's stacking context -->
@@ -124,6 +124,9 @@
target={reviewCta.external ? '_blank' : undefined}
rel={reviewCta.external ? 'noopener' : undefined}
aria-label="Read our five-star Google reviews"
data-track-event="social_proof_click"
data-track-type="hero_reviews"
data-track-label={reviewCta.label}
>
<span class="hero-trust-mark" aria-hidden="true">
<img
@@ -152,6 +155,9 @@
rel={linkRel(primaryCta.external)}
class="btn btn-yellow btn-with-arrow btn-hide-arrow-mobile"
on:click={handlePrimaryCtaClick}
data-track-event="cta_click"
data-track-type="hero_primary"
data-track-label={primaryCta.label}
>
{primaryCta.label}
<Icon name="fas fa-arrow-right" />
@@ -161,6 +167,9 @@
target={linkTarget(hero.secondaryCta.external)}
rel={linkRel(hero.secondaryCta.external)}
class="hero-secondary-link"
data-track-event="cta_click"
data-track-type="hero_secondary"
data-track-label={hero.secondaryCta.label}
>
{hero.secondaryCta.label}
<Icon name="fas fa-arrow-down" className="hero-cta-arrow" />
+2 -2
View File
@@ -12,7 +12,7 @@
];
</script>
<section id="how-it-works" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block">
<section id="how-it-works" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block" data-track-location="how_it_works">
<div class="hiw-inner">
<div class="section-header hiw-header fade-up">
@@ -55,7 +55,7 @@
</div>
<div class="hiw-cta fade-up">
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow cta-shimmer">
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow cta-shimmer" data-track-event="cta_click" data-track-type="how_it_works_primary" data-track-label="Book your free Meet and Greet">
Book your free Meet &amp; Greet
<Icon name="fas fa-arrow-right" />
</a>
+2 -2
View File
@@ -15,7 +15,7 @@
.map((suburb) => ({ name: suburb, slug: slugBySuburb.get(suburb) ?? null }));
</script>
<section id="info">
<section id="info" data-track-location="info">
<div class="info-inner">
<div class="info-block">
<h2>
@@ -40,7 +40,7 @@
<span class="info-nearby-kicker">Nearby but not listed?</span>
<p>{info.nearbyText} There's a good chance we can still help.</p>
</div>
<a class="info-nearby-cta" href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
<a class="info-nearby-cta" href={info.nearbyCta.href} data-track-event="cta_click" data-track-type="info_nearby_suburb" data-track-label={info.nearbyCta.label}>{info.nearbyCta.label}</a>
</div>
<div class="info-hours-card">
+2 -1
View File
@@ -121,8 +121,9 @@
class="mobile-book-bar"
class:mobile-book-bar-visible={visible}
aria-hidden={!visible}
data-track-location="mobile_sticky_cta"
>
<a class="mobile-book-bar-cta" href="/contact-us" tabindex={visible ? 0 : -1}>
<a class="mobile-book-bar-cta" href="/contact-us" tabindex={visible ? 0 : -1} data-track-event="cta_click" data-track-type="mobile_sticky_bar" data-track-label="Book a free Meet and Greet">
<Icon name="fas fa-paw" />
<span>Book a free Meet &amp; Greet</span>
<Icon name="fas fa-arrow-right" className="mobile-book-bar-arrow" />
+4 -1
View File
@@ -67,7 +67,7 @@
.map(({ service }) => service);
</script>
<section id="services" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block">
<section id="services" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block" data-track-location="services">
<div class="services-inner">
<div class="section-header fade-up">
<h2 class="section-heading">{heading}</h2>
@@ -84,6 +84,9 @@
class:service-card-featured={meta?.featured}
class="service-card fade-up"
aria-label={`${service.title} — view service page`}
data-track-event="service_card_click"
data-track-type={meta?.featured ? 'featured_service_card' : 'service_card'}
data-track-label={service.title}
>
<div class="service-card-media">
{#if meta}
@@ -133,7 +133,23 @@
page: string;
};
type JourneyEvent = {
name?: string;
page_path?: string;
label?: string;
ts?: string | number | null;
params?: Record<string, unknown> | null;
};
type SubmissionJourney = {
id: number;
anonId: string | null;
events: JourneyEvent[];
clientEvents: JourneyEvent[];
createdAt: string | null;
};
let enquiry: Enquiry | null = null;
let enquiryJourney: SubmissionJourney | null = null;
let enquiryLoading = false;
let enquiryError = '';
let enquiryOpen = false;
@@ -286,6 +302,48 @@
});
}
function formatJourneyTime(value: string | number | null | undefined): string {
if (value == null || value === '') return '';
const parsed = typeof value === 'number' ? new Date(value) : new Date(String(value));
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleTimeString('en-NZ', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
});
}
function describeJourneyEvent(evt: JourneyEvent): string {
const name = (evt.name || '').replace(/_/g, ' ');
const label = typeof evt.label === 'string' ? evt.label : '';
const params = evt.params && typeof evt.params === 'object' ? evt.params : null;
const paramLabel = params && typeof params.label === 'string' ? params.label : '';
const detail = label || paramLabel;
return detail ? `${name} — ${detail}` : name;
}
function mergedJourneyEvents(journey: SubmissionJourney | null): JourneyEvent[] {
if (!journey) return [];
const server = Array.isArray(journey.events) ? journey.events : [];
const client = Array.isArray(journey.clientEvents) ? journey.clientEvents : [];
// Merge by timestamp; server-captured events take precedence when both
// sides logged the same moment because they include params.
const seen = new Set<string>();
const all = [...server, ...client];
return all
.filter((evt) => {
const key = `${evt.name}|${evt.page_path ?? ''}|${evt.ts ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort((a, b) => {
const ta = a.ts ? new Date(a.ts as string | number).getTime() : 0;
const tb = b.ts ? new Date(b.ts as string | number).getTime() : 0;
return ta - tb;
});
}
function formatBirthday(value: string): string {
if (!value) return 'No birthday';
const parsed = new Date(value);
@@ -583,6 +641,7 @@
enquiryLoading = true;
enquiryError = '';
enquiry = null;
enquiryJourney = null;
const token = getToken();
if (!token) {
enquiryError = 'Your session has expired. Please sign in again.';
@@ -596,6 +655,7 @@
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load enquiry.');
enquiry = data?.enquiry ?? null;
enquiryJourney = data?.journey ?? null;
} catch (error) {
enquiryError = error instanceof Error ? error.message : 'Could not load enquiry.';
} finally {
@@ -606,6 +666,7 @@
function closeEnquiry() {
enquiryOpen = false;
enquiry = null;
enquiryJourney = null;
enquiryError = '';
}
@@ -1805,6 +1866,31 @@
<div><dt>Referrer</dt><dd>{enquiry.referrer}</dd></div>
{/if}
</dl>
{#if enquiryJourney}
{@const events = mergedJourneyEvents(enquiryJourney)}
<section class="owner-journey">
<header class="owner-journey-head">
<h3>Visitor journey</h3>
<small>{events.length} event{events.length === 1 ? '' : 's'} before they submitted</small>
</header>
{#if events.length === 0}
<p class="owner-journey-empty">No browsing events were recorded for this submission.</p>
{:else}
<ol class="owner-journey-list">
{#each events as evt}
<li>
<span class="owner-journey-time">{formatJourneyTime(evt.ts)}</span>
<span class="owner-journey-event">{describeJourneyEvent(evt)}</span>
{#if evt.page_path}
<span class="owner-journey-path">{evt.page_path}</span>
{/if}
</li>
{/each}
</ol>
{/if}
</section>
{/if}
{/if}
</div>
</div>
@@ -3192,6 +3278,79 @@
background: rgba(33, 48, 33, 0.05);
}
.owner-journey {
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid rgba(33, 48, 33, 0.12);
}
.owner-journey-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.owner-journey-head h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gw-green);
}
.owner-journey-head small {
color: rgba(33, 48, 33, 0.62);
font-size: 12px;
}
.owner-journey-empty {
margin: 0;
color: rgba(33, 48, 33, 0.62);
font-size: 13px;
}
.owner-journey-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 280px;
overflow-y: auto;
border-radius: 10px;
background: rgba(33, 48, 33, 0.04);
padding: 10px 12px;
}
.owner-journey-list li {
display: grid;
grid-template-columns: 84px 1fr;
column-gap: 10px;
row-gap: 2px;
font-size: 12.5px;
line-height: 1.4;
}
.owner-journey-time {
color: rgba(33, 48, 33, 0.55);
font-variant-numeric: tabular-nums;
}
.owner-journey-event {
color: var(--gw-green);
font-weight: 500;
}
.owner-journey-path {
grid-column: 2;
color: rgba(33, 48, 33, 0.58);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
word-break: break-all;
}
@media (max-width: 768px) {
.owner-page {
/* Prevent any element from breaking horizontal scroll on a phone. */
+27 -17
View File
@@ -38,7 +38,7 @@ export const homepageContent: HomePageContent = {
mobileTitle: 'Come home to a\ncalm, happy dog',
seoHeading: 'Dog walking across Auckland Central',
subtitle:
'Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings.',
'Trusted dog walking for Auckland\'s small and medium dogs. Thoughtful matching, familiar routines, and calmer evenings at home.',
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
secondaryCta: {
label: 'See how it works',
@@ -99,30 +99,30 @@ export const homepageContent: HomePageContent = {
howItWorks: {
title: 'How it works',
intro:
'Meet your dog. Settle them in. Let the routine do the rest.',
'We assess carefully, build confidence gradually, and only give dogs the level of freedom they are ready for.',
steps: [
{
phase: 'Meet',
benefit: 'No pressure, just clarity',
title: 'We come to your home first.',
benefit: 'Safe fit before anything starts',
title: 'We come to your home and assess properly.',
body:
"You show us your dog. We talk routine and temperament. Nobody books until everyone's sure.",
"We get to know your dog's temperament, confidence, energy, and routine. Nobody joins until the fit feels right for everyone.",
icon: 'fas fa-handshake'
},
{
phase: 'Settle',
benefit: 'Confidence before commitment',
title: 'Your dog settles in. No rushing.',
benefit: 'Confidence before more freedom',
title: 'Your dog settles in at the right pace.',
body:
'Two assessment walks. We find their pace before any regular spot.',
'Two assessment walks give us time to introduce the environment properly, support recall, and see what level of handling suits your dog best.',
icon: 'fas fa-clipboard-check'
},
{
phase: 'Thrive',
benefit: 'Calmer evenings',
title: 'Then it just runs.',
benefit: 'Calmer dogs, healthier mindset',
title: 'Then the routine starts to work for them.',
body:
'Your dog comes home tired and happy. Evenings get quieter. The workday stops carrying guilt.',
'Some dogs earn off-leash freedom, others stay more managed. Either way, the goal is the same: a dog who comes home calmer, happier, and more settled.',
icon: 'fas fa-heart'
}
]
@@ -142,17 +142,17 @@ export const homepageContent: HomePageContent = {
},
{
icon: 'fas fa-users',
title: 'Matched, not just grouped',
title: 'Matched, never random',
order: 2,
body:
'4 to 8 dogs, matched on size, energy, and play style. Small and medium only.'
'Dogs are introduced thoughtfully based on temperament, confidence, energy, and social fit. Small and medium only.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety, by default',
title: 'Safety with structure',
order: 1,
body:
'Pet first aid certified. Careful screening. Proactive handling. The baseline, not a premium.'
'Pet first aid certified, carefully screened, and handled proactively. Not every dog is off leash, and that is by design.'
},
{
icon: 'fas fa-calendar-check',
@@ -278,7 +278,7 @@ export const homepageContent: HomePageContent = {
{
question: 'Can any dog use your service?',
answer:
'Every dog goes through a screening process with a minimum of two assessment walks. We confirm the fit before anything regular starts.'
'No. Every dog goes through a screening process with a minimum of two assessment walks. We confirm the fit before anything regular starts, and we will say if a different setup would suit your dog better.'
},
{
question: 'How does payment work?',
@@ -292,7 +292,17 @@ export const homepageContent: HomePageContent = {
{
question: 'What requirements does my dog need?',
answer:
'A current Auckland Council dog registration and up-to-date vaccinations. That keeps every dog in our care safe.'
'A current Auckland Council dog registration, up-to-date vaccinations, and enough comfort around people, handling, and new environments to settle safely into the routine.'
},
{
question: 'Are all dogs off leash on pack walks?',
answer:
'No. Off-leash time is only for dogs with the recall, social behaviour, and confidence to handle it well. We do not assume every dog should have the same level of freedom.'
},
{
question: 'Does my dog need perfect recall before starting?',
answer:
'No. Dogs do not need to arrive fully off-leash ready. We work with each dog at the right pace to build confidence, healthier habits, and the right mindset before more freedom is introduced.'
},
{
question: 'Do you have insurance or First Aid training?',
+35 -23
View File
@@ -4,17 +4,17 @@ export const packWalksContent: ServicePageContent = {
hero: {
eyebrow: 'Tiny Gang Pack Walks',
title: 'Pack walks that actually suit small & medium dogs',
subtitle: 'Small groups. Same walker. A real walk, every time.',
subtitle: 'Small groups, thoughtful matching, and off-leash freedom only when a dog is truly ready.',
introEyebrow: 'How Tiny Gang works',
introHeading: 'What joining the pack actually looks like',
introHeading: 'What joining Tiny Gang actually looks like',
detailHighlights: [
{ value: '4-8', label: 'dogs per walk' },
{ value: '2', label: 'assessment walks first' },
{ value: '60-75', label: 'minutes on the ground' }
],
paragraphs: [
'Tiny Gang is built for small and medium dogs who like the right kind of company. 4 to 8 dogs, matched on size and energy.',
'Free Meet & Greet at home, then two assessment walks. Regular slot only once we know the fit is right.',
'Tiny Gang is built for small and medium dogs who enjoy the right kind of company. We do not put random dogs together. Every group is matched carefully for temperament, confidence, energy, and social fit.',
'Free Meet & Greet at home, then two assessment walks. Regular slot only starts once we know your dog feels safe, settled, and well suited to the environment.',
'60 to 75 minutes on the ground. We rotate Western Springs, Fowlds Park, Cornwall Park, Grey Lynn Park, and Oakley Creek — picked on the day for weather and group.'
],
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
@@ -28,24 +28,24 @@ export const packWalksContent: ServicePageContent = {
},
highlight: {
eyebrow: 'What Tiny Gang is',
title: 'Small groups. Right dogs. Real walks.',
title: 'Safe, structured pack walks for the right dogs',
imageUrl: '/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp',
imageAlt: 'Small and medium dogs together on a Goodwalk pack walk in Auckland',
points: [
{
title: 'Small, social groups',
title: 'Carefully matched groups',
body:
'4 to 8 dogs, carefully matched. Real play, movement, and social time — no oversized-pack chaos.'
'4 to 8 dogs, matched with intention. Temperament, confidence, energy, and social behaviour matter more than simply filling a slot.'
},
{
title: 'Built for the right dogs',
title: 'Freedom is earned',
body:
'Sociable small and medium dogs who enjoy company. If your dog needs more space, solo walks are the better fit.'
'Not every dog is off leash. Off-leash time is only for dogs with the recall, confidence, and social skills to handle that freedom well.'
},
{
title: 'A weekly rhythm that sticks',
title: 'Confidence comes before pressure',
body:
'Regular weekday routine. Pickup and drop-off across Auckland Central. Easy to keep going.'
'Some dogs are ready quickly, others need more support first. We work with each dog at the pace that helps them feel secure and succeed.'
}
]
},
@@ -55,19 +55,21 @@ export const packWalksContent: ServicePageContent = {
fitItems: [
'Is small or medium-sized',
'Enjoys other dogs',
'Is sociable and adaptable',
'Can settle into a new environment',
'Has settled basic on-lead manners',
'Shows emerging recall or willingness to respond',
'Is up to date on vaccinations and Auckland Council registration'
],
notFitTitle: 'Better with a solo walk if your dog:',
notFitItems: [
'Is reactive on the lead',
'Is anxious in groups of dogs',
'Becomes overwhelmed by busy social environments',
'Is significantly larger than a typical pack member',
'Is recovering from injury or surgery',
'Needs a slower, steadier pace'
],
footnote: 'Not sure? The free Meet & Greet decides — and we will be straight with you.'
footnote: 'Dogs do not need to arrive perfect or fully off-leash ready. They do need to be suitable for a structured, supportive environment, and we will be straight with you about the fit.'
},
pricing: {
title: 'Pick the weekly routine that fits',
@@ -108,7 +110,7 @@ export const packWalksContent: ServicePageContent = {
},
benefits: {
title: 'Why the right dogs thrive in Tiny Gang',
intro: 'Small groups. Compatible dogs. No chaos. That\'s why it works.',
intro: 'Safe groups, clear structure, and the right pace for each dog. That is why it works.',
items: [
{
title: 'Calmer evenings at home',
@@ -118,13 +120,13 @@ export const packWalksContent: ServicePageContent = {
},
{
title: 'Confidence with the right dogs',
body: 'Matched groups. Right pace. No pressure from bigger mixed packs.',
body: 'Matched groups, right pace, and no random mixing. Dogs can build social confidence without being thrown in at the deep end.',
badge: 'Carefully matched groups',
icon: 'fas fa-user-group'
},
{
title: 'No bigger dogs to dodge',
body: 'Built for small and medium dogs. Group size, pace, and play style all picked to help them feel safe.',
title: 'Small and medium dogs only',
body: 'Built for small and medium dogs. Group size, pace, and play style are chosen to help them feel safe and capable.',
badge: 'Small & medium dogs',
icon: 'fas fa-shield-dog'
},
@@ -136,13 +138,13 @@ export const packWalksContent: ServicePageContent = {
},
{
title: 'Eyes on every dog',
body: 'Smaller groups mean we notice confidence, handling, and the small details that matter.',
body: 'Smaller groups mean we notice recall, confidence, behaviour shifts, and the small details that matter before they become bigger problems.',
badge: '48 dogs per walk',
icon: 'fas fa-eye'
},
{
title: 'Safer than a one-size pack',
body: 'Calm, compatible packs. Less intimidation. A safer walk than a one-size-fits-all approach.',
title: 'Safer than a one-size-fits-all pack',
body: 'Calm, compatible groups and earned freedom. Less intimidation, less chaos, and better decisions for each dog.',
badge: 'Calm, compatible packs',
icon: 'fas fa-shield-heart'
}
@@ -160,12 +162,12 @@ export const packWalksContent: ServicePageContent = {
{
question: 'How big are the pack walks?',
answer:
'4 to 8 dogs, carefully matched on size, energy, and play style. We never run oversized packs — that is the whole point.'
'4 to 8 dogs, carefully matched on temperament, confidence, energy, and play style. We never run oversized, random packs. That is the whole point.'
},
{
question: 'What size and type of dog suits a Tiny Gang walk?',
answer:
'Sociable small and medium dogs who enjoy other dogs. If your dog is reactive, anxious in groups, or much larger than the typical pack, a solo walk is the better fit and we will say so at the Meet & Greet.'
'Sociable small and medium dogs who enjoy other dogs, can settle into a new environment, and are suitable for a structured group setting. If your dog is reactive, anxious in groups, or much larger than the typical pack, a solo walk is the better fit, and we will say so at the Meet & Greet.'
},
{
question: 'How long is a Tiny Gang walk and where do you go?',
@@ -180,7 +182,17 @@ export const packWalksContent: ServicePageContent = {
{
question: 'How does my dog join the Tiny Gang?',
answer:
'Free Meet & Greet at home, then two assessment walks before a regular slot. Time for your dog to settle in without pressure.'
'Free Meet & Greet at home, then two assessment walks before a regular slot. That gives us time to assess fit properly, build confidence, and avoid rushing dogs into the wrong setup.'
},
{
question: 'Are all dogs off leash on Tiny Gang walks?',
answer:
'No. Off-leash time is only for dogs with reliable recall, appropriate social behaviour, and the confidence to manage that freedom well. Some dogs stay more managed, and that is intentional.'
},
{
question: 'Does my dog need perfect recall before joining Tiny Gang?',
answer:
'No. Dogs do not need to start perfect. What matters is that they are suitable for a structured, supportive environment and can build toward more freedom safely over time.'
},
{
question: 'Can I book casual or one-off pack walks?',
+15
View File
@@ -46,6 +46,21 @@ export const privacyPolicyContent: LegalPageContent = {
}
]
},
{
title: 'Analytics',
blocks: [
{
type: 'paragraph',
content:
'We use website analytics to understand how visitors use our site, such as which pages are most popular and how people find us. Some of this measurement runs in your browser through standard analytics tools, and some happens through our own systems. The browsing record we keep includes the pages you view, the buttons and links you click, the time of each action, and a random identifier stored in your browser. It does not include your name, email address, phone number, or anything else you type into our forms.'
},
{
type: 'paragraph',
content:
'If you do not submit an enquiry, this browsing record is deleted automatically within 24 hours. If you do submit an enquiry through our booking or contact forms, we keep your browsing record from that visit and link it to your enquiry so we can understand how you found us and what you were interested in before getting in touch. This information is visible only to the Goodwalk team through our internal dashboard and is used to provide a better response to your enquiry and improve our services. It is not shared with third parties or used for advertising. You can ask us to delete this record at any time by emailing us at info@goodwalk.co.nz.'
}
]
},
{
title: 'How we use your information',
blocks: [