diff --git a/deploy.env.template b/deploy.env.template index c121798..5893c6a 100644 --- a/deploy.env.template +++ b/deploy.env.template @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 178240e..eef65f9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker/postgres/init/004-session-events.sql b/docker/postgres/init/004-session-events.sql new file mode 100644 index 0000000..b5af378 --- /dev/null +++ b/docker/postgres/init/004-session-events.sql @@ -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); diff --git a/docs/README.md b/docs/README.md index 388f62b..e0d9eb5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/server-side-analytics.md b/docs/server-side-analytics.md new file mode 100644 index 0000000..48f68c1 --- /dev/null +++ b/docs/server-side-analytics.md @@ -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 **20–40% 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= # 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`. diff --git a/mail-api/db.py b/mail-api/db.py index d04a141..bb3be0a 100644 --- a/mail-api/db.py +++ b/mail-api/db.py @@ -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.""" diff --git a/mail-api/main.py b/mail-api/main.py index 8b01feb..e465b1f 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -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") diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 66c8f3f..c047d64 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -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): 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 = {}): void { + const normalizedName = sanitizeEventName(eventName); + if (!normalizedName) return; + + const enriched: Record = { + 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('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 = { 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 }); diff --git a/src/lib/components/BookingWizard.svelte b/src/lib/components/BookingWizard.svelte index 40a0751..d7e8551 100644 --- a/src/lib/components/BookingWizard.svelte +++ b/src/lib/components/BookingWizard.svelte @@ -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 = {}; 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; } diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index f12b2f1..2ce4eb5 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -35,7 +35,7 @@ $: navigationLinks = withAboutLink(footer.navigationLinks); -