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
+7
View File
@@ -19,6 +19,13 @@ ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false
# Server-side GA4 (ad-block-resistant fallback). See docs/server-side-analytics.md.
# GA4_MEASUREMENT_ID matches the ID in src/app.html.
# GA4_API_SECRET: GA4 admin → Data Streams → web stream → Measurement Protocol API secrets → Create.
# Leave blank to disable the forwarder (endpoint still accepts requests but skips the GA4 call).
GA4_MEASUREMENT_ID=G-K7TLSFJVP1
GA4_API_SECRET=
FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200
RATE_LIMIT_WINDOW_SECONDS=900
+2
View File
@@ -13,6 +13,8 @@ services:
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES: ${PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES:-false}
GA4_MEASUREMENT_ID: ${GA4_MEASUREMENT_ID:-}
GA4_API_SECRET: ${GA4_API_SECRET:-}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -0,0 +1,48 @@
-- Visitor journey reconstruction. Two tables, two retention policies.
--
-- session_events: every analytics event the SvelteKit /api/track endpoint
-- receives, keyed by the anon_id cookie set in hooks.server.ts. This buffer
-- is the source for journey reconstruction when a visitor submits the
-- booking form. It is pruned aggressively (24h TTL) so we don't hoard
-- behavioural data for visitors who never enquire.
--
-- submission_journeys: when a visitor submits the booking/contact form,
-- /api/track/promote copies their recent session_events into this table
-- linked to the submission email. These rows are kept so the owner can
-- review the journey from the CP dashboard.
--
-- The promotion step is the only place where an anonymous browsing record
-- becomes linked to a named person, and it only happens because that
-- person chose to submit a form. The privacy policy covers this.
create table if not exists session_events (
id bigserial primary key,
anon_id text not null,
event_name text not null,
page_path text,
params jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists session_events_anon_idx
on session_events (anon_id, created_at);
-- Used by the probabilistic prune in /api/track to find rows past TTL.
create index if not exists session_events_created_idx
on session_events (created_at);
create table if not exists submission_journeys (
id bigserial primary key,
email text not null,
anon_id text,
-- Snapshot of session_events rows at promotion time (server-captured).
events jsonb not null default '[]'::jsonb,
-- Client-side sessionStorage buffer sent with the promote request, as a
-- fallback for events that never reached /api/track (e.g. blocked at the
-- network layer alongside gtag).
client_events jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists submission_journeys_email_idx
on submission_journeys (email, created_at desc);
+1
View File
@@ -27,6 +27,7 @@ Reference, audit, and planning documents for the Goodwalk site. Project-level ru
- [deployment.md](deployment.md) — production deploy flow, server layout, nginx cutover
- [webp-conversion.md](webp-conversion.md) — one-time WebP setup for hero images
- [onboarding.md](onboarding.md) — client onboarding flow, lifecycle status, legacy CSV migration, Postgres target schema
- [server-side-analytics.md](server-side-analytics.md) — first-party `/api/track` endpoint that forwards to GA4 when ad blockers kill client-side gtag
## Archive
+116
View File
@@ -0,0 +1,116 @@
# Server-side analytics + visitor journey
Two things in one pipeline:
1. **Ad-block-resistant GA4** — forward events server-to-server when client-side `gtag.js` is blocked.
2. **Visitor journey reconstruction** — record every event into our own DB, and when a visitor submits the booking form, link their journey to that submission so the owner can review it in the CP dashboard.
## Why each piece exists
### Ad-block fallback
Browser ad blockers (uBlock, Brave, Safari ITP, AdGuard, Pi-hole) block requests to `googletagmanager.com` and `google-analytics.com`. For NZ consumer traffic that's roughly **2040% of visits silently lost**. The fix is a first-party endpoint on our own domain that blocklists don't match, which forwards to GA4 via the Measurement Protocol.
### Journey reconstruction
GA4 is aggregate. The owner can see "200 hero CTA clicks this week" but not "this specific submission's journey was /pricing → /about → hero CTA → form." For a small services business, knowing what a *specific lead* engaged with before submitting is more useful than another aggregate dashboard.
## Architecture
```
Browser ── trackEvent() ──┬─► gtag (when not blocked) ─► GA4
└─► /api/track (always) ─┬─► session_events table
└─► GA4 (only when gtag missing)
Browser also keeps a rolling sessionStorage buffer of the last 30 events
as a fallback (in case /api/track is itself blocked at the network layer).
On booking form submit success:
Browser ── promoteJourney(email) ──► /api/track/promote
├─► reads session_events for this anon_id
├─► reads sessionStorage buffer from request body
└─► writes one row to submission_journeys
Owner opens enquiry in CP dashboard:
AdminDashboard ── /api/owner/client-enquiry?email=... ──► mail-api
├─► returns enquiry record
└─► returns submission_journeys row
```
## Tables (`docker/postgres/init/004-session-events.sql`)
### `session_events`
Every analytics event, keyed by the `anonId` cookie set in `src/hooks.server.ts`. **Pruned after 24h** by a probabilistic cleanup inside `/api/track` (~1 in 200 inserts triggers a `DELETE WHERE created_at < now() - 24h`). No cron container needed — cleanup runs naturally with traffic.
### `submission_journeys`
Promoted journeys keyed by email. **Not auto-pruned.** Owner-facing data. Contains:
- `events` — snapshot of `session_events` rows at promotion time (server-captured)
- `client_events` — the sessionStorage buffer the client posted (fallback)
The merge happens in the CP UI (`AdminDashboard.mergedJourneyEvents`), de-duped by `name|page_path|ts`.
## De-duplication
- **GA4** receives each event exactly once: client when gtag is loaded, server when it isn't. The `forward_ga4` flag in the `/api/track` body controls this.
- **session_events** receives every event once (always written server-side).
- **Journey display** merges server + client events with key `name|page_path|ts`.
## Privacy
Disclosed in `src/lib/content/privacy-policy.ts` under the **Analytics** section. The key promises:
- Browsing record contains pages, clicks, timestamps, and a random browser ID — never name/email/phone or form contents.
- Unsubmitted journeys are deleted within 24h.
- Submitted journeys are linked to the enquiry email, visible only to the Goodwalk team, never shared or used for advertising.
- Users can request deletion at info@goodwalk.co.nz.
**Update the policy in the same PR** if you ever change what's stored or how long.
## Configuration
```bash
GA4_MEASUREMENT_ID=G-K7TLSFJVP1 # already in deploy.env.template
GA4_API_SECRET=<from GA4 admin> # required for the GA4 forward
```
To get the API secret: GA4 admin → Data Streams → web stream → Measurement Protocol API secrets → Create. Without it, `/api/track` still records to `session_events` (journey works) — only the GA4 forward is off.
## Files
- `src/routes/api/track/+server.ts` — main ingest, persists + forwards
- `src/routes/api/track/promote/+server.ts` — links journey to submission email
- `src/lib/analytics.ts` — client `trackEvent`, sessionStorage buffer, `promoteJourney(email)`
- `src/lib/components/BookingWizard.svelte` — calls `promoteJourney(email)` on submit success
- `mail-api/db.py``get_submission_journey(email)` reader
- `mail-api/main.py``/owner/client-enquiry` returns `{enquiry, journey}`
- `src/lib/components/admin-dashboard/AdminDashboard.svelte` — renders the **Visitor journey** section in the enquiry modal
- `src/lib/content/privacy-policy.ts` — disclosure
- `docker/postgres/init/004-session-events.sql` — table definitions
## Testing locally
Without env vars set (no GA4 forwarding):
```bash
curl -X POST http://localhost:5173/api/track \
-H 'content-type: application/json' \
-H 'user-agent: Mozilla/5.0' \
-d '{"name":"test_event","params":{"label":"manual","page_path":"/"}}'
```
Then check the row landed:
```bash
docker exec -it goodwalk_svelte_db psql -U goodwalk -d goodwalk \
-c "select event_name, page_path, created_at from session_events order by id desc limit 5;"
```
To test the full journey flow locally, submit a booking through the wizard with a test email, then open `cp.goodwalk.local` (or use `?preview=cp` on localhost), open the enquiry for that email, and the **Visitor journey** panel should list every page view and click that led to the submission.
## What this does NOT do
- **Meta Pixel / Facebook Ads** — same blocker problem, different fix (Conversions API). Not built.
- **Real-time owner notifications** — journey is visible only after submission, not as a live feed of who's on the site.
- **Cross-device journey** — anon_id is per-browser. A visitor who researches on phone then submits on laptop produces two separate (mostly empty) journeys.
- **Consent banner** — NZ has no explicit cookie law today. If we ever serve EU/UK traffic, we need Consent Mode v2 before this pipeline is legal there for the GA4 forward.
- **Pruning of `submission_journeys`** — these are kept indefinitely. If you want a max retention (e.g. delete journeys older than 12 months), add a cron or extend the probabilistic cleanup in `/api/track`.
+51
View File
@@ -303,6 +303,57 @@ async def set_kv(key: str, value: Any) -> bool:
return True
async def get_submission_journey(email: str) -> dict | None:
"""Return the most recent submission_journeys row for the given email, or
None when no journey was promoted (or DB is off). The table is owned by
the SvelteKit app — see src/routes/api/track/promote — but the mail-api
reads it here so the owner dashboard's enquiry view can render it
alongside the booking record."""
pool = await get_pool()
if pool is None:
return None
normalized = (email or "").strip().lower()
if not normalized:
return None
try:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
select id, anon_id, events, client_events, created_at
from submission_journeys
where email = $1
order by created_at desc
limit 1
""",
normalized,
)
except Exception as exc:
# Table may not exist yet in environments where the new init script
# hasn't run. Don't break the enquiry view over it.
logger.warning("submission_journeys read failed: %s", exc)
return None
if not row:
return None
def _parse(value: Any) -> Any:
if isinstance(value, (list, dict)):
return value
if isinstance(value, (bytes, bytearray)):
value = value.decode("utf-8")
try:
return json.loads(value) if value else []
except Exception:
return []
return {
"id": row["id"],
"anonId": row["anon_id"],
"events": _parse(row["events"]),
"clientEvents": _parse(row["client_events"]),
"createdAt": row["created_at"].isoformat() if row["created_at"] else None,
}
async def has_any_value() -> bool:
"""Return True if admin_kv already has any rows. Used to decide whether
to seed from JSON files on first boot."""
+6 -1
View File
@@ -3165,7 +3165,12 @@ async def owner_client_enquiry(request: Request):
"referrer": "",
"page": "",
}
return {"ok": True, "enquiry": enquiry}
# Journey is populated by the SvelteKit /api/track/promote endpoint when
# the visitor submits the booking form. None means we never recorded a
# journey for this email (legacy submission, ad-blocker that also blocked
# /api/track, or DB-less local dev).
journey = await admin_db.get_submission_journey(email)
return {"ok": True, "enquiry": enquiry, "journey": journey}
@app.get("/owner/activity")
+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: [
+2 -1
View File
@@ -110,7 +110,7 @@
}
onMount(() => {
initClickTracking();
const cleanupClickTracking = initClickTracking();
requestAnimationFrame(initReveal);
restoreScrollPosition();
@@ -142,6 +142,7 @@
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
cleanupClickTracking();
window.removeEventListener('hashchange', onHashChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
+184
View File
@@ -0,0 +1,184 @@
import { json, error } from '@sveltejs/kit';
import { getPool } from '$lib/server/db';
// First-party analytics ingest. Two jobs:
//
// 1. Persist every event into `session_events` keyed by the anon_id cookie
// set in hooks.server.ts. That table powers visitor-journey
// reconstruction when someone submits the booking form. Old rows are
// pruned after 24h (see PRUNE_TTL_HOURS).
//
// 2. When the client signals that gtag.js failed to load (`forward_ga4`),
// forward the event server-to-server to GA4 via the Measurement
// Protocol so we don't lose analytics for ad-blocked visitors.
//
// Disclosure for this pipeline lives in the Privacy Policy under
// "Analytics". Keep the two in sync if you change what we send or store.
const ALLOWED_EVENT_NAME = /^[a-z][a-z0-9_]{0,39}$/;
const MAX_PARAM_KEY = 40;
const MAX_PARAM_VALUE = 200;
const MAX_PARAMS = 25;
const MAX_BODY_BYTES = 8 * 1024;
const MAX_PAGE_PATH = 512;
const BOT_UA_RE = /bot|spider|crawl|slurp|facebookexternalhit|whatsapp|telegram|preview|monitor|pingdom|uptime|lighthouse|headlesschrome|axios|curl|wget|python-requests/i;
const PRUNE_TTL_HOURS = 24;
const PRUNE_PROBABILITY = 0.005; // ~1 in 200 inserts triggers a cleanup pass
const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
let schemaReady: Promise<void> | null = null;
function ensureSchema(pool: ReturnType<typeof getPool>): Promise<void> {
if (!pool) return Promise.resolve();
if (!schemaReady) {
schemaReady = pool
.query(
`create table if not exists session_events (
id bigserial primary key,
anon_id text not null,
event_name text not null,
page_path text,
params jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists session_events_anon_idx
on session_events (anon_id, created_at);
create index if not exists session_events_created_idx
on session_events (created_at);`
)
.then(() => undefined)
.catch((err) => {
schemaReady = null;
throw err;
});
}
return schemaReady;
}
function parseGaClientId(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const match = cookieHeader.match(/(?:^|;\s*)_ga=([^;]+)/);
if (!match) return null;
const parts = match[1].split('.');
if (parts.length < 4) return null;
return `${parts[2]}.${parts[3]}`;
}
function sanitiseParams(input: unknown): Record<string, string | number | boolean> {
if (!input || typeof input !== 'object') return {};
const out: Record<string, string | number | boolean> = {};
let count = 0;
for (const [rawKey, rawVal] of Object.entries(input as Record<string, unknown>)) {
if (count >= MAX_PARAMS) break;
const key = rawKey.slice(0, MAX_PARAM_KEY).replace(/[^a-zA-Z0-9_]/g, '_');
if (!key) continue;
if (typeof rawVal === 'string') out[key] = rawVal.slice(0, MAX_PARAM_VALUE);
else if (typeof rawVal === 'number' && Number.isFinite(rawVal)) out[key] = rawVal;
else if (typeof rawVal === 'boolean') out[key] = rawVal;
else continue;
count += 1;
}
return out;
}
async function forwardToGa4(
ua: string,
ip: string,
clientId: string,
name: string,
params: Record<string, unknown>
): Promise<void> {
const measurementId = process.env.GA4_MEASUREMENT_ID;
const apiSecret = process.env.GA4_API_SECRET;
if (!measurementId || !apiSecret) return;
const payload = {
client_id: clientId,
events: [{ name, params: { engagement_time_msec: 1, ...params } }]
};
try {
const res = await fetch(
`${GA4_ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': ua, 'x-forwarded-for': ip },
body: JSON.stringify(payload)
}
);
if (!res.ok) {
console.error('GA4 MP forward failed', res.status, await res.text().catch(() => ''));
}
} catch (err) {
console.error('GA4 MP forward threw', err);
}
}
async function maybePrune(pool: ReturnType<typeof getPool>): Promise<void> {
if (!pool) return;
if (Math.random() > PRUNE_PROBABILITY) return;
try {
await pool.query(
`delete from session_events where created_at < now() - ($1 || ' hours')::interval`,
[String(PRUNE_TTL_HOURS)]
);
} catch (err) {
console.error('session_events prune failed', err);
}
}
export async function POST({ request, locals, getClientAddress }) {
const ua = request.headers.get('user-agent') ?? '';
if (!ua || BOT_UA_RE.test(ua)) {
return json({ ok: true, recorded: false, reason: 'ua' });
}
const contentLength = Number(request.headers.get('content-length') ?? '0');
if (contentLength > MAX_BODY_BYTES) throw error(413, 'payload too large');
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
throw error(400, 'invalid json');
}
const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
if (!ALLOWED_EVENT_NAME.test(name)) throw error(400, 'bad event name');
const params = sanitiseParams(body.params);
const pagePath =
typeof params.page_path === 'string' ? params.page_path.slice(0, MAX_PAGE_PATH) : null;
const forwardGa4 = body.forward_ga4 === true;
const anonId = locals.anonId;
const pool = getPool();
// Persist to session_events when we have both an anonId (marketing surface)
// and a DB. Either being missing is fine — we still respond OK.
if (anonId && pool) {
try {
await ensureSchema(pool);
await pool.query(
`insert into session_events (anon_id, event_name, page_path, params)
values ($1, $2, $3, $4::jsonb)`,
[anonId, name, pagePath, JSON.stringify(params)]
);
// Fire-and-forget probabilistic cleanup. Run after insert so a failure
// in prune never blocks the recorded event.
maybePrune(pool);
} catch (err) {
console.error('session_events insert failed', err);
// Don't 500 the client — analytics must never break the page.
}
}
if (forwardGa4) {
const ip = getClientAddress();
const clientId =
parseGaClientId(request.headers.get('cookie')) ?? anonId ?? crypto.randomUUID();
await forwardToGa4(ua, ip, clientId, name, params);
}
return json({ ok: true });
}
+136
View File
@@ -0,0 +1,136 @@
import { json, error } from '@sveltejs/kit';
import { getPool } from '$lib/server/db';
// Promote a visitor's recent session_events into submission_journeys so the
// owner can review the full pre-submission journey from the CP dashboard.
// Called by the booking form on submit success. Without this call, the
// session_events rows expire after 24h and the journey is gone forever.
//
// This endpoint is the moment an anonymous browsing record becomes linked
// to a named person. The Privacy Policy must disclose it; do not call it
// from anywhere other than a form submission the user explicitly made.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_CLIENT_EVENTS = 60;
const MAX_BODY_BYTES = 32 * 1024;
const LOOKBACK_HOURS = 6;
const BOT_UA_RE = /bot|spider|crawl|slurp|facebookexternalhit|whatsapp|telegram|preview|monitor|pingdom|uptime|lighthouse|headlesschrome|axios|curl|wget|python-requests/i;
let schemaReady: Promise<void> | null = null;
function ensureSchema(pool: ReturnType<typeof getPool>): Promise<void> {
if (!pool) return Promise.resolve();
if (!schemaReady) {
schemaReady = pool
.query(
`create table if not exists submission_journeys (
id bigserial primary key,
email text not null,
anon_id text,
events jsonb not null default '[]'::jsonb,
client_events jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists submission_journeys_email_idx
on submission_journeys (email, created_at desc);`
)
.then(() => undefined)
.catch((err) => {
schemaReady = null;
throw err;
});
}
return schemaReady;
}
function sanitiseClientEvents(input: unknown): unknown[] {
if (!Array.isArray(input)) return [];
return input
.slice(0, MAX_CLIENT_EVENTS)
.filter((entry) => entry && typeof entry === 'object')
.map((entry) => {
const e = entry as Record<string, unknown>;
return {
name: typeof e.name === 'string' ? e.name.slice(0, 40) : '',
page_path: typeof e.page_path === 'string' ? e.page_path.slice(0, 512) : '',
label: typeof e.label === 'string' ? e.label.slice(0, 200) : '',
ts: typeof e.ts === 'number' && Number.isFinite(e.ts) ? e.ts : null
};
});
}
export async function POST({ request, locals }) {
const ua = request.headers.get('user-agent') ?? '';
if (!ua || BOT_UA_RE.test(ua)) {
return json({ ok: true, promoted: false, reason: 'ua' });
}
const contentLength = Number(request.headers.get('content-length') ?? '0');
if (contentLength > MAX_BODY_BYTES) throw error(413, 'payload too large');
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
throw error(400, 'invalid json');
}
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
if (!email || !EMAIL_RE.test(email) || email.length > 320) {
throw error(400, 'bad email');
}
const clientEvents = sanitiseClientEvents(body.client_events);
const anonId = locals.anonId ?? null;
const pool = getPool();
// Without a DB we can't persist, but we still return OK so the form's
// success path is unaffected. Local dev without Postgres should not break
// booking submissions.
if (!pool) {
return json({ ok: true, promoted: false, reason: 'no_db' });
}
let serverEvents: unknown[] = [];
if (anonId) {
try {
// Lookback window guards against picking up a stale session that
// happens to share the anon_id cookie from days earlier.
const result = await pool.query(
`select event_name, page_path, params, created_at
from session_events
where anon_id = $1
and created_at >= now() - ($2 || ' hours')::interval
order by created_at asc
limit 500`,
[anonId, String(LOOKBACK_HOURS)]
);
serverEvents = result.rows.map((row: Record<string, unknown>) => ({
name: row.event_name,
page_path: row.page_path,
params: row.params,
ts: row.created_at
}));
} catch (err) {
console.error('session_events read failed during promote', err);
}
}
try {
await ensureSchema(pool);
await pool.query(
`insert into submission_journeys (email, anon_id, events, client_events)
values ($1, $2, $3::jsonb, $4::jsonb)`,
[email, anonId, JSON.stringify(serverEvents), JSON.stringify(clientEvents)]
);
} catch (err) {
console.error('submission_journeys insert failed', err);
throw error(500, 'insert failed');
}
return json({
ok: true,
promoted: true,
server_events: serverEvents.length,
client_events: clientEvents.length
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
import { getHomepageContent } from '$lib/server/content';
import type { HomePageContent, TestimonialContent } from '$lib/types';
// Variant goal: render the live homepage end-to-end, but shift the messaging
// off 1:1 solo walks and onto Tiny Gang pack walks, with puppy visits framed
// as the on-ramp that trains pups to graduate into the pack.
function applyPackFirstOverrides(base: HomePageContent): HomePageContent {
const content = structuredClone(base);
// ── SEO ──────────────────────────────────────────────────────────────
content.seo = {
title: 'Auckland Pack Walks for Small Dogs | Tiny Gang | Goodwalk',
description:
"Goodwalk's Tiny Gang is a small-group pack walk for sociable Auckland dogs. Puppy Academy trains pups to graduate into the pack. Solo walks available on request."
};
// ── Hero ─────────────────────────────────────────────────────────────
// Keep the live homepage hero — same Maya image, same headline, same CTAs.
// The pack-first pivot lives in the sections below, not the first scroll.
// (Hero intentionally left untouched.)
// ── Services: pack walks lead, puppy academy second, solo walks demoted
content.services = [
{
icon: 'fas fa-paw',
title: 'Tiny Gang Pack Walks',
body: 'Our flagship walk. Small group of 48 well-matched dogs. Same walker. Real friends.',
priceFrom: 'From $49.50 / walk',
href: '/pack-walks'
},
{
icon: 'fas fa-graduation-cap',
title: 'Puppy Academy',
body: 'In-home visits that quietly train your pup to graduate into the Tiny Gang.',
priceFrom: 'From $39 / visit',
href: '/puppy-visits'
},
{
icon: 'fas fa-person-walking',
title: 'Solo Walks (on request)',
body: 'For dogs the pack does not suit — reactive on lead, recovering, or larger than the gang.',
priceFrom: 'From $45 / walk',
href: '/dog-walking'
}
];
// ── How it works: reframe as the graduation pipeline
content.howItWorks = {
title: 'How a dog joins the pack',
intro: 'Pack walks are not booked. They are earned. Here is the path.',
steps: [
{
phase: 'Apply',
benefit: 'Right fit before anything else',
title: 'Free Meet & Greet at home.',
body:
'We meet your dog where they live. We talk temperament, energy, and play style — the things that decide pack fit.',
icon: 'fas fa-handshake'
},
{
phase: 'Train',
benefit: 'Foundations that hold up in a group',
title: 'Puppy Academy or assessment walks.',
body:
'Puppies do home visits and short outings. Older dogs do two assessment walks. We build manners and trust before any group meets them.',
icon: 'fas fa-graduation-cap'
},
{
phase: 'Graduate',
benefit: 'A weekly slot with real friends',
title: 'Welcome to the Tiny Gang.',
body:
'A regular weekly walk with the same small pack. Same walker, same dogs, same parks. Calmer evenings start here.',
icon: 'fas fa-paw'
}
]
};
// ── Founder story: lean on the pack
content.founderStory = {
...content.founderStory,
title: 'Not just dog walking.',
subtitle: 'Built around the pack.',
body: [
'Most dog walkers sell minutes on a lead. We build a pack.',
'Small groups of well-matched dogs, the same walker every week, and a puppy programme that grows the next intake from scratch.',
'You know who has your dog. Your dog knows the friends they are meeting. And you come home to a tired, happy one. Ready to'
],
emphasis: 'join the Tiny Gang?'
};
// ── Values: reorder so the pack-related lines lead
content.values = [
{
icon: 'fas fa-users',
title: 'Matched, not just grouped',
body: '4 to 8 dogs, matched on size, energy, and play style. Small and medium only.'
},
{
icon: 'fas fa-graduation-cap',
title: 'Puppy Academy on-ramp',
body: 'Pups train in-home, then graduate into the pack when they are ready.'
},
{
icon: 'fas fa-heart',
title: 'Calm, kind handling',
body: 'Patient routines. Confidence over stress.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety, by default',
body: 'Pet first aid certified. Careful screening. Proactive handling.'
},
{
icon: 'fas fa-camera',
title: 'Updates you actually want',
body: 'See your dog out with their friends. Less wondering.'
},
{
icon: 'fas fa-calendar-check',
title: 'Built for real schedules',
body: 'Regular weekly slot. Life changes; we adjust.'
}
];
// ── Testimonials: pack-walk reviews lead the slider
const isPack = (t: TestimonialContent) =>
!!t.service && t.service.toLowerCase().includes('pack');
content.testimonials = [
...content.testimonials.filter(isPack),
...content.testimonials.filter((t) => !isPack(t))
].map((t, i) => ({
...t,
showInSlider: i < 4 ? true : t.showInSlider
}));
// ── Booking: pack walks first in the picker
content.booking = {
...content.booking,
title: 'Apply to join the Tiny Gang',
subtitle:
"A few details about your dog. We reply within 24 hours to set up your free Meet & Greet.",
serviceOptions: ['Tiny Gang Pack Walks', 'Puppy Academy', 'Solo Walks (on request)', 'Other']
};
// ── Mega menu mirror so the Header reflects the variant ordering
content.navigation = {
...content.navigation,
megaMenuServices: [
{
icon: 'fas fa-paw',
label: 'Tiny Gang Pack Walks',
description: 'Our flagship — small, well-matched groups',
href: '/pack-walks'
},
{
icon: 'fas fa-graduation-cap',
label: 'Puppy Academy',
description: 'In-home visits, on-ramp to the pack',
href: '/puppy-visits'
},
{
icon: 'fas fa-person-walking',
label: 'Solo Walks (on request)',
description: 'For dogs the pack does not suit',
href: '/dog-walking'
}
]
};
return content;
}
export async function load() {
const base = await getHomepageContent();
const content = applyPackFirstOverrides(base);
return {
siteVariant: 'marketing' as const,
content
};
}
@@ -0,0 +1,40 @@
<script lang="ts">
import SeoHead from '$lib/components/SeoHead.svelte';
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import HeroSection from '$lib/components/HeroSection.svelte';
import HowItWorksSection from '$lib/components/HowItWorksSection.svelte';
import InfoSection from '$lib/components/InfoSection.svelte';
import InstagramSection from '$lib/components/InstagramSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import FounderStorySection from '$lib/components/FounderStorySection.svelte';
import ServicesSection from '$lib/components/ServicesSection.svelte';
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
import ValuesSection from '$lib/components/ValuesSection.svelte';
import type { PageData } from './$types';
export let data: PageData;
$: content = data.content;
</script>
<SeoHead
title={content.seo.title}
description={content.seo.description}
canonicalPath="/variants/pack-first"
image={content.hero.imageUrl}
imageAlt={content.hero.imageAlt}
noindex={true}
/>
<Header navigation={content.navigation} />
<HeroSection hero={content.hero} reviewCta={content.intro.reviewCta} />
<ValuesSection values={content.values} />
<ServicesSection services={content.services} />
<HowItWorksSection content={content.howItWorks} />
<TestimonialsSection testimonials={content.testimonials} seedKey="/variants/pack-first" />
<FounderStorySection founderStory={content.founderStory} />
<InfoSection info={content.info} />
<BookingWizard booking={content.booking} pagePath="/variants/pack-first" />
<InstagramSection instagram={content.instagram} />
<Footer footer={content.footer} />