v4.1 - Admin/onboarding

This commit is contained in:
2026-05-18 22:25:43 +12:00
parent 6ff970015f
commit 541ae2eeec
79 changed files with 11544 additions and 1007 deletions
+4 -1
View File
@@ -4,7 +4,10 @@
"Bash(kill %1)",
"Bash(pkill -f \"vite dev\")",
"Bash(npm run *)",
"WebFetch(domain:raw.githubusercontent.com)"
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(node \"C:\\\\Users\\\\mattc\\\\.claude\\\\plugins\\\\cache\\\\impeccable\\\\impeccable\\\\3.1.1\\\\skills\\\\impeccable\\\\scripts\\\\load-context.mjs\")",
"Bash(cat \"C:\\\\Users\\\\mattc\\\\.claude\\\\plugins\\\\cache\\\\impeccable\\\\impeccable\\\\3.1.1\\\\skills\\\\impeccable\\\\reference\\\\distill.md\" 2>&1 | head -100)",
"Read(//c/Users/mattc/.claude/plugins/cache/impeccable/impeccable/3.1.1/skills/impeccable/reference/**)"
]
}
}
+56 -1
View File
@@ -1,5 +1,45 @@
# Deployment
## Hosts served by this stack
The Goodwalk Svelte stack serves three subdomains from the same SvelteKit app
container, routed by Host header at nginx:
| Hostname | Purpose |
|-----------------------------|--------------------------------------------|
| `goodwalk.co.nz` / `www.…` | Public marketing site |
| `onboarding.goodwalk.co.nz` | New-client onboarding flow |
| `admin.goodwalk.co.nz` | Owner admin dashboard (Aless only) |
The admin host needs its own TLS certificate at
`/etc/letsencrypt/live/admin.goodwalk.co.nz/`. Issue it once before the first
nginx reload, e.g.:
```bash
docker compose -p nginx -f /docker/nginx/docker-compose.yml exec nginx \
certbot certonly --webroot -w /var/www/certbot -d admin.goodwalk.co.nz
```
The dashboard's data (`client_profiles`, `allowed_emails`, `drafts`) lives in
the shared postgres database alongside the marketing site content, in a single
`admin_kv` table created by `docker/postgres/init/002-admin-kv.sql`. The
mail-api connects with the same `DATABASE_URL` the SvelteKit app uses.
### Seeding admin_kv from the old JSON files
Existing installs have admin data in `client_profiles.json`,
`allowed_emails.json`, and `drafts.json` on the mail-api Docker volume. To copy
that data into postgres on the next deploy, run:
```powershell
./deploy.ps1 -SeedAdminData
```
That sets `ADMIN_DATA_SEED_FROM_JSON=force` for the mail-api container, which
overwrites `admin_kv` from the JSON files on the next boot. Subsequent deploys
default back to `auto` (seed only when `admin_kv` is empty), so they are no-ops
for the seed. Use `-SeedAdminData` again if you ever need to force a re-seed.
## Server layout confirmed
The production server currently runs multiple separate Docker Compose projects:
@@ -73,9 +113,10 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env
APP_VERSION=4.2.3
APP_VERSION=4.0.0
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -87,6 +128,7 @@ RESEND_API_KEY=replace-me
OWNER_EMAIL=replace-me
FROM_EMAIL=GoodWalk <bookings@goodwalk.co.nz>
REPLY_TO=aless@goodwalk.co.nz
MAIL_API_DATA_DIR=/app/data
FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200
@@ -105,6 +147,7 @@ Frontend flags:
- `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false` keeps the sticky mobile booking CTA hidden.
- Set `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=true` to show it again.
- `PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false` skips eager `@sveltejs/enhanced-img` processing for content images during production builds. Turn it on only if you intentionally want non-WebP images from `src/lib/images` to go through the imagetools pipeline.
4. Confirm the shared Docker network already exists:
@@ -218,6 +261,18 @@ The deployment flow now handles that automatically:
This means future deploys will carry your latest file-based homepage/navigation/
shared content changes into production PostgreSQL automatically.
## Mail auth persistence
The mail API stores auth state in `DATA_DIR`, including:
- `allowed_emails.json`
- `client_profiles.json`
- `drafts.json`
Both compose files now mount a named Docker volume at `MAIL_API_DATA_DIR`
(default `/app/data`) so previously registered client emails and saved drafts
survive container rebuilds and redeploys.
## Cutover nginx
After the new Svelte stack is up and healthy, update the shared nginx config on
+3 -2
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.2.3
ARG APP_VERSION=4.0.0
FROM node:22-alpine AS builder
ARG APP_VERSION
@@ -6,7 +6,8 @@ ARG APP_VERSION
WORKDIR /app
COPY package.json ./
RUN npm install
COPY package-lock.json ./
RUN npm ci
COPY . .
RUN node --experimental-strip-types --import="file:///app/scripts/sveltekit-resolver.mjs" scripts/export-homepage-content.mjs
+3 -1
View File
@@ -1,4 +1,4 @@
APP_VERSION=4.2.3
APP_VERSION=4.0.0
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -12,8 +12,10 @@ OWNER_BCC=mattcohen0@gmail.com
CLIENT_BCC=mattcohen0@gmail.com
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
REPLY_TO=info@goodwalk.co.nz
MAIL_API_DATA_DIR=/app/data
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false
FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200
+19 -2
View File
@@ -2,7 +2,13 @@
param(
[switch]$Force,
[switch]$SkipSiteCheck,
[string]$Service
[string]$Service,
# When set, the next mail-api boot copies admin state (client_profiles,
# allowed_emails, drafts) from the on-disk JSON files into the shared
# postgres database, overwriting anything currently in admin_kv. After the
# deploy completes the flag is automatically reset to 'auto' for subsequent
# boots so we don't keep overwriting live data.
[switch]$SeedAdminData
)
# ---------------------------------------------------------------------------
@@ -242,6 +248,9 @@ Write-Host "[deploy] SSH config: $SshConfigPath"
if (-not [string]::IsNullOrWhiteSpace($Service)) {
Write-Host "[deploy] Target service: $Service"
}
if ($SeedAdminData) {
Write-Host '[deploy] Admin data: seeding postgres from JSON on next mail-api boot'
}
if ([string]::IsNullOrWhiteSpace($SshKeyPath)) {
Write-Host '[deploy] SSH auth: interactive password prompt'
} else {
@@ -254,6 +263,13 @@ Write-Host ' - Legacy WordPress/onboarding compose files are not used.'
Write-Host ' - Remote .env files are preserved because they are not uploaded.'
Write-Host ' - No global Docker prune/stop/delete commands are used.'
Write-Host ' - Shared nginx will be updated and reloaded with the Docker-DNS-based config.'
Write-Host ' - Subdomains served by this stack:'
Write-Host ' goodwalk.co.nz / www.goodwalk.co.nz (marketing)'
Write-Host ' onboarding.goodwalk.co.nz (client onboarding)'
Write-Host ' admin.goodwalk.co.nz (owner admin dashboard)'
if ($SeedAdminData) {
Write-Host ' - Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.'
}
if (-not $Force) {
$confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'"
@@ -310,7 +326,8 @@ try {
$MaintenanceHostDir,
'--maintenance-flag',
$MaintenanceFlagPath
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }))
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }) `
+ $(if ($SeedAdminData) { @('--seed-admin-data') } else { @() }))
Write-Host ''
Write-Host '[deploy] Cleaning remote temporary files'
+15 -4
View File
@@ -3,15 +3,16 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
container_name: goodwalk_svelte_app
environment:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: 3000
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}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -26,10 +27,12 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
container_name: goodwalk_svelte_mail_api
depends_on:
- db
environment:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
@@ -37,6 +40,11 @@ services:
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <info@goodwalk.co.nz>}
REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
DATA_DIR: ${MAIL_API_DATA_DIR:-/app/data}
# Shared postgres for admin state (client_profiles, allowed_emails, drafts).
# When unset the mail-api falls back to JSON files under DATA_DIR.
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
ADMIN_DATA_SEED_FROM_JSON: ${ADMIN_DATA_SEED_FROM_JSON:-auto}
FORM_MIN_SECONDS: ${FORM_MIN_SECONDS:-4}
FORM_MAX_SECONDS: ${FORM_MAX_SECONDS:-7200}
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-900}
@@ -47,6 +55,8 @@ services:
TZ: ${TZ:-Pacific/Auckland}
expose:
- '8000'
volumes:
- mail_api_data:${MAIL_API_DATA_DIR:-/app/data}
restart: unless-stopped
networks:
- default
@@ -68,6 +78,7 @@ services:
volumes:
postgres_data:
mail_api_data:
networks:
webnet:
+9 -4
View File
@@ -3,14 +3,15 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
environment:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: ${APP_PORT:-3000}
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}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -20,9 +21,9 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
environment:
APP_VERSION: ${APP_VERSION:-4.2.3}
APP_VERSION: ${APP_VERSION:-4.0.0}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
@@ -30,7 +31,10 @@ services:
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <info@goodwalk.co.nz>}
REPLY_TO: ${REPLY_TO:-info@goodwalk.co.nz}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
DATA_DIR: ${MAIL_API_DATA_DIR:-/app/data}
TZ: ${TZ:-Pacific/Auckland}
volumes:
- mail_api_data:${MAIL_API_DATA_DIR:-/app/data}
restart: unless-stopped
db:
@@ -60,3 +64,4 @@ services:
volumes:
postgres_data:
mail_api_data:
+12
View File
@@ -0,0 +1,12 @@
-- Key/value table used by mail-api for admin state.
-- Three keys are written from main.py:
-- client_profiles -> dict[email, profile]
-- allowed_emails -> {"emails": [..]}
-- drafts -> dict[email, drafts]
-- Stored as JSONB blobs to match the existing dict-shaped storage in the
-- application and to avoid coupling the DB schema to mail-api shape changes.
create table if not exists admin_kv (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
);
+36
View File
@@ -0,0 +1,36 @@
18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s)
18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
18/05/2026 19:34:41 New Zealand Standard Time INFO mail-api: [ab4f9aaf] auth: code issued for email=info@goodwalk.co.nz
18/05/2026 19:34:41 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 625809
18/05/2026 19:34:41 New Zealand Standard Time INFO mail-api: [ab4f9aaf] POST /auth/request-code → 200 (3ms)
18/05/2026 19:34:47 New Zealand Standard Time INFO mail-api: [a5564b23] auth: session created for email=info@goodwalk.co.nz
18/05/2026 19:34:47 New Zealand Standard Time INFO mail-api: [a5564b23] POST /auth/verify-code → 200 (1ms)
18/05/2026 19:34:47 New Zealand Standard Time INFO mail-api: [f0a2b1fe] GET /auth/verify → 200 (0ms)
18/05/2026 19:35:42 New Zealand Standard Time INFO mail-api: [c3a5a829] GET /auth/verify → 200 (0ms)
18/05/2026 19:35:42 New Zealand Standard Time INFO mail-api: [0bd54996] GET /owner/pending-onboarding → 200 (1ms)
18/05/2026 19:35:43 New Zealand Standard Time INFO mail-api: [ce90270c] GET /auth/verify → 200 (1ms)
18/05/2026 19:35:43 New Zealand Standard Time INFO mail-api: [d3d6a292] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:35:52 New Zealand Standard Time INFO mail-api: [758551a5] POST /auth/logout → 200 (0ms)
18/05/2026 19:35:55 New Zealand Standard Time INFO mail-api: [901ce77f] auth: code issued for email=info@goodwalk.co.nz
18/05/2026 19:35:55 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 166556
18/05/2026 19:35:55 New Zealand Standard Time INFO mail-api: [901ce77f] POST /auth/request-code → 200 (1ms)
18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [0f092606] auth: session created for email=info@goodwalk.co.nz
18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [0f092606] POST /auth/verify-code → 200 (1ms)
18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [2c504fc8] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [5f742030] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:05 New Zealand Standard Time INFO mail-api: [01546195] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:05 New Zealand Standard Time INFO mail-api: [6c10de94] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:20 New Zealand Standard Time INFO mail-api: [8740c583] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:20 New Zealand Standard Time INFO mail-api: [d8025471] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:21 New Zealand Standard Time INFO mail-api: [c45f3685] GET /auth/verify → 200 (1ms)
18/05/2026 19:36:21 New Zealand Standard Time INFO mail-api: [2f8d224d] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:33 New Zealand Standard Time INFO mail-api: [48041b64] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:33 New Zealand Standard Time INFO mail-api: [d3946240] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:34 New Zealand Standard Time INFO mail-api: [41be2531] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:34 New Zealand Standard Time INFO mail-api: [02357a33] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [b579012f] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [14ac39b2] GET /owner/pending-onboarding → 200 (0ms)
18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [01acd4c8] GET /auth/verify → 200 (0ms)
18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [9a3b3304] GET /owner/pending-onboarding → 200 (0ms)
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.2.3
ARG APP_VERSION=4.0.0
FROM python:3.12-slim
ARG APP_VERSION
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{
"emails": [
"mattcohen0@gmail.com",
"test@test.com"
]
}
+19 -2
View File
@@ -2,11 +2,28 @@
"mattcohen0@gmail.com": {
"fullName": "Matt Test",
"phone": "02124347477",
"dogName": "Geoffrey"
"dogName": "Geoffrey",
"welcomePackSentAt": "2026-05-18T20:37:14",
"welcomePackOffer": {
"serviceType": "test",
"priceDetails": "45",
"startDate": "2026-05-18",
"sentAt": "2026-05-18T20:37:14"
}
},
"natalie@desseinparke.com": {
"fullName":"Natalie Parke",
"fullName": "Natalie Parke",
"phone": "021616200",
"dogName": "Ziggy"
},
"test@test.com": {
"fullName": "test test",
"phone": "test@test.com",
"address": "test@test.com",
"dogName": "X",
"dogBreed": "H",
"dogAge": "2026-05-18",
"onboardingCompleted": true,
"birthdayAutoSend": false
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"mattcohen0@gmail.com": {
"onboarding": {
"currentStep": 5,
"ownerFirstName": "test",
"ownerLastName": "test",
"email": "test@test.com",
"phone": "test@test.com",
"address": "test@test.com",
"dogName": "test@test.com",
"dogLastName": "test@test.com",
"dogBreed": "test@test.com",
"dogDateOfBirth": "2026-05-18",
"servicesNeeded": [
"Tiny Gang Pack Walks"
],
"temperament": "test",
"accessInstructions": "01",
"vetName": "test",
"vetAddress": "test",
"vetPhone": "test",
"emergencyContactName": "test",
"emergencyContactPhone": "test",
"isVaccinated": "no",
"hasFoodAllergies": "no",
"foodAllergiesDetail": "",
"hasEnvAllergies": "no",
"envAllergiesDetail": "",
"onSpecialDiet": "no",
"specialDietDetail": "",
"onMedication": "no",
"medicationDetail": "",
"wellSocialised": "",
"dogsInteractedWeekly": "",
"visitsBeach": "yes",
"visitsDogParks": "no",
"dogParksFrequency": "",
"biteHistory": "no",
"reactiveToDogs": "no",
"reactiveToAnimals": "no",
"reactiveToChildren": "no",
"reactiveToPeople": "no",
"isDesexed": "no",
"isRegistered": "yes",
"leashTrained": "yes",
"recallRating": 5,
"ranAwayBefore": "yes",
"carBehaviour": "test",
"knownCommands": "test",
"additionalNotes": "",
"socialMediaAccount": "",
"howDidYouHear": "",
"emergencyVetConsent": false,
"termsAccepted": false
}
}
}
+138
View File
@@ -0,0 +1,138 @@
"""Postgres-backed key/value persistence for mail-api admin state.
The mail-api historically stored client_profiles / allowed_emails / drafts
as JSON files on a Docker volume. This module lets the same data live in
the shared Goodwalk postgres database so the admin dashboard at
admin.goodwalk.co.nz reads from a real database instead of a per-container
JSON file. JSON files remain as a development/local fallback and as the
seed source for the initial postgres migration.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
try:
import asyncpg
except Exception: # pragma: no cover - asyncpg is optional in dev
asyncpg = None # type: ignore[assignment]
logger = logging.getLogger("mail-api.db")
_pool: Any = None
_pool_lock = asyncio.Lock()
_schema_lock = asyncio.Lock()
_schema_ensured = False
def database_url() -> str:
return (os.environ.get("DATABASE_URL", "") or "").strip()
def is_enabled() -> bool:
return bool(database_url()) and asyncpg is not None
async def get_pool() -> Any:
"""Return a lazily-initialised asyncpg pool, or None when DB is disabled."""
global _pool
if not is_enabled():
return None
if _pool is not None:
return _pool
async with _pool_lock:
if _pool is None:
try:
_pool = await asyncpg.create_pool(
dsn=database_url(),
min_size=1,
max_size=4,
command_timeout=10,
)
logger.info("Postgres pool ready for admin_kv persistence")
except Exception as exc:
logger.warning("Postgres pool init failed (%s); falling back to JSON only", exc)
return None
return _pool
async def _ensure_schema() -> None:
global _schema_ensured
if _schema_ensured:
return
pool = await get_pool()
if pool is None:
return
async with _schema_lock:
if _schema_ensured:
return
async with pool.acquire() as conn:
await conn.execute(
"""
create table if not exists admin_kv (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
);
"""
)
_schema_ensured = True
async def get_kv(key: str) -> Any | None:
pool = await get_pool()
if pool is None:
return None
await _ensure_schema()
async with pool.acquire() as conn:
row = await conn.fetchrow("select value from admin_kv where key = $1", key)
if not row:
return None
raw = row["value"]
# asyncpg returns jsonb as a Python str; parse to native value.
if isinstance(raw, (dict, list)):
return raw
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8")
try:
return json.loads(raw)
except Exception:
return None
async def set_kv(key: str, value: Any) -> bool:
pool = await get_pool()
if pool is None:
return False
await _ensure_schema()
payload = json.dumps(value)
async with pool.acquire() as conn:
await conn.execute(
"""
insert into admin_kv (key, value, updated_at)
values ($1, $2::jsonb, now())
on conflict (key) do update
set value = excluded.value,
updated_at = excluded.updated_at
""",
key,
payload,
)
return True
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."""
pool = await get_pool()
if pool is None:
return False
await _ensure_schema()
async with pool.acquire() as conn:
row = await conn.fetchrow("select 1 from admin_kv limit 1")
return row is not None
File diff suppressed because one or more lines are too long
+1505 -40
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.32
resend>=2.0
pydantic[email]>=2.10
asyncpg>=0.30
+422
View File
@@ -0,0 +1,422 @@
# Marketing Voice v2 — Distillation Proposal
Proposed copy changes across the site, measured against `marketing-voice.md`. Nothing applied yet — review, redline, then I'll commit the survivors.
The aim: cut hedges, kill restated headings, collapse repetition across pages, and replace abstract phrasing with concrete nouns. Where two sentences carry one idea, one sentence wins.
---
## 1. Cross-site naming
### "1:1" still appears in 6+ places
We already changed the FAQ heading on the dog-walking page. The phrase still lives in navigation, footer, mega menu, decision blocks, FAQs, and SEO metadata. It's calendar-speak — customers say "solo" or "one-on-one".
| Location | Current | Proposed |
|---|---|---|
| `homepage.ts` mobileLinks | `1:1 Walks` | `Solo Walks` |
| `homepage.ts` megaMenuServices | `1:1 Walks` / `Personalised solo walks` | `Solo Walks` / `One dog. One walker.` |
| `homepage.ts` services[1] | `1:1 Walks` | `Solo Walks` |
| `homepage.ts` testimonials.service | `1:1 Walk` | `Solo Walk` |
| `homepage.ts` booking.serviceOptions | `1:1 Walks` | `Solo Walks` |
| `homepage.ts` footer.navigationLinks | `1:1 Walks` | `Solo Walks` |
| `pack-walks.ts` paragraphs[4] | `our 1:1 walks are the better fit` | `our solo walks are the better fit` |
| `dog-walking.ts` hero.eyebrow | `1:1 Walks` | `Solo Walks` |
| `dog-walking.ts` decision.title | `Is a 1:1 walk right for your dog?` | `Is a solo walk right for your dog?` |
| `dog-walking.ts` FAQ | `1:1 walks start from...` | `Solo walks start from...` |
Open question: keep `1:1` in SEO `<title>` / `description` if it carries search volume, or sweep that too? I'd keep it on `llms.txt` and meta titles where the keyword has weight, drop it in body copy.
---
## 2. Homepage (`homepage.ts`)
### Hero subtitle — already tight, leave it.
✅ Current: "Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings." Keep.
### Intro
Current: `"Professional dog walking services across Auckland."`
Proposal: **`"Dog walking across Auckland Central."`**
Why: "Professional services" is filler — readers already assume it. The location is the real specificity; drop "Auckland", say "Auckland Central" since that's where we actually are.
### Founder story body
Current:
> "Most companies sell walks. We sell a calmer evening at home."
> "Same walker. Small groups. Real attention. Your dog learns to trust one face at the door — not a rotating roster."
> "You know who has your dog. Your dog knows who is collecting them. And you come home to a tired, happy one. Ready to"
Proposal — line 3 is doing two jobs and stumbles into the CTA:
> "Most companies sell walks. We sell a calmer evening at home."
> "Same walker. Small groups. Real attention. Your dog learns one face at the door, not a rotating roster."
> "You know who has your dog. They know who's at the door. You come home to a tired, happy one. Ready to"
Reasons: kill "trust one face" (abstract + redundant with later "tired, happy one"), trim "to trust" (we just said we sell trust). Contraction on "they know who's" makes it sound spoken.
### How it works — phase benefits
The "benefit" line under each phase is doing the job of the title. Right now both compete.
| Phase | Current title + benefit | Proposal |
|---|---|---|
| Meet | `No pressure, just clarity` + `A proper Meet & Greet at home` | Drop the benefit subtitle. Title carries enough. |
| Settle | `A smoother start for nervous dogs` + `Your dog settles in. No rushing.` | Drop benefit subtitle. |
| Thrive | `The outcome you actually want` + `Then the routine does the work` | Drop benefit subtitle — it's meta-commentary. |
Alternative if benefit subtitles are structural: rewrite as deliverables, not vibes. `Free, 30 mins, your home` / `Two walks before regulars` / `Photo updates, every walk`.
### Values
Current titles read OK. Bodies have a few hedges to fix:
- `"4 to 8 dogs. Always. Calm, structured walks with real attention for every dog."`**`"4 to 8 dogs. Always. Calm walks, real attention."`** (drop "for every dog" — implied)
- `"Pet first aid certified. Careful screening. Proactive handling. Not extras — the baseline."` → keep, but "proactive handling" is fluff. → **`"Pet first aid certified. Careful screening. Not extras — the baseline."`**
- `"You should not have to chase your dog walker. Consistent pickup. Clear communication. Nothing to manage."`**`"You shouldn't have to chase a dog walker. Same pickup time. Clear updates. Nothing to manage."`** (contraction, "consistent pickup" → "same pickup time")
### Booking subtitles
Current generalSubtitle: `"A few details. We reply properly within 24 hours."`
"Properly" is a hedge that sounds defensive. Drop it.
**`"A few details. We reply within 24 hours."`**
### Locations & Hours intro
Current: `"We cover most of Auckland Central's suburbs:"`
Proposal: **`"We cover Auckland Central:"`**
"Most of" is a hedge that undercuts the long list of suburbs immediately below.
---
## 3. Pack Walks (`pack-walks.ts`)
### Hero — 5 paragraphs is too many
The hero currently delivers: who it's for, how it works, walk length, coverage, who it isn't for. Five paragraphs. Most heroes earn their keep in 2-3. The "who it isn't for" line belongs in the decision block (and it's already there).
Proposal — collapse to three 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."
>
> "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."
Coverage line moves down to the Highlight or Pricing block where it does work. The "if your dog isn't a fit" line lives in the decision block — no need to preview it here.
### Pricing intro
Current: `"Right amount of exercise. Right amount of social time. Same walker every week."`
Proposal: **`"Right exercise. Right social time. Same walker every week."`** Cut the repeated "amount of". Reads faster.
### Pricing plan features
The features hedge with "Best for…" which sounds product-y. Rewrite as straight facts:
| Plan | Current 4th feature | Proposal |
|---|---|---|
| 1 Walk Per Week | `Best for dogs starting out` | `Good for dogs starting out` (or drop) |
| 2-3 Walks Per Week | `Best fit for busy owners` | `Most popular routine` (matches `popular: true` flag) |
| 4-5 Walks Per Week | `Best for high-energy social dogs` | `For high-energy social dogs` |
| Casual Pack Walk | `Higher rate than weekly routines` | Drop — price already shows it |
### Benefits intro
Current: `"Small groups. Compatible dogs. No chaos. That is why it works."`
Proposal: **`"Small groups. Compatible dogs. No chaos. That's why it works."`** Contraction. (Same pattern repeats elsewhere — see § 6.)
### Benefit titles — abstract vs concrete
- `"No overwhelming dynamics"`**`"No bigger dogs to dodge"`** (concrete)
- `"A routine you can rely on"`**`"A weekly routine that sticks"`** (matches highlight block phrasing already used on the same page)
- `"Real individual attention"`**`"Eyes on every dog"`**
- `"Safety, built in"`**`"Safer than a one-size pack"`**
### Booking dogIntro
Current: `"Tell us about your dog. Where you are. Anything we should know. We will come back about whether Tiny Gang is the right fit."`
Proposal: **`"Tell us about your dog. Where you are. Anything we should know. We'll come back about the fit."`** Contraction, drop "whether Tiny Gang is the right" (implied from page).
---
## 4. Solo Walks (`dog-walking.ts`)
### Hero — five paragraphs again, same pattern
Current paragraphs 1-5 cover: who it's for, the dog types, walk length, coverage, honesty-at-Meet-&-Greet. Same compression opportunity.
Proposal — three paragraphs:
> "Built for dogs who do better one-on-one. Their pace. Their walk. Same walker every time."
>
> "Reactive on the lead. Recovering from surgery. A senior who needs it slower. An anxious rescue still finding their feet. These are the dogs solo walks are for."
>
> "30 minutes for seniors or lower energy. 45 for most. 60 for dogs who want a longer outing. Door-to-door, photo update after every walk."
The "honesty at Meet & Greet" sentence is already said on every other page — it's a brand-wide promise, not a hero claim.
### Highlight points
- `"For larger or more sensitive dogs"` body: `"When your dog needs more space, more clarity, or more attention — this gives us room to do it properly."`**`"For dogs who need more space, clarity, or attention. We have room to give it."`** (drop "When your dog needs" → just state who it's for)
### Pricing intro
Current: `"Shaped around your dog, not a group schedule. For dogs who need extra attention, a steadier pace, or a more personal routine."`
Proposal: **`"Shaped around your dog. For dogs who need extra attention, a steadier pace, or more personal time."`** Cuts "not a group schedule" (over-explained by context), trims "more personal routine" → "more personal time" (concrete).
### Pricing plan features
Same pattern as pack-walks. Drop hedging "Best for…" labels or rewrite as facts.
| Plan | Current 4th feature | Proposal |
|---|---|---|
| 30 Min | `Good fit for lower-energy dogs` | `For seniors and lower-energy dogs` |
| 45 Min | `Best fit for many routines` | `Most popular length` |
| 60 Min | `Best for dogs needing a fuller outing` | `For dogs who want a longer outing` |
### Pricing scarcityNote
Current: `"A limited number of 1:1 slots are available each week."`
Proposal: **`"Solo slots are limited each week."`** Active, shorter, no passive "are available".
### Benefits intro
Current: `"More space. Steadier handling. A pace that fits. The whole week feels easier."`
Already strong. Keep.
### Benefit titles
- `"Full attention. No competition."` — keep. Strong.
- `"The walk matches their pace"`**`"A walk at their pace"`** (more direct)
- `"Room to relax"` body has 21 words and ends in "without group pressure" — third time the page implies group pressure. Compress: **`"Without group pressure, anxious dogs move through the world more easily."`**
- `"A routine built around you both"` body: `"1:1 gives us flexibility to build a routine that works for your dog and your week."`**`"We shape the routine around your dog and your week."`** (drop "1:1 gives us flexibility" — meta-framing, redundant)
### Booking dogIntro
Current: `"Tell us about your dog. Where you are. Anything we should know. We will come back about the right fit."`
Proposal: **`"Tell us about your dog. Where you are. Anything we should know. We'll come back about the fit."`** Match pack-walks pattern; contraction.
---
## 5. Puppy Visits (`puppy-visits.ts`)
### Hero subtitle
Current: `"While you're at work, your puppy is fed, played with, and looked after. At home."`
This is the v1 voice-doc's chosen example. Keep.
### Hero paragraphs
Currently four. Paragraph 3 is the strongest (the growth-plates / vet rationale) and is buried. Reorder + tighten:
Proposal:
> "A visit means a toilet break, fresh water, a feed if scheduled, play, and calm settling time before we leave. Photo update lands in your phone."
>
> "Short visits beat long walks while your puppy is growing. Vets recommend low-impact exercise until growth plates settle — usually 12 to 18 months. Visits give them company and stimulation without the joint stress."
>
> "Visits are also where Goodwalk usually starts. We know your puppy early, so the move to solo walks or Tiny Gang later is smooth."
Coverage line ("Across Auckland Central — Mt Eden, Ponsonby...") goes to the chip / FAQ. Three paragraphs, clearer order: what happens → why it's right for puppies → where it leads.
### Highlight title
Current: `"Calm routines now. A smoother Tiny Gang later."`
Already nice. Keep.
### Decision footnote
Current: `"Free Meet & Greet first. Always."`
Already nice. Keep.
### Pricing intro
Current: `"Built around your puppy. Real support now. Foundations for later, if Tiny Gang is the right fit."`
Proposal: **`"Built around your puppy. Real support now. Foundations for whatever comes next."`** "If Tiny Gang is the right fit" hedges and over-explains. The "whatever comes next" implies the same thing without conditional language.
### Plan features — same hedge pattern
| Plan | Current 4th feature | Proposal |
|---|---|---|
| 20 Min | `Good for shorter midday support` | `For shorter midday support` |
| 45 Min | `Best fit for many puppies` | `Most popular visit length` |
| 60 Min | `Best for pups needing more time` | `For pups who need more time` |
### scarcityNote
Current: `"Puppy Visit spaces are limited so we can keep care consistent."`
Proposal: **`"Puppy Visit spaces are limited."`** The reason is obvious and over-explained.
### Benefits intro
Current: `"The puppy stage moves fast. Daytime visits give your puppy support now — and build the routines that make later life easier."`
Proposal: **`"The puppy stage moves fast. Daytime visits help now, and build routines that make later life easier."`** Drop "give your puppy support" (abstract), use "help now" (concrete verb).
### Benefit body fixes
- `"Foundations for Tiny Gang later"` body: `"For puppies who may join Tiny Gang one day, early visits build the confidence and routines that make the next step smooth."` — TWO hedges in one sentence ("may", "one day"). → **`"For puppies who'll join Tiny Gang later, early visits build the confidence and routines that make the next step smooth."`**
- `"Support for busy owners too"` body has `"during a demanding stage"` — vague. → **`"Real help when puppies are learning fast. Guidance from someone who's been through this stage with dozens of dogs."`**
### FAQ "How long is each visit?"
Current answer says "30 minutes — the sweet spot" but the pricing plan starts at **20 minutes**. Inconsistency — fix the FAQ, not the price.
Proposal: **`"20 minutes for shorter midday support. 45 minutes for most puppies. 60 minutes if your pup needs more time."`** Matches the pricing plans exactly.
### FAQ "Can Puppy Visits lead into Tiny Gang…"
Current: `"Exactly what they are designed for. When your puppy is old enough and the right temperament fit, we already know them well. The next step is smooth, not new."`
Proposal: **`"Exactly what they're for. By the time your puppy is old enough, we already know them. The next step is smooth, not new."`** Contraction, drop "and the right temperament fit" (implied), drop "designed for" (passive-corporate).
### Booking dogIntro
Current: `"Tell us about your puppy. Where you are. Their routine. Anything we should know — and we will plan the right visit."`
Proposal: **`"Tell us about your puppy. Where you are. Their routine. We'll plan the right visit."`** Drop "Anything we should know — and we will" (redundant, hedging).
---
## 6. About page (`about.ts`)
### "Who we are" section
Current:
> "Alessandra started Goodwalk because she could not find a walker she trusted. So she became one."
> "She walks every dog herself. Posts photos to Instagram so you can see your dog's day. Knows some of the Tiny Gang from ten weeks old."
> "Thirty-plus five-star Google reviews say the same thing: the dogs adore her, and their owners finally stop worrying."
Line 1 is gold. Keep. Line 2 is fine. Line 3 has "say the same thing" which is filler.
Proposal line 3: **`"Thirty-plus five-star Google reviews: the dogs adore her, and their owners stop worrying."`** Drop "say the same thing" and "finally" (mild hedge).
### "How we do things"
Current:
> "Calm handling. Positive reinforcement. A walker who already knows your dog. Same principles, every walk."
> "Small packs because attention matters. Free pickup and drop-off because your day should not work around us. First aid certified. Public liability insured. That part is not negotiable."
Line 2 is doing six things at once. Split:
Proposal:
> "Calm handling. Positive reinforcement. A walker who already knows your dog. Same principles, every walk."
> "Small packs because attention matters. Free pickup and drop-off because your day shouldn't work around ours."
> "First aid certified. Public liability insured. Not negotiable."
Three paragraphs, three jobs. Contraction on "shouldn't".
### "Meet the founder" — line 3 (Maya)
Current: `"Maya is the reason small dogs sit at the centre of everything. A Cavalier King Charles cross Shih Tzu. Opinionated. Dramatic in the rain. Completely impossible to ignore on a walk — and the best argument we have for building a service around small dogs, not one that just makes room for them."`
Strong texture, but ends with a 27-word sentence past the voice budget (max ~24). Split:
Proposal:
> "Maya is the reason small dogs sit at the centre of everything. A Cavalier King Charles cross Shih Tzu. Opinionated. Dramatic in the rain. Impossible to ignore on a walk."
> "She's the best argument we have for a service built around small dogs — not one that just makes room for them."
### FAQ "Why do you specialise in small dogs?"
Current: `"Small dogs need different pace, different group dynamics, different handling. Goodwalk was built around that — not adapted from a generic dog-walking model."`
Proposal: **`"Small dogs need a different pace, different group dynamics, different handling. We were built around that, not adapted from a generic model."`** Active voice ("We were built" instead of "Goodwalk was built"), trim "dog-walking" (implied).
### FAQ "What suburbs do you cover?"
Current has 16 suburb names listed inline. The map / chips already show them. Compress:
Proposal: **`"Most of Auckland Central — Ponsonby, Grey Lynn, Mt Eden, Kingsland, Herne Bay, Remuera and surrounds. If you're nearby and unsure, just ask."`** Cuts the list to the highest-recognition six. The exhaustive list lives on the homepage Locations block and the coverage map.
---
## 7. Locations (`locations.ts`) — sweeping pattern
Every location intro is structured the same way: descriptive lead → "well-suited / natural home / ideal place for…" → Goodwalk services available → free pickup line. Three of these in a row, the pattern shows.
### Park descriptions — universal cleanup
The voice doc says "Replace abstract nouns with concrete verbs". Park blurbs lean on adjective-stacks:
- `"offers wide open paths, panoramic views across Auckland, and a mix of gentle and steeper terrain"`**`"Wide open paths, panoramic views, gentle and steep terrain."`** (drop "offers")
- `"Popular with local dog walkers and a staple route for the Tiny Gang"` → keep.
- `"A well-used neighbourhood park… with open grass areas and shade trees"`**`"Neighbourhood park with open grass and shade."`**
Recommend a pass on all 30+ park descriptions: cut every "offers", "provides", "with X and Y" sentence opener, and every "well-suited / ideal place / natural home".
### Intro pattern — propose a template
Right now Mt Eden's intro is 71 words. Most location intros are 50-80 words. Voice doc says "Body sentences: max ~24". Propose a 3-sentence template:
> "[Suburb] [one specific thing — geography, vibe, dog density]. [Goodwalk fact — who from here is in the Tiny Gang / what we run here]. Free pickup and drop-off."
Worked example, Mt Eden:
> "Mt Eden's volcanic cone, leafy streets, and mix of reserves and quiet paths make it a daily-outing favourite. We run pack walks and solo walks here weekly, with several Tiny Gang regulars based in the suburb. Free pickup and drop-off included."
53 words → still long, but every clause does work. Apply the template to all 17 suburbs in a follow-up pass.
---
## 8. Repeated lines across pages
The phrase **"Free pickup and drop-off across Auckland Central"** (or close variants) appears 14+ times: in hero chips, paragraph 4 of every service hero, FAQ answers, the coverage map, the locations page, and the about page. It's a real selling point — but said this often it stops landing.
Proposal — vary the wording by surface:
- **Chip** (compact): `"Free pickup & drop-off"`
- **Hero paragraph**: usually deletable since the chip is right there. If kept: `"Pickup and drop-off included, across Auckland Central."`
- **FAQ**: keep specific — that's where someone is actually checking.
- **Footer / coverage block**: `"Door-to-door across Auckland Central."`
Save the full sentence for places where the suburb list matters.
---
## 9. Summary of recurring fixes
If we agree on the principles below, I can sweep these patterns site-wide without you reviewing each one:
1. **Drop "Best for / Best fit"** in pricing features. State who it's for, or drop.
2. **Contractions** ("we'll", "shouldn't", "they're") where the surrounding tone is conversational.
3. **"1:1" → "Solo"** in body copy, navigation, decision blocks. Keep "1:1" in SEO meta/title where keyword volume might matter.
4. **Cut "properly", "actually", "genuinely", "really"** unless the sentence dies without them.
5. **Cut "we will come back about"** → "we'll reply about" / "we'll come back" (less corporate).
6. **Park descriptions**: rewrite the "offers / provides / well-suited / a mix of" sentences as concrete noun phrases.
7. **Service hero paragraphs**: target 3 paragraphs, not 5. Coverage and disqualifier lines move down the page.
8. **Drop reasons that are already obvious from context** — "so we can keep care consistent", "Higher rate than weekly routines", "in your home" after "in-home".
---
## What this changes for the reader
- Faster scan on every service page (3 hero paragraphs instead of 5).
- Consistent terminology between nav, body, and CTAs (no "1:1" / "Solo" / "one-on-one" mix).
- Pricing tables stop sounding like a SaaS comparison grid.
- Location pages stop reading like council brochures.
## What it does NOT change
- The brand voice from `marketing-voice.md` — this proposal is a stricter application of that voice, not a rewrite of it.
- SEO `<title>` / `meta description` / `llms.txt` keywords — those remain under a separate review (the "1:1" tradeoff lives there).
- Customer testimonials — never edited.
- Service names ("Tiny Gang", "Meet & Greet") — kept verbatim.
---
**Next step**: redline this file. Strike the changes you don't want, mark anything that needs different phrasing, and I'll apply the rest in one sweep. Or pick a single section to start with (homepage? service heroes? locations?) so we can validate the voice before going site-wide.
+197
View File
@@ -0,0 +1,197 @@
# Goodwalk Marketing Voice
A practical guide for writing site copy that sells without sounding like it's selling.
## The voice in one line
**A trusted neighbour who happens to be brilliant at this.** Calm, certain, warm, specific. Not corporate. Not chirpy. Not over-promising.
## What we're borrowing from Apple
Apple's marketing works because it does three things ruthlessly:
1. **Leads with the outcome, not the process.** "A thousand songs in your pocket" — not "5GB of solid-state storage."
2. **Makes the decision feel small.** Confident, declarative sentences. No hedging.
3. **Cuts every word the meaning doesn't need.** Short. Then one longer line for texture. Then short again.
For a service business, the equivalent is selling the **evening** (calm dog, settled house, no guilt), not the **walk** (60 minutes, pickup included, group size 48).
## Voice attributes
| Attribute | What it means | What it isn't |
|---|---|---|
| **Calm** | Even cadence. No exclamation marks. No "amazing!" or "incredible!" | Hyped, sales-y |
| **Certain** | "We do X." Not "We try to X" or "We may be able to X." | Arrogant, brash |
| **Warm** | Real feeling for dogs and owners. "Your dog comes home tired and happy." | Saccharine, cutesy ("fur babies", "pawsome") |
| **Specific** | Names suburbs, parks, times. Numbers when they help. | Vague ("various", "a wide range", "we offer") |
| **Honest** | If a service isn't right for a dog, we say so. | False scarcity, manipulative urgency |
## Principles
### 1. Lead with the customer's win, not your feature
Open every section with what the **owner gets** or what the **dog feels**. The mechanism comes second.
> ❌ "Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine."
>
> ✅ "Your dog comes home tired and happy. You stop worrying through the workday. That's the whole point."
### 2. Cut every hedge
Search-and-destroy these words: *can, may, might, try to, more, genuinely, properly, generally, often, typically, possibly.* Each one quietly weakens the sentence.
> ❌ "Walks tailored to your dog's pace, confidence, and routine."
>
> ✅ "Built around your dog. Their pace. Their walk."
### 3. Short. Then long. Then short.
Vary the rhythm. A wall of medium-length sentences is the most boring possible cadence.
> ❌ "Goodwalk Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday."
>
> ✅ "A walk your dog looks forward to. A routine you don't have to manage. Pickup, walk, drop-off, photo update — every time, without you having to ask."
### 4. Active voice, present tense
Things happen. We do them. Your dog enjoys them. Avoid "are designed to" / "is intended for" / "can be tailored."
> ❌ "Our visits are intended to provide enrichment and support during the day."
>
> ✅ "We visit. We play. We feed. You get a photo when we leave."
### 5. Replace abstract nouns with concrete verbs
"Provide structure" → "settles them." "Build confidence" → "they stop pulling on the lead." "Ensure consistency" → "same walker, every time."
### 6. Specifics build trust faster than adjectives
"A well-loved local park" tells me nothing. "Western Springs at 9:15, Cornwall Park on Wednesdays" tells me you're real.
### 7. Sell the relief
Owners aren't buying a walk. They're buying: a quieter evening, a guilt-free workday, one fewer thing to manage. Name those.
### 8. One idea per sentence
If you wrote a comma, ask whether it should be a full stop.
## Patterns we use
### Headlines
Two flavours, used purposefully:
- **Outcome line:** "Come home to a calm, happy dog."
- **Definitional line:** "Pack walks for small dogs that actually suit small dogs."
Avoid: "Welcome to Goodwalk." / "About Us." / "Our Services."
### Subheads / leads
One sentence. Says what the section delivers, not what it is.
> ❌ "About our pack walks"
>
> ✅ "Four to eight dogs. Same walker every time. Home by mid-afternoon."
### Body copy
- 13 short paragraphs max per section
- Lead sentence is the most important; treat it like a headline
- One link or CTA per paragraph, max
### CTAs
Action + outcome, never just "Submit" or "Learn more."
| ❌ | ✅ |
|---|---|
| Submit | Book a free Meet & Greet |
| Learn more | See if Tiny Gang fits your dog |
| Contact us | Talk to Aless |
| Get started | Start with a Meet & Greet |
### FAQ answers
- First sentence answers the question completely
- Second sentence (if any) adds the texture
- No "Great question!" / "Glad you asked"
> Q: How big are the pack walks?
>
> ✅ "48 dogs, carefully matched on size and energy. We never run oversized packs — the small group size is the whole point."
## Words and phrases
### Use
- **You / your dog** — far more than "owners" or "clients"
- **We** — direct, owned. Not "the team" or "our walkers"
- **Walk, visit, pickup, drop-off** — the customer's words
- **Tiny Gang** — our signature, use sparingly so it stays distinct
- **Auckland Central** — anchors local intent
- Concrete park names, suburb names, times
### Avoid
- "Solutions" — never. We're not enterprise software.
- "Services" as a noun in body copy — too distant. Name the thing.
- "Pet parents" / "fur babies" / "pup parents" — twee
- "Pawsome" / "pawfect" / any pun — never
- "We are passionate about" — show, don't tell
- "Industry-leading" / "best in class" / "premium" — empty
- "Reach out" — say "email" or "text" or "call"
- Exclamation marks in headlines or body copy
## Sentence length budget
- **Headlines:** ≤ 8 words
- **Subheads:** ≤ 14 words
- **Body sentences:** average 1216 words, max ~24
- **First sentence of any section:** ≤ 12 words
If you wrote a 30-word sentence, it's two sentences.
## Before / after, from the live site
### Hero subtitle (homepage)
> ❌ "Reliable dog walking for busy Auckland owners who want happier dogs, calmer evenings, and a team they can trust."
>
> ✅ "Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings."
### Pack walks intro paragraph
> ❌ "Goodwalk Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday."
>
> ✅ "Tiny Gang is built for small and medium dogs who like the right kind of company. Small groups. Same walker. A real walk, every time."
### Puppy visits subtitle
> ❌ "Toilet breaks, play, feeding, and calm one-on-one attention — at home, while you're out."
>
> ✅ "While you're at work, your puppy is fed, played with, and looked after. At home."
### Benefits-section intro
> ❌ "Small, compatible groups give dogs the exercise, confidence, and routine they need without the chaos of oversized pack walks."
>
> ✅ "Small groups. Compatible dogs. No chaos. That's why it works."
## A 60-second editing pass
Before any new copy ships, run it through this:
1. **Cut 20%.** If you can't, cut 10%.
2. **First sentence test.** Could it be a headline? If not, rewrite.
3. **Hedge sweep.** Delete every *can/may/might/try to/generally/typically* and re-read. Most are improvements.
4. **Active voice check.** Search for "is/are [verb-ed] by" or "is intended to" and rewrite.
5. **Specific vs vague.** Replace one vague phrase per paragraph with a real name, number, or detail.
6. **Read it aloud.** If you take a breath mid-sentence, it's too long.
## When to break these rules
- **Legal pages, contracts, privacy.** Be precise and complete, not punchy.
- **Onboarding instructions.** Clarity > rhythm.
- **Genuine warmth moments.** A short, slightly longer line about a dog or a moment is allowed — it's the texture. Just don't make it the default.
+85
View File
@@ -28,6 +28,20 @@ server {
}
}
server {
listen 80;
server_name admin.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
location / {
return 301 https://admin.goodwalk.co.nz$request_uri;
}
}
server {
listen 443 ssl;
server_name goodwalk.co.nz;
@@ -205,3 +219,74 @@ server {
proxy_set_header Connection "upgrade";
}
}
server {
listen 443 ssl;
server_name admin.goodwalk.co.nz;
ssl_certificate /etc/letsencrypt/live/admin.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.goodwalk.co.nz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Robots-Tag "noindex, nofollow" always;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
resolver 127.0.0.11 ipv6=off valid=30s;
location ~* /\.(git|env|htaccess) {
deny all;
}
# Auth endpoints proxied to mail-api (verify / login / logout).
location /api/auth/ {
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
limit_req zone=goodwalk_limit burst=10 nodelay;
rewrite ^/api/auth/(.*)$ /auth/$1 break;
proxy_pass http://$goodwalk_mail_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All /api/owner/* endpoints proxied to mail-api.
location /api/owner/ {
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
limit_req zone=goodwalk_limit burst=20 nodelay;
rewrite ^/api/owner/(.*)$ /owner/$1 break;
proxy_pass http://$goodwalk_mail_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
set $goodwalk_frontend goodwalk_svelte_app:3000;
proxy_pass http://$goodwalk_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "goodwalk-svelte-port",
"version": "4.2.3",
"name": "gw-svelte",
"version": "4.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodwalk-svelte-port",
"version": "4.2.3",
"name": "gw-svelte",
"version": "4.0.0",
"dependencies": {
"canvas-confetti": "^1.9.4",
"pg": "^8.13.1"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "goodwalk-svelte-port",
"version": "4.2.3",
"name": "gw-svelte",
"version": "4.0.0",
"private": true,
"type": "module",
"scripts": {
+12
View File
@@ -12,6 +12,7 @@ NGINX_COMPOSE_FILE=""
NGINX_PROJECT_NAME=""
MAINTENANCE_HOST_DIR=""
MAINTENANCE_FLAG_PATH=""
SEED_ADMIN_DATA=0
usage() {
cat <<'EOF'
@@ -84,6 +85,10 @@ while [[ $# -gt 0 ]]; do
usage
exit 0
;;
--seed-admin-data)
SEED_ADMIN_DATA=1
shift 1
;;
*)
fail "Unknown argument: $1"
;;
@@ -349,6 +354,13 @@ if (( nginx_args_present )); then
MAINTENANCE_ACTIVE=1
fi
if [[ "$SEED_ADMIN_DATA" -eq 1 ]]; then
echo "[deploy-remote] Admin data seed requested: mail-api will overwrite admin_kv from JSON on next boot"
export ADMIN_DATA_SEED_FROM_JSON="force"
else
export ADMIN_DATA_SEED_FROM_JSON="auto"
fi
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-remote] Stopping only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
+6
View File
@@ -4,6 +4,7 @@ import { join, extname, basename } from 'node:path';
const MAX_WIDTH = 1600;
const MIN_BYTES_TO_OPTIMISE = 250 * 1024;
const OPTIMISE_WEBP = process.env.OPTIMISE_WEBP === '1';
const dirs = ['src/lib/images', 'static/images'];
@@ -31,6 +32,7 @@ async function optimiseFile(file) {
} else if (ext === '.jpg' || ext === '.jpeg') {
buf = await pipeline.jpeg({ quality: 82, mozjpeg: true }).toBuffer();
} else if (ext === '.webp') {
if (!OPTIMISE_WEBP) return { file, original: (await stat(file)).size, optimised: (await stat(file)).size, skipped: true };
buf = await pipeline.webp({ quality: 80, effort: 6 }).toBuffer();
} else {
return null;
@@ -54,6 +56,10 @@ for (const dir of dirs) {
const file = join(dir, name);
const s = await stat(file);
if (s.size < MIN_BYTES_TO_OPTIMISE) continue;
if (/\.webp$/i.test(name) && !OPTIMISE_WEBP) {
console.log(`${basename(file).padEnd(58)} ${(s.size / 1024).toFixed(0).padStart(5)}KB (skipped: existing WebP)`);
continue;
}
try {
const res = await optimiseFile(file);
if (!res) continue;
+33
View File
@@ -0,0 +1,33 @@
import type { Handle } from '@sveltejs/kit';
const ADMIN_HOSTNAME = 'admin.goodwalk.co.nz';
const ADMIN_PATH = '/owner/welcome';
function isAdminHost(hostname: string | undefined | null): boolean {
if (!hostname) return false;
return hostname.toLowerCase() === ADMIN_HOSTNAME;
}
export const handle: Handle = async ({ event, resolve }) => {
const onAdminHost = isAdminHost(event.url.hostname);
const path = event.url.pathname;
// The admin host serves the dashboard at its root.
if (onAdminHost && (path === '/' || path === '')) {
return new Response(null, {
status: 302,
headers: { location: ADMIN_PATH },
});
}
// Block the admin dashboard from the public marketing host so it only
// lives on admin.goodwalk.co.nz in production. Localhost and the
// onboarding subdomain are still allowed for development and migration.
const hostname = event.url.hostname.toLowerCase();
const isPublicMarketingHost = hostname === 'goodwalk.co.nz' || hostname === 'www.goodwalk.co.nz';
if (isPublicMarketingHost && path.startsWith('/owner/')) {
return new Response('Not Found', { status: 404 });
}
return resolve(event);
};
+3 -1
View File
@@ -164,11 +164,13 @@
<div class="page-inner">
<CtaCard
title={pageContent.contact.title}
description="Questions, pricing, or your first Meet &amp; Greet start here and we'll reply within 24 hours."
description="Questions, pricing, or a first Meet &amp; Greet. Email, call, or send an Instagram DM. We&apos;ll reply within 24 hours."
ctaHref={pageContent.contact.cta.href}
ctaLabel={pageContent.contact.cta.label}
email={pageContent.contact.email}
phone={pageContent.contact.phone}
instagramHref="https://www.instagram.com/goodwalk.nz/"
contactNote="Email, call, or send an Instagram DM. We want to be easy to reach in the way that suits you best."
showIcons={true}
/>
</div>
+6 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import Icon from '$lib/components/Icon.svelte';
import InfoSection from '$lib/components/InfoSection.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,7 +7,10 @@
export let booking: BookingContent;
export let info: InfoContent;
// General-enquiry mode disabled site-wide for now. Accepted as a prop so
// existing callers keep type-checking, but the value is intentionally ignored.
export let allowGeneralEnquiry = false;
void allowGeneralEnquiry;
const email = 'info@goodwalk.co.nz';
const phone = '(022) 642 1011';
@@ -18,9 +21,7 @@
<PageHeader
variant="green"
title="Contact Us"
subtitle={allowGeneralEnquiry
? "Book a Meet & Greet or send a general enquiry. We'll come back within 24 hours."
: "Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."}
subtitle="Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."
>
<div class="booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link">
@@ -34,7 +35,7 @@
</div>
</PageHeader>
<BookingSection {booking} {allowGeneralEnquiry} variant="contact-modern" />
<BookingWizard {booking} pagePath="/contact-us" />
<InfoSection {info} />
</main>
+3 -3
View File
@@ -29,12 +29,12 @@
messagePlaceholder:
'For example: age, confidence around other dogs, recall, and anything else that would help us place them well.'
},
'1:1 Walks': {
'Solo Walks': {
intro:
'Tell us about your dog, your suburb, and what you want from one-to-one walks so we can plan the right routine.',
'Tell us about your dog, your suburb, and what you want from solo walks so we can plan the right routine.',
messageLabel: 'What your dog needs',
messagePlaceholder:
'For example: size, pace, lead manners, confidence, and anything else that would help us tailor a one-to-one walk.'
'For example: size, pace, lead manners, confidence, and anything else that would help us tailor a solo walk.'
},
'Puppy Visits': {
intro:
File diff suppressed because it is too large Load Diff
+11 -13
View File
@@ -14,7 +14,7 @@
const ownerEmail = 'info@goodwalk.co.nz';
const ownerPhone = '(022) 642 1011';
const services = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits'];
const services = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits'];
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const draftStorageKey = 'goodwalk_contract_draft';
@@ -173,14 +173,14 @@
} catch { /* storage unavailable */ }
}
function applyProfile(serverEmail: string, profile: Record<string, string> = {}) {
function applyProfile(serverEmail: string, profile: Record<string, unknown> = {}) {
if (!email) email = serverEmail;
if (!fullName) fullName = profile.fullName ?? '';
if (!phone) phone = profile.phone ?? '';
if (!address) address = profile.address ?? '';
if (!dogName) dogName = profile.dogName ?? '';
if (!dogBreed) dogBreed = profile.dogBreed ?? '';
if (!dogAge) dogAge = profile.dogAge ?? '';
if (!fullName && typeof profile.fullName === 'string') fullName = profile.fullName;
if (!phone && typeof profile.phone === 'string') phone = profile.phone;
if (!address && typeof profile.address === 'string') address = profile.address;
if (!dogName && typeof profile.dogName === 'string') dogName = profile.dogName;
if (!dogBreed && typeof profile.dogBreed === 'string') dogBreed = profile.dogBreed;
if (!dogAge && typeof profile.dogAge === 'string') dogAge = profile.dogAge;
onboardingCompleted = Boolean((profile as Record<string, unknown>).onboardingCompleted);
contractCompleted = Boolean((profile as Record<string, unknown>).contractCompleted);
}
@@ -206,7 +206,7 @@
authChecking = false;
}
function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, string>; draft?: Record<string, unknown> }>) {
function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, unknown>; draft?: Record<string, unknown> }>) {
isAuthenticated = true;
userEmail = e.detail.email;
applyProfile(e.detail.email, e.detail.profile ?? {});
@@ -1396,8 +1396,7 @@
}
.field input,
.field textarea,
.field select {
.field textarea {
width: 100%;
padding: 15px 16px;
border: 1px solid rgba(33, 48, 33, 0.14);
@@ -1411,8 +1410,7 @@
}
.field input:focus,
.field textarea:focus,
.field select:focus {
.field textarea:focus {
border-color: rgba(255, 209, 0, 0.9);
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16);
}
+26 -1
View File
@@ -9,6 +9,9 @@
export let email: string | undefined = undefined;
export let phone: string | undefined = undefined;
export let phoneHref: string | undefined = undefined;
export let instagramHref: string | undefined = undefined;
export let instagramLabel = 'Instagram DM';
export let contactNote: string | undefined = undefined;
export let showIcons = false;
$: resolvedPhoneHref = phoneHref ?? (phone ? `tel:${phone.replace(/[^0-9+]/g, '')}` : undefined);
@@ -22,7 +25,7 @@
{ctaLabel}
<Icon name="fas fa-arrow-right" />
</a>
{#if email || phone}
{#if email || phone || instagramHref}
<div class="cta-card__links">
{#if email}
<a class="cta-card__link" href="mailto:{email}">
@@ -36,7 +39,16 @@
{phone}
</a>
{/if}
{#if instagramHref}
<a class="cta-card__link" href={instagramHref} target="_blank" rel="noopener">
{#if showIcons}<Icon name="fab fa-instagram" />{/if}
{instagramLabel}
</a>
{/if}
</div>
{#if contactNote}
<p class="cta-card__contact-note">{contactNote}</p>
{/if}
{/if}
</div>
@@ -96,6 +108,14 @@
margin-top: 22px;
}
.cta-card__contact-note {
margin: 14px auto 0;
max-width: 460px;
color: rgba(255, 255, 255, 0.68);
font-size: 14px;
line-height: 1.5;
}
.cta-card__link {
display: inline-flex;
align-items: center;
@@ -122,5 +142,10 @@
align-items: center;
gap: 14px;
}
.cta-card__contact-note {
margin-top: 12px;
font-size: 13px;
}
}
</style>
@@ -27,7 +27,7 @@
<article class="founder-note">
<div class="founder-intro">
<span class="eyebrow founder-kicker">A note from Aless</span>
<span class="founder-greeting">Hi, I'm Aless.</span>
<span class="founder-greeting">Hi, Aless from Goodwalk <span class="founder-greeting-wave" aria-hidden="true">👋</span></span>
</div>
<h2 class="founder-heading">
@@ -57,7 +57,7 @@
<div class="founder-actions">
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk">
<span class="founder-contact-wave" aria-hidden="true">👋</span>
<span>If you are unsure about anything, feel free to email me anytime.</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">
@@ -157,6 +157,11 @@
line-height: 1.5;
}
.founder-greeting-wave {
display: inline-block;
margin-left: 4px;
}
.founder-heading {
display: grid;
gap: 8px;
+2 -2
View File
@@ -238,8 +238,8 @@
text-align: left;
}
.faq summary,
.faq details p {
:global(.faq summary),
:global(.faq details p) {
text-align: left;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
</div>
<div class="instagram-dog-wrap" aria-hidden="true">
<enhanced:img src="$lib/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" />
<img src="/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" />
</div>
</div>
</aside>
+5 -3
View File
@@ -41,7 +41,7 @@
icon: 'fas fa-paw',
label: 'Services',
value: '3 ways to help',
detail: 'Pack walks, 1:1 walks, and puppy visits'
detail: 'Pack walks, solo walks, and puppy visits'
},
{
icon: 'fas fa-van-shuttle',
@@ -164,7 +164,7 @@
<span class="loc-eyebrow">What we offer</span>
<h2>Goodwalk services in {location.suburb}</h2>
<p class="loc-section-intro">
We offer pack walks, 1:1 walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet &amp; Greet so we can understand your dog and recommend the right fit.
We offer pack walks, solo walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet &amp; Greet so we can understand your dog and recommend the right fit.
</p>
</div>
<div class="loc-services-grid">
@@ -205,12 +205,14 @@
<div class="page-inner">
<CtaCard
title="Ready to get started in {location.suburb}?"
description="A free Meet & Greet is the first step — no commitment, no pressure. We meet your dog, answer your questions, and see if Goodwalk is the right fit."
description="A free Meet &amp; Greet is the first step. No commitment, no pressure. Email, call, or send an Instagram DM to talk through what your dog needs."
ctaHref="/contact-us"
ctaLabel="Book a free Meet & Greet"
email="info@goodwalk.co.nz"
phone="(022) 642 1011"
phoneHref="tel:+64226421011"
instagramHref="https://www.instagram.com/goodwalk.nz/"
contactNote="Email, call, or send an Instagram DM. We want to be easy to reach wherever you already are."
/>
</div>
</section>
+9 -4
View File
@@ -2,10 +2,15 @@
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
export let context: 'onboarding' | 'contract' = 'onboarding';
$: flowLabel = context === 'contract' ? 'contract' : 'onboarding';
export let context: 'onboarding' | 'contract' | 'owner' = 'onboarding';
$: introText =
context === 'contract'
? "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your contract."
: context === 'owner'
? "Enter the Goodwalk owner email address. We'll send you a one-time code so you can manage onboarding welcome emails."
: "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your onboarding.";
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, string>; draft: Record<string, unknown> } }>();
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, unknown>; draft: Record<string, unknown> } }>();
const ownerEmail = 'info@goodwalk.co.nz';
const ownerPhone = '(022) 642 1011';
@@ -94,7 +99,7 @@
{#if stage === 'email'}
<h2>Sign in to continue</h2>
<p>Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your {flowLabel}.</p>
<p>{introText}</p>
<div class="auth-field">
<label for="auth-email">Email address</label>
+26 -1
View File
@@ -108,7 +108,32 @@
@media (max-width: 768px) {
.ob-footer-inner {
padding: 0 18px;
height: auto;
padding: 12px 18px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
row-gap: 10px;
}
.ob-footer-email {
order: 3;
flex: 0 0 100%;
width: 100%;
margin-left: 0;
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.4;
}
.ob-footer-back,
.ob-footer-logout {
flex: 0 0 auto;
}
.ob-footer-logout {
margin-left: auto;
}
}
</style>
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import OnboardingPage from './OnboardingPage.svelte';
function clickService(label: string) {
const chip = screen.getByText(label).closest('label');
if (!chip) throw new Error(`Could not find service chip: ${label}`);
return fireEvent.click(chip);
}
async function chooseOption(groupName: RegExp | string, option: 'Yes' | 'No') {
const group = screen.getByRole('radiogroup', { name: groupName });
await fireEvent.click(within(group).getByRole('radio', { name: option }));
}
describe('OnboardingPage', () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.setItem('gw_onboarding_session', 'test-token');
window.scrollTo = vi.fn();
});
it('progresses from behaviour to sign in the 5-step flow', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/verify') {
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue({
email: 'alex@example.com',
profile: {},
draft: {}
})
});
}
if (url === '/api/save-draft') {
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue({})
});
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
})
);
render(OnboardingPage);
await waitFor(() => expect(screen.getByPlaceholderText('First name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('First name'), { target: { value: 'Alex' } });
await fireEvent.input(screen.getByPlaceholderText('Surname'), { target: { value: 'Walker' } });
await fireEvent.input(screen.getByPlaceholderText('you@example.com'), { target: { value: 'alex@example.com' } });
await fireEvent.input(screen.getByPlaceholderText('021 234 5678'), { target: { value: '0212345678' } });
await fireEvent.input(screen.getByPlaceholderText('Street address'), { target: { value: '1 Test Street' } });
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByPlaceholderText('Dog name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Dog name'), { target: { value: 'Milo' } });
await fireEvent.input(screen.getByPlaceholderText('Breed'), { target: { value: 'Spoodle' } });
await fireEvent.input(screen.getByLabelText(/date of birth/i), { target: { value: '2020-01-01' } });
await clickService('Tiny Gang Pack Walks');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByPlaceholderText('Vet clinic or vet name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Vet clinic or vet name'), { target: { value: 'Grey Lynn Vets' } });
await fireEvent.input(screen.getByPlaceholderText('Vet phone number'), { target: { value: '099999999' } });
await fireEvent.input(screen.getByPlaceholderText('Vet address'), { target: { value: '2 Vet Street' } });
await fireEvent.input(screen.getByPlaceholderText('Emergency contact name'), { target: { value: 'Jamie Walker' } });
await fireEvent.input(screen.getByPlaceholderText('Emergency contact number'), { target: { value: '0211111111' } });
await chooseOption(/is your dog vaccinated/i, 'Yes');
await chooseOption(/does your dog have any food allergies/i, 'No');
await chooseOption(/does your dog have any environmental allergies/i, 'No');
await chooseOption(/is your dog on a special diet/i, 'No');
await chooseOption(/is your dog taking any medication/i, 'No');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByText(/registered with council/i)).toBeInTheDocument());
await chooseOption(/registered with council/i, 'Yes');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByText(/anything else you'd like us to know/i)).toBeInTheDocument());
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import Icon from '$lib/components/Icon.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import PricingPlanCard from '$lib/components/PricingPlanCard.svelte';
@@ -189,7 +189,7 @@
testimonials={content.testimonials}
seedKey="/our-pricing"
/>
<BookingSection booking={pageContent.booking} variant="card-stepper" />
<BookingWizard booking={pageContent.booking} pagePath="/our-pricing" />
</main>
<style>
+42 -6
View File
@@ -319,25 +319,61 @@
@media (max-width: 768px) {
.plan-card {
order: var(--mobile-order, 0);
width: min(100%, 440px);
width: 100%;
margin-inline: auto;
padding: 28px 22px 24px;
padding: 22px 18px 20px;
border-radius: 22px;
}
.plan-card--featured {
padding-top: 44px;
padding-top: 36px;
}
.plan-card--pricing.plan-card--featured {
padding: 44px 22px 24px;
padding: 36px 18px 20px;
}
.plan-card__price {
font-size: 44px;
font-size: 36px;
}
.plan-card--supporting .plan-card__price {
font-size: 42px;
font-size: 34px;
}
.plan-card__title {
font-size: 17px;
}
.plan-card__price-block {
margin-top: 14px;
}
.plan-card__features {
margin-top: 18px;
padding-top: 16px;
}
.plan-card__features li {
gap: 8px;
font-size: 13px;
line-height: 1.45;
}
.plan-card__features li + li {
margin-top: 8px;
}
.plan-card__cta {
margin-top: 20px;
padding-inline: 16px;
}
.plan-card__ribbon {
top: 12px;
left: 16px;
padding: 4px 9px 4px 8px;
font-size: 10px;
}
.plan-card__cta {
+929
View File
@@ -0,0 +1,929 @@
<script lang="ts">
type LabelAnchor = 'top' | 'bottom' | 'left' | 'right';
type LabelTone = 'brand' | 'accent';
type Breakpoint = 'desktop' | 'tablet' | 'mobile';
type Placement = {
x: number;
y: number;
anchor: LabelAnchor;
dx?: number;
dy?: number;
visible?: boolean;
z?: number;
};
type Pin = {
slug: string;
suburb: string;
tone?: LabelTone;
desktop: Placement;
tablet?: Partial<Placement>;
mobile?: Partial<Placement>;
};
const mapWidth = 640;
const mapViewY = 100;
const mapHeight = 350;
const centre = { x: 320, y: 238 };
const city = { x: 344, y: 176 };
const pins: Pin[] = [
{
slug: 'pt-chevalier',
suburb: 'Pt Chevalier',
tone: 'accent',
desktop: { x: 124, y: 228, anchor: 'left', dy: -6, z: 4 },
tablet: { x: 120, y: 230, anchor: 'left', dy: -4 },
mobile: { x: 114, y: 230, anchor: 'right', dx: 8, dy: -2, z: 6 }
},
{
slug: 'herne-bay',
suburb: 'Herne Bay',
tone: 'accent',
desktop: { x: 226, y: 118, anchor: 'top', dx: -10, z: 5 },
tablet: { x: 224, y: 122, anchor: 'top', dx: -14, dy: -2 },
mobile: { x: 214, y: 116, anchor: 'bottom', dx: -14, dy: 6, z: 6 }
},
{
slug: 'freemans-bay',
suburb: 'Freemans Bay',
desktop: { x: 330, y: 124, anchor: 'top', dx: 14, dy: -4, z: 5 },
tablet: { x: 326, y: 130, anchor: 'top', dx: 10, dy: -2 },
mobile: { x: 322, y: 132, anchor: 'top', dx: 8, dy: -4, visible: false }
},
{
slug: 'ponsonby',
suburb: 'Ponsonby',
desktop: { x: 250, y: 178, anchor: 'left', dx: -4, dy: -10, z: 5 },
tablet: { x: 244, y: 184, anchor: 'left', dx: -6, dy: -10 },
mobile: { x: 238, y: 172, anchor: 'top', dx: -6, dy: -10, z: 6 }
},
{
slug: 'grey-lynn',
suburb: 'Grey Lynn',
desktop: { x: 214, y: 220, anchor: 'left', dx: -10, dy: -2, z: 5 },
tablet: { x: 208, y: 222, anchor: 'left', dx: -10, dy: 2 },
mobile: { x: 196, y: 222, anchor: 'left', dx: -6, dy: 0, z: 6 }
},
{
slug: 'kingsland',
suburb: 'Kingsland',
desktop: { x: 248, y: 258, anchor: 'left', dx: -6, dy: -2 },
tablet: { x: 236, y: 260, anchor: 'left', dx: -8, dy: -2 },
mobile: { x: 232, y: 260, anchor: 'left', dx: -2, visible: false }
},
{
slug: 'morningside',
suburb: 'Morningside',
desktop: { x: 196, y: 290, anchor: 'left', dx: -10, dy: 2 },
tablet: { x: 186, y: 290, anchor: 'left', dx: -8, dy: 4 },
mobile: { x: 184, y: 288, anchor: 'left', dx: -2, dy: 2, visible: false }
},
{
slug: 'mt-albert',
suburb: 'Mt Albert',
tone: 'accent',
desktop: { x: 148, y: 314, anchor: 'left', dx: -6, dy: 2, z: 4 },
tablet: { x: 148, y: 314, anchor: 'left', dx: -8, dy: 4 },
mobile: { x: 144, y: 312, anchor: 'left', dx: -2, dy: 0, z: 5 }
},
{
slug: 'sandringham',
suburb: 'Sandringham',
desktop: { x: 244, y: 344, anchor: 'bottom', dx: -10, dy: 0, z: 4 },
tablet: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2 },
mobile: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2, visible: false }
},
{
slug: 'mt-eden',
suburb: 'Mt Eden',
desktop: { x: 344, y: 280, anchor: 'right', dx: 6, dy: -8, z: 5 },
tablet: { x: 334, y: 282, anchor: 'right', dx: 6, dy: -6, z: 5 },
mobile: { x: 332, y: 280, anchor: 'right', dx: 4, dy: -8, z: 6 }
},
{
slug: 'balmoral',
suburb: 'Balmoral',
desktop: { x: 314, y: 334, anchor: 'right', dx: 8, dy: 4, z: 4 },
tablet: { x: 306, y: 338, anchor: 'bottom', dx: 0, dy: 6, z: 4 },
mobile: { x: 304, y: 338, anchor: 'bottom', dx: 0, dy: 6, visible: false }
},
{
slug: 'remuera',
suburb: 'Remuera',
tone: 'accent',
desktop: { x: 470, y: 272, anchor: 'right', dx: 10, dy: -10, z: 4 },
tablet: { x: 452, y: 280, anchor: 'right', dx: 8, dy: -8, z: 4 },
mobile: { x: 438, y: 276, anchor: 'right', dx: 6, dy: -6, z: 5 }
},
{
slug: 'greenlane',
suburb: 'Greenlane',
desktop: { x: 426, y: 348, anchor: 'right', dx: 10, dy: 6, z: 4 },
tablet: { x: 410, y: 346, anchor: 'right', dx: 8, dy: 6, z: 4 },
mobile: { x: 404, y: 340, anchor: 'right', dx: 4, dy: 6, z: 5 }
},
{
slug: 'mt-roskill',
suburb: 'Mt Roskill',
desktop: { x: 182, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 },
tablet: { x: 180, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 },
mobile: { x: 178, y: 382, anchor: 'left', dx: 4, dy: 2, z: 5 }
},
{
slug: 'three-kings',
suburb: 'Three Kings',
desktop: { x: 296, y: 390, anchor: 'bottom', dx: 8, dy: 0, z: 4 },
tablet: { x: 292, y: 388, anchor: 'bottom', dx: 8, dy: 0, z: 4 },
mobile: { x: 292, y: 390, anchor: 'bottom', dx: 6, dy: 0, z: 6 }
},
{
slug: 'hillsborough',
suburb: 'Hillsborough',
tone: 'accent',
desktop: { x: 216, y: 426, anchor: 'bottom', dx: -6, dy: 0, z: 4 },
tablet: { x: 214, y: 422, anchor: 'bottom', dx: -8, dy: 0, z: 4 },
mobile: { x: 218, y: 418, anchor: 'top', dx: -2, dy: -8, z: 6 }
},
{
slug: 'onehunga',
suburb: 'Onehunga',
desktop: { x: 364, y: 426, anchor: 'bottom', dx: 8, dy: 0, z: 3 },
tablet: { x: 354, y: 424, anchor: 'bottom', dx: 8, dy: 0, z: 3 },
mobile: { x: 352, y: 420, anchor: 'top', dx: 10, dy: -8, visible: false }
}
];
function percentX(x: number) {
return `${(x / mapWidth) * 100}%`;
}
function percentY(y: number) {
return `${((y - mapViewY) / mapHeight) * 100}%`;
}
function resolvePlacement(pin: Pin, breakpoint: Breakpoint): Placement {
const desktop = pin.desktop;
const override =
breakpoint === 'desktop' ? {} : breakpoint === 'tablet' ? pin.tablet ?? {} : pin.mobile ?? {};
return {
...desktop,
...override
};
}
function placementTokens(prefix: string, placement: Placement, gap: number, stem: number) {
const dx = placement.dx ?? 0;
const dy = placement.dy ?? 0;
if (placement.anchor === 'left') {
return [
`--${prefix}-direction: row-reverse`,
`--${prefix}-transform: translate(calc(-100% - ${gap}px + ${dx}px), calc(-50% + ${dy}px))`,
`--${prefix}-stem-w: ${stem}px`,
`--${prefix}-stem-h: 1.5px`
];
}
if (placement.anchor === 'right') {
return [
`--${prefix}-direction: row`,
`--${prefix}-transform: translate(calc(${gap}px + ${dx}px), calc(-50% + ${dy}px))`,
`--${prefix}-stem-w: ${stem}px`,
`--${prefix}-stem-h: 1.5px`
];
}
if (placement.anchor === 'top') {
return [
`--${prefix}-direction: column-reverse`,
`--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(-100% - ${gap}px + ${dy}px))`,
`--${prefix}-stem-w: 1.5px`,
`--${prefix}-stem-h: ${stem}px`
];
}
return [
`--${prefix}-direction: column`,
`--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(${gap}px + ${dy}px))`,
`--${prefix}-stem-w: 1.5px`,
`--${prefix}-stem-h: ${stem}px`
];
}
function displayToken(placement: Placement | undefined) {
return placement?.visible === false ? 'none' : 'inline-flex';
}
function pinStyle(pin: Pin, index: number) {
const desktop = resolvePlacement(pin, 'desktop');
const tablet = resolvePlacement(pin, 'tablet');
const mobile = resolvePlacement(pin, 'mobile');
return [
`--desktop-left: ${percentX(desktop.x)}`,
`--desktop-top: ${percentY(desktop.y)}`,
`--desktop-z: ${desktop.z ?? 3}`,
`--desktop-display: ${displayToken(desktop)}`,
...placementTokens('desktop', desktop, 14, 16),
`--tablet-left: ${percentX(tablet.x)}`,
`--tablet-top: ${percentY(tablet.y)}`,
`--tablet-z: ${tablet.z ?? desktop.z ?? 3}`,
`--tablet-display: ${displayToken(tablet)}`,
...placementTokens('tablet', tablet, 12, 14),
`--mobile-left: ${percentX(mobile.x)}`,
`--mobile-top: ${percentY(mobile.y)}`,
`--mobile-z: ${mobile.z ?? tablet.z ?? desktop.z ?? 3}`,
`--mobile-display: ${displayToken(mobile)}`,
...placementTokens('mobile', mobile, 10, 12),
`--pin-delay: ${((index * 0.11) % 1.6).toFixed(2)}s`
].join('; ');
}
function routePath(pin: Pin) {
const target = pin.desktop;
const controlX = (centre.x + target.x) / 2;
const controlY = (centre.y + target.y) / 2 + (target.y >= centre.y ? 14 : -14);
return `M ${centre.x} ${centre.y} Q ${controlX} ${controlY} ${target.x} ${target.y}`;
}
</script>
<figure class="area-map" aria-labelledby="area-map-caption">
<div class="area-map-shell">
<div class="area-map-stage">
<svg
class="area-map-svg"
viewBox={`0 ${mapViewY} ${mapWidth} ${mapHeight}`}
role="presentation"
aria-hidden="true"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="area-map-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(var(--white-rgb), 0.98)" />
<stop offset="58%" stop-color="rgba(var(--white-rgb), 0.94)" />
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.1)" />
</linearGradient>
<radialGradient id="area-map-glow" cx="50%" cy="46%" r="54%">
<stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.14)" />
<stop offset="48%" stop-color="rgba(var(--accent-rgb), 0.04)" />
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0)" />
</radialGradient>
<radialGradient id="area-map-core-glow" cx="50%" cy="50%" r="58%">
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.12)" />
<stop offset="70%" stop-color="rgba(var(--brand-rgb), 0.015)" />
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" />
</radialGradient>
<linearGradient id="area-map-district-main" x1="18%" y1="12%" x2="82%" y2="88%">
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.16)" />
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0.05)" />
</linearGradient>
<linearGradient id="area-map-district-soft" x1="10%" y1="20%" x2="95%" y2="85%">
<stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.1)" />
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.02)" />
</linearGradient>
<linearGradient id="area-map-route-flow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0)" />
<stop offset="40%" stop-color="rgba(var(--brand-rgb), 0.04)" />
<stop offset="56%" stop-color="rgba(var(--accent-rgb), 0.44)" />
<stop offset="72%" stop-color="rgba(var(--brand-rgb), 0.08)" />
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" />
</linearGradient>
<pattern id="area-map-grid" width="52" height="52" patternUnits="userSpaceOnUse">
<path d="M 52 0 L 0 0 0 52" fill="none" stroke="rgba(var(--brand-rgb), 0.045)" stroke-width="1" />
</pattern>
<filter id="area-map-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="rgba(var(--brand-rgb), 0.1)" />
</filter>
</defs>
<rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-bg)" />
<rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-grid)" />
<ellipse cx="324" cy="234" rx="206" ry="154" fill="url(#area-map-glow)" />
<g class="area-map-waterways">
<path
d="M 26 108 C 96 74, 176 66, 244 84 C 302 100, 336 124, 396 122 C 470 120, 548 76, 620 90"
class="area-map-waterway"
/>
<path
d="M 78 454 C 140 430, 186 416, 240 420 C 292 424, 330 460, 392 462 C 472 466, 560 428, 616 384"
class="area-map-waterway area-map-waterway-soft"
/>
</g>
<g class="area-map-districts" filter="url(#area-map-shadow)">
<path
d="M 94 206 C 132 162, 198 132, 274 126 C 334 122, 396 134, 446 160 C 500 188, 532 240, 530 300 C 528 362, 492 412, 430 434 C 342 466, 224 458, 150 412 C 92 376, 70 282, 94 206 Z"
fill="url(#area-map-district-main)"
/>
<path
d="M 126 214 C 168 180, 222 164, 284 164 C 348 164, 402 180, 442 214 C 474 242, 490 280, 486 320 C 482 366, 448 402, 394 420 C 316 446, 214 438, 154 394 C 112 360, 98 274, 126 214 Z"
fill="rgba(var(--white-rgb), 0.32)"
stroke="rgba(var(--brand-rgb), 0.08)"
stroke-width="1"
/>
<path
d="M 300 158 C 360 158, 420 176, 462 210 C 500 240, 522 278, 522 322 C 522 370, 500 402, 462 414 C 420 426, 362 412, 332 378 C 304 346, 302 312, 338 282 C 366 256, 384 230, 378 202 C 372 178, 346 162, 300 158 Z"
fill="url(#area-map-district-soft)"
/>
<path
d="M 156 248 C 188 236, 224 244, 248 268 C 268 288, 278 320, 266 348 C 252 382, 218 402, 180 402 C 148 400, 120 384, 104 356 C 88 324, 92 286, 116 262 C 128 250, 142 244, 156 248 Z"
fill="rgba(var(--white-rgb), 0.16)"
/>
</g>
<g class="area-map-roads" aria-hidden="true">
<path d="M 148 248 C 204 250, 244 266, 286 292 C 322 316, 360 354, 406 390" class="area-map-road" />
<path d="M 240 126 C 270 160, 292 194, 316 238 C 336 278, 356 340, 364 430" class="area-map-road area-map-road-strong" />
<path d="M 122 300 C 182 304, 244 300, 306 290 C 356 282, 418 264, 468 238" class="area-map-road area-map-road-soft" />
</g>
<g class="area-map-routes">
{#each pins as pin}
<path d={routePath(pin)} class="area-map-route-base" pathLength="1" />
<path d={routePath(pin)} class="area-map-route-flow" pathLength="1" />
{/each}
</g>
<g class="area-map-core">
<circle cx={centre.x} cy={centre.y} r="64" class="area-map-core-aura" />
<circle cx={centre.x} cy={centre.y} r="48" class="area-map-core-halo" />
<circle cx={centre.x} cy={centre.y} r="24" class="area-map-core-pulse" />
<circle cx={centre.x} cy={centre.y} r="15" class="area-map-core-ring" />
<circle cx={centre.x} cy={centre.y} r="10" class="area-map-core-dot" />
</g>
<g class="area-map-skyline" transform={`translate(${city.x - 46} ${city.y - 54}) scale(0.94)`}>
<ellipse cx="42" cy="74" rx="34" ry="8" class="area-map-tower-shadow" />
<path d="M 16 72 C 24 58, 29 41, 32 21 C 34 10, 36 6, 38.5 2.5 C 39.4 1.2, 40.2 0.5, 40.8 0 C 41.5 0.5, 42.3 1.2, 43.2 2.5 C 45.8 6, 47.8 10, 49.8 21 C 52.8 41, 57.8 58, 65.5 72 L 59 72 C 54.8 63.5, 51.8 49.5, 48.4 31 L 47.2 31 L 47.2 20.5 C 47.2 17.6, 46.3 15.7, 44.8 13.8 L 43.9 12.4 L 45 12.4 L 44 8.6 L 42.5 8.6 L 42.8 4.8 L 41.4 4.8 L 40.2 4.8 L 38.8 4.8 L 39.1 8.6 L 37.6 8.6 L 36.6 12.4 L 37.7 12.4 L 36.8 13.8 C 35.3 15.7, 34.4 17.6, 34.4 20.5 L 34.4 31 L 33.2 31 C 29.8 49.5, 26.8 63.5, 22.6 72 Z" class="area-map-tower-body" />
<path d="M 34 21.5 C 34 16.5, 36.8 13.6, 40.8 13.6 C 44.8 13.6, 47.6 16.5, 47.6 21.5 C 47.6 25.3, 45.8 28.2, 43 29.5 L 43 34.2 C 45.8 34.9, 48 36.5, 49.6 39.3 L 31.8 39.3 C 33.5 36.5, 35.6 34.9, 38.6 34.2 L 38.6 29.5 C 35.8 28.2, 34 25.3, 34 21.5 Z" class="area-map-tower-observation" />
<path d="M 30.8 39.3 L 50.6 39.3 L 52 42.9 L 29.4 42.9 Z" class="area-map-tower-band" />
<path d="M 29.4 42.9 C 32.8 46.4, 36.1 47.8, 40.8 47.8 C 45.5 47.8, 48.8 46.4, 52.2 42.9 L 51.1 49.4 C 47.8 51.5, 45 52.2, 40.8 52.2 C 36.6 52.2, 33.8 51.5, 30.5 49.4 Z" class="area-map-tower-ring" />
<path d="M 33.2 52.2 L 48.4 52.2 L 50 72 L 31.6 72 Z" class="area-map-tower-stem" />
<path d="M 37.4 55.6 L 39.4 55.6 L 39.4 68.8 L 37.4 68.8 Z M 42.2 55.6 L 44.2 55.6 L 44.2 68.8 L 42.2 68.8 Z" class="area-map-tower-slit" />
<circle cx="40.8" cy="10.4" r="1.9" class="area-map-tower-beacon" />
</g>
</svg>
<div class="area-map-overlay">
<span
class="area-map-label area-map-label-static area-map-label-city"
style={`left:${percentX(city.x)}; top:${percentY(city.y)};`}
aria-hidden="true"
>
<span class="area-map-label-stem"></span>
<span class="area-map-label-dot-wrap">
<span class="area-map-label-dot area-map-label-dot-city"></span>
</span>
<span class="area-map-label-pill area-map-label-pill-city">City</span>
</span>
{#each pins as pin, index}
<a
href={`/locations/${pin.slug}`}
class={`area-map-label ${pin.tone === 'accent' ? 'area-map-label-accent' : ''}`}
style={pinStyle(pin, index)}
aria-label={`View ${pin.suburb} location page`}
>
<span class="area-map-label-stem" aria-hidden="true"></span>
<span class="area-map-label-dot-wrap" aria-hidden="true">
<span class="area-map-label-pulse"></span>
<span class="area-map-label-dot"></span>
</span>
<span class="area-map-label-pill">
<span class="area-map-label-text-full">{pin.suburb}</span>
</span>
</a>
{/each}
</div>
</div>
</div>
<figcaption id="area-map-caption" class="area-map-caption">
Tap a suburb to open its local page with parks, routes, and service details.
</figcaption>
</figure>
<style>
.area-map {
margin: 0;
}
@media (max-width: 768px) {
.area-map {
display: none;
}
}
.area-map-shell {
display: grid;
place-items: center;
padding: 0 clamp(12px, 1.8vw, 18px) clamp(14px, 2vw, 20px);
overflow: visible;
}
.area-map-stage {
position: relative;
width: min(100%, 54rem);
overflow: hidden;
border-radius: clamp(26px, 2.8vw, 34px);
background:
radial-gradient(circle at 16% 12%, rgba(var(--accent-rgb), 0.1), transparent 30%),
linear-gradient(180deg, rgba(var(--white-rgb), 0.24), rgba(var(--white-rgb), 0));
box-shadow:
inset 0 0 0 1px rgba(var(--brand-rgb), 0.08),
0 18px 42px rgba(var(--ink-rgb), 0.08);
}
.area-map-svg {
display: block;
width: 100%;
height: auto;
aspect-ratio: 640 / 340;
}
.area-map-overlay {
position: absolute;
inset: clamp(14px, 2vw, 20px) clamp(28px, 4vw, 48px);
overflow: visible;
pointer-events: none;
}
.area-map-waterway {
fill: none;
stroke: rgba(var(--white-rgb), 0.56);
stroke-width: 10;
stroke-linecap: round;
opacity: 0.56;
}
.area-map-waterway-soft {
stroke-width: 7;
opacity: 0.28;
}
.area-map-road {
fill: none;
stroke: rgba(var(--brand-rgb), 0.1);
stroke-width: 1.8;
stroke-linecap: round;
stroke-dasharray: 1 9;
opacity: 0.66;
}
.area-map-road-soft {
opacity: 0.38;
stroke-dasharray: 1 11;
}
.area-map-road-strong {
stroke: rgba(var(--brand-rgb), 0.15);
stroke-width: 2.2;
opacity: 0.72;
}
.area-map-route-base {
fill: none;
stroke: rgba(var(--brand-rgb), 0.08);
stroke-width: 1.2;
stroke-linecap: round;
opacity: 0.72;
}
.area-map-route-flow {
fill: none;
stroke: url(#area-map-route-flow);
stroke-width: 2.4;
stroke-linecap: round;
stroke-dasharray: 0.18 0.82;
animation: areaRouteFlow 7.2s linear infinite;
opacity: 0.76;
}
.area-map-core-aura {
fill: url(#area-map-core-glow);
}
.area-map-core-halo {
fill: rgba(var(--accent-rgb), 0.12);
}
.area-map-core-pulse {
fill: rgba(var(--brand-rgb), 0.1);
transform-origin: 320px 238px;
animation: areaCorePulse 4.8s ease-out infinite;
}
.area-map-core-ring {
fill: rgba(var(--white-rgb), 0.86);
stroke: rgba(var(--brand-rgb), 0.16);
stroke-width: 1.25;
}
.area-map-core-dot {
fill: var(--gw-green);
stroke: rgba(var(--accent-rgb), 0.9);
stroke-width: 2.2;
}
.area-map-skyline {
pointer-events: none;
opacity: 0.62;
filter: drop-shadow(0 6px 12px rgba(var(--ink-rgb), 0.12));
}
.area-map-tower-shadow {
fill: rgba(var(--ink-rgb), 0.08);
}
.area-map-tower-body,
.area-map-tower-observation,
.area-map-tower-band,
.area-map-tower-ring,
.area-map-tower-stem,
.area-map-tower-slit {
animation: areaTowerFloat 8s ease-in-out infinite;
transform-origin: 40.8px 74px;
}
.area-map-tower-body {
fill: rgba(var(--white-rgb), 0.92);
stroke: rgba(var(--ink-rgb), 0.24);
stroke-width: 1.05;
}
.area-map-tower-observation {
fill: rgba(var(--white-rgb), 0.96);
stroke: rgba(var(--ink-rgb), 0.3);
stroke-width: 1;
}
.area-map-tower-band {
fill: rgba(var(--ink-rgb), 0.72);
}
.area-map-tower-ring {
fill: rgba(var(--white-rgb), 0.98);
stroke: rgba(var(--ink-rgb), 0.26);
stroke-width: 0.9;
}
.area-map-tower-stem {
fill: rgba(var(--white-rgb), 0.84);
stroke: rgba(var(--ink-rgb), 0.2);
stroke-width: 0.85;
}
.area-map-tower-slit {
fill: rgba(var(--ink-rgb), 0.76);
}
.area-map-tower-beacon {
fill: var(--yellow);
opacity: 0.7;
animation: areaBeaconBlink 4.2s ease-in-out infinite;
}
.area-map-label {
position: absolute;
left: var(--desktop-left);
top: var(--desktop-top);
z-index: var(--desktop-z);
display: var(--desktop-display);
flex-direction: var(--desktop-direction);
align-items: center;
gap: clamp(6px, 0.85vw, 9px);
transform: var(--desktop-transform);
color: inherit;
text-decoration: none;
pointer-events: auto;
outline: none;
}
.area-map-label-static {
left: 53.75%;
top: 32.38%;
z-index: 2;
transform: translate(-50%, calc(-100% - 12px));
pointer-events: none;
}
.area-map-label-stem {
display: block;
flex: 0 0 auto;
width: var(--desktop-stem-w);
height: var(--desktop-stem-h);
border-radius: 999px;
background: rgba(var(--brand-rgb), 0.18);
transition: background var(--motion-fast);
}
.area-map-label-dot-wrap {
position: relative;
flex: 0 0 auto;
width: clamp(11px, 1.4vw, 14px);
height: clamp(11px, 1.4vw, 14px);
}
.area-map-label-dot {
position: absolute;
inset: 0;
margin: auto;
width: clamp(7px, 1vw, 9px);
height: clamp(7px, 1vw, 9px);
border-radius: 50%;
background: var(--gw-green);
border: 2px solid rgba(var(--white-rgb), 0.96);
box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.11);
transition:
transform var(--motion-fast),
background var(--motion-fast),
box-shadow var(--motion-fast);
}
.area-map-label-pulse {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(var(--brand-rgb), 0.14);
transform: scale(0.5);
opacity: 0;
animation: areaPinPulse 4.6s ease-out infinite;
animation-delay: var(--pin-delay);
}
.area-map-label-pill {
display: inline-flex;
align-items: center;
min-height: clamp(30px, 3vw, 34px);
padding: clamp(6px, 0.95vw, 8px) clamp(10px, 1.35vw, 14px);
border: 1px solid rgba(var(--brand-rgb), 0.12);
border-radius: 999px;
background: rgba(var(--white-rgb), 0.95);
color: var(--text-brand);
font-family: var(--font-head);
font-size: clamp(0.61rem, 0.52rem + 0.24vw, 0.78rem);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1;
white-space: nowrap;
box-shadow:
inset 0 1px 0 rgba(var(--white-rgb), 0.82),
0 8px 18px rgba(var(--ink-rgb), 0.06);
transition:
transform var(--motion-fast),
background var(--motion-fast),
border-color var(--motion-fast),
box-shadow var(--motion-fast),
color var(--motion-fast);
backdrop-filter: blur(8px);
}
.area-map-label-accent .area-map-label-pill {
background: rgba(var(--accent-rgb), 0.12);
border-color: rgba(var(--accent-rgb), 0.2);
}
.area-map-label-pill-city {
min-height: 28px;
padding: 6px 10px;
background: rgba(var(--brand-rgb), 0.1);
border-color: rgba(var(--brand-rgb), 0.16);
font-size: 0.66rem;
box-shadow: 0 6px 14px rgba(var(--ink-rgb), 0.05);
}
.area-map-label-dot-city {
background: var(--yellow);
box-shadow: 0 0 0 5px rgba(var(--brand-rgb), 0.1);
}
.area-map-label:hover .area-map-label-pill,
.area-map-label:focus-visible .area-map-label-pill {
background: var(--gw-green);
border-color: rgba(var(--brand-rgb), 0.18);
color: var(--text-inverse);
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(var(--brand-rgb), 0.14);
}
.area-map-label:hover .area-map-label-stem,
.area-map-label:focus-visible .area-map-label-stem {
background: rgba(var(--brand-rgb), 0.32);
}
.area-map-label:hover .area-map-label-dot,
.area-map-label:focus-visible .area-map-label-dot {
background: var(--yellow);
transform: scale(1.12);
box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.16);
}
.area-map-caption {
margin: 12px auto 0;
max-width: 54rem;
color: var(--text-subtle);
font-size: 13px;
line-height: 1.55;
text-align: center;
}
@keyframes areaCorePulse {
0% {
transform: scale(0.8);
opacity: 0.42;
}
72% {
transform: scale(1.2);
opacity: 0;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
@keyframes areaPinPulse {
0% {
transform: scale(0.52);
opacity: 0.32;
}
78% {
transform: scale(2.25);
opacity: 0;
}
100% {
transform: scale(2.25);
opacity: 0;
}
}
@keyframes areaRouteFlow {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -1.3;
}
}
@keyframes areaTowerFloat {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1.5px);
}
}
@keyframes areaBeaconBlink {
0%,
100% {
opacity: 0.3;
transform: scale(0.92);
}
50% {
opacity: 0.74;
transform: scale(1.04);
}
}
@media (max-width: 1120px) {
.area-map-shell {
padding-block: clamp(14px, 2vw, 20px);
}
.area-map-stage {
width: min(100%, 48rem);
}
.area-map-overlay {
inset: 16px 18px 18px;
}
.area-map-label {
left: var(--tablet-left);
top: var(--tablet-top);
z-index: var(--tablet-z);
display: var(--tablet-display);
flex-direction: var(--tablet-direction);
transform: var(--tablet-transform);
}
.area-map-label-stem {
width: var(--tablet-stem-w);
height: var(--tablet-stem-h);
}
}
@media (max-width: 768px) {
.area-map-stage {
width: 100%;
border-radius: 26px;
}
.area-map-overlay {
inset: 14px 16px 18px;
}
.area-map-label-pill {
box-shadow:
inset 0 1px 0 rgba(var(--white-rgb), 0.84),
0 6px 14px rgba(var(--ink-rgb), 0.06);
}
}
@media (max-width: 640px) {
.area-map-shell {
padding-inline: 4px;
padding-bottom: 14px;
}
.area-map-stage {
border-radius: 22px;
}
.area-map-overlay {
inset: 16px 14px 22px;
}
.area-map-label {
left: var(--mobile-left);
top: var(--mobile-top);
z-index: var(--mobile-z);
display: var(--mobile-display);
flex-direction: var(--mobile-direction);
gap: 5px;
transform: var(--mobile-transform);
}
.area-map-label-stem {
width: var(--mobile-stem-w);
height: var(--mobile-stem-h);
}
.area-map-label-pill {
min-height: 28px;
padding: 6px 9px;
font-size: clamp(0.56rem, 0.52rem + 0.18vw, 0.64rem);
}
.area-map-label-pill-city {
min-height: 26px;
padding-inline: 8px;
font-size: 0.6rem;
}
.area-map-label-dot-wrap {
width: 11px;
height: 11px;
}
.area-map-label-dot {
width: 7px;
height: 7px;
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1);
}
}
@media (max-width: 430px) {
.area-map-shell {
padding-inline: 0;
}
.area-map-overlay {
inset: 18px 12px 24px;
}
.area-map-label-pill {
min-height: 27px;
padding: 5px 8px;
font-size: 0.55rem;
letter-spacing: -0.012em;
}
.area-map-caption {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.area-map-route-flow,
.area-map-core-pulse,
.area-map-label-pulse,
.area-map-tower-body,
.area-map-tower-observation,
.area-map-tower-band,
.area-map-tower-ring,
.area-map-tower-stem,
.area-map-tower-slit,
.area-map-tower-beacon {
animation: none;
}
}
</style>
+13
View File
@@ -52,6 +52,10 @@
30+ five-star Google reviews
</a>
</div>
<p class="sh-credentials">
Walked by Alessandra · Pet first aid certified · Public liability insured
</p>
</div>
<!-- Right: full-height photo, no card, no shadow, bleeds to viewport edge -->
@@ -200,6 +204,15 @@
flex: 0 0 auto;
}
.sh-credentials {
margin: 18px 0 0;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
}
/* ── Photo column — fills full height, bleeds to right viewport edge ── */
.sh-media {
position: relative;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -36,7 +36,7 @@
lead: 'The Tiny Gang is built for dogs who love company, big adventures, and coming home happily worn out!',
cues: ['4-8 dogs', 'Pickup & drop-off', 'Tiny Gang matching']
},
'1:1 Walks': {
'Solo Walks': {
eyebrow: 'Tailored support',
imageUrl: '/images/goodwalk-brown-curly-dog-one-on-one-walk-auckland.webp',
imageAlt: 'Dog enjoying a one-on-one walk',
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import Icon from '$lib/components/Icon.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
@@ -147,7 +147,7 @@
</div>
</section>
<BookingSection booking={content.booking} variant="card-stepper" />
<BookingWizard booking={content.booking} pagePath="/testimonials" />
</main>
<style>
+2 -2
View File
@@ -53,8 +53,8 @@
{
imageUrl: '/images/goodwalk-dogs-group-outing-auckland.webp',
alt: 'Otis enjoying his Goodwalk routine in Auckland',
name: 'Otis',
detail: 'regular weekly walks'
name: 'Digby teaching tricks!',
detail: ''
},
{
imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
File diff suppressed because it is too large Load Diff
@@ -109,7 +109,7 @@
</label>
<label class="vf-choice">
<input type="radio" name={`${idPrefix}-service-fit`} value="one-to-one" />
<span>1:1 Walks</span>
<span>Solo Walks</span>
</label>
<label class="vf-choice">
<input type="radio" name={`${idPrefix}-service-fit`} value="puppy-visits" />
+15 -13
View File
@@ -7,8 +7,9 @@ export const aboutPageContent: AboutPageContent = {
eyebrow: 'Our story',
title: 'Who we are',
body: [
"Goodwalk is built around Alessandra — who started this because she couldn't find a walker she actually trusted, and hasn't stopped showing up the same way since. She walks every dog herself, posts updates to Instagram so you can see exactly what your dog is up to, and has built relationships with some dogs from as young as ten weeks old. Thirty-plus five-star Google reviews later, the feedback keeps saying the same thing: the dogs adore her, and their owners finally stop worrying.",
"We specialise in small and medium dogs because we understand them — not as a category, but as actual dogs with specific needs, specific quirks, and specific ways they fall apart in the wrong environment. The pace of a walk matters. The size of the group matters. The temperament of the other dogs matters. That's why we built a service around them, not just one that fits them in."
"Alessandra started Goodwalk because she could not find a walker she trusted. So she became one.",
"She walks every dog herself. Posts photos to Instagram so you can see your dog's day. Knows some of the Tiny Gang from ten weeks old.",
"Thirty-plus five-star Google reviews say the same thing: the dogs adore her, and their owners finally stop worrying."
],
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park"
@@ -17,8 +18,8 @@ export const aboutPageContent: AboutPageContent = {
eyebrow: 'What we stand for',
title: 'How we do things',
body: [
"Every walk you've seen across our pages — the Tiny Gang outings, the one-on-ones, the puppy visits — runs on the same principles. Calm handling, positive reinforcement, and a walker who already knows your dog. That's not a promise we make at signup. It's how every single walk actually goes.",
"We keep packs small because we mean it when we say your dog gets real attention. We cover pickup and drop-off because your day shouldn't have to work around us. And every walker holds pet first aid certification and public liability insurance — because the dogs in our care aren't just bookings, they're the whole reason we do this."
"Calm handling. Positive reinforcement. A walker who already knows your dog. Same principles, every walk.",
"Small packs because attention matters. Free pickup and drop-off because your day should not work around us. First aid certified. Public liability insured. That part is not negotiable."
],
imageUrl: '/images/goodwalk-dogs-group-outing-auckland.webp',
imageAlt: 'Goodwalk dogs enjoying a group outing in Auckland',
@@ -29,8 +30,9 @@ export const aboutPageContent: AboutPageContent = {
eyebrow: 'Meet the founder',
title: 'The heart of Goodwalk',
body: [
"Alessandra started Goodwalk because she couldn't find a walker she actually trusted with Maya. So she became one. Italian-born and Auckland Central-based, she leads every walk herself — not because she has to, but because handing that off was never something she was willing to do. The dogs she walks have her full attention. Their owners have her number.",
"Maya is a Cavalier King Charles cross Shih Tzu, and the reason small dogs sit at the centre of everything Goodwalk does. She is opinionated, dramatic when it rains, and completely impossible to ignore on a walk. She is also the best argument we have for why small dogs deserve a service built specifically around them — not just accommodated by one."
"Italian-born, Auckland Central-based. Alessandra leads every walk herself — not because she has to. Because she will not hand that off.",
"The dogs she walks have her full attention. Their owners have her number.",
"Maya is the reason small dogs sit at the centre of everything. A Cavalier King Charles cross Shih Tzu. Opinionated. Dramatic in the rain. Completely impossible to ignore on a walk — and the best argument we have for building a service around small dogs, not one that just makes room for them."
],
imageUrl: '/images/alessandra-goodwalk-founder-auckland.webp',
imageAlt: 'Alessandra, founder of Goodwalk Auckland',
@@ -42,37 +44,37 @@ export const aboutPageContent: AboutPageContent = {
{
question: 'Who actually walks my dog?',
answer:
'Alessandra, the founder, personally leads every walk. We are not a platform or an agency — you will always know who is at the door.'
'Alessandra. Every walk. We are not a platform or an agency — you always know who is at the door.'
},
{
question: 'Why do you specialise in small dogs?',
answer:
'Small dogs have different energy levels, social dynamics, and handling needs compared to larger breeds. Alessandra is a small dog owner herself, and Goodwalk was built specifically around what those dogs need — not adapted from a one-size-fits-all model.'
'Small dogs need different pace, different group dynamics, different handling. Goodwalk was built around that — not adapted from a generic dog-walking model.'
},
{
question: 'How big are your packs?',
answer:
'We keep Tiny Gang packs to 48 dogs. Smaller packs mean better supervision, calmer outings, and dogs that actually come home settled rather than overstimulated.'
'4 to 8 dogs. Always. Smaller packs mean better supervision and dogs that come home settled, not overstimulated.'
},
{
question: "What is a Meet & Greet?",
answer:
'A Meet & Greet is a free, no-obligation introduction where Alessandra meets you and your dog in person. It is a chance to ask questions, see how your dog responds, and decide if Goodwalk is the right fit — with no pressure either way.'
'A free, no-pressure introduction at your home. Ask anything. See how your dog responds. Decide if Goodwalk is the right fit — either way, no obligation.'
},
{
question: 'What suburbs do you cover?',
answer:
'We cover most of Auckland Central including Ponsonby, Grey Lynn, Mt Eden, Kingsland, Morningside, Sandringham, Mt Albert, Mt Roskill, Herne Bay, Freemans Bay, Pt Chevalier, Avondale, Eden Terrace, Balmoral, and more. If you are nearby and unsure, just ask.'
'We cover most of Auckland Central including Ponsonby, Grey Lynn, Mt Eden, Kingsland, Morningside, Sandringham, Mt Albert, Mt Roskill, Herne Bay, Freemans Bay, Pt Chevalier, Remuera, Greenlane, Onehunga, Balmoral, and more. If you are nearby and unsure, just ask.'
},
{
question: 'Are your walkers insured and first-aid trained?',
answer:
'Yes. All walkers hold public liability insurance and a current pet first aid certificate. Your dog is covered from pickup to drop-off.'
'Yes. Public liability insurance and current pet first aid certificates. Your dog is covered from pickup to drop-off.'
},
{
question: 'What does onboarding look like?',
answer:
'Every new dog goes through a screening process that includes at minimum two assessment walks. This lets us make sure the pack is the right fit for your dog, and your dog is the right fit for the pack.'
'A free Meet & Greet at home, then a minimum of two assessment walks. We only confirm a regular slot once we know the fit is right for your dog and for the pack.'
}
],
contact: {
+77 -55
View File
@@ -2,21 +2,18 @@ import type { ServicePageContent } from '$lib/types';
export const dogWalkingContent: ServicePageContent = {
hero: {
eyebrow: '1:1 Walks',
title: 'A calmer walk for dogs who need more attention',
eyebrow: 'Solo Walks',
title: 'Solo dog walks for the dogs who need them',
subtitle: 'Full attention, your dog\'s pace. Free pickup and drop-off across Auckland Central.',
paragraphs: [
'Goodwalk 1:1 Walks are for dogs who do better with more individual attention, a quieter setup, and a walk tailored to their own pace, confidence, and routine.',
'They can be a great fit for larger dogs, dogs who are not suited to group walks, or owners who want a more personal approach with extra care and consistency.',
'Common reasons owners choose 1:1 over pack walks: a dog who is reactive on lead, a senior dog who needs a slower pace, a dog recovering from injury or surgery, an anxious rescue still building confidence, or a dog whose play style does not suit a group. We are happy to talk through whether a 1:1 setup is genuinely the right fit during your free Meet & Greet, rather than upselling you into a service your dog will not benefit from.',
'If your dog needs space, structure, and a walker who can focus fully on them, our one-on-one walks are designed for exactly that.',
'Walk length is matched to your dog: 30 minutes for lower-energy or older dogs, 45 minutes for most routines, and 60 minutes for dogs who genuinely benefit from a longer outing. Door-to-door times include pickup and drop-off, and updates with photos arrive after every walk.',
'We run 1:1 walks across Auckland Central — including Mt Eden, Ponsonby, Kingsland, Grey Lynn, Herne Bay, and surrounding suburbs — with free pickup and drop-off included.'
'Built for dogs who do better one-on-one. Their pace. Their walk. Same walker every time.',
'Reactive on the lead. Recovering from surgery. A senior who needs it slower. An anxious rescue still finding their feet. These are the dogs solo walks are for.',
'30 minutes for seniors or lower energy. 45 for most. 60 for dogs who want a longer outing. Door-to-door, photo update after every walk.'
],
imageUrl: '/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp',
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk',
chips: [
{ icon: 'fas fa-dog', label: 'One-on-one walk' },
{ icon: 'fas fa-dog', label: 'Solo walk' },
{ icon: 'fas fa-tag', label: 'From $45 / walk' },
{ icon: 'fas fa-car', label: 'Free pickup & drop-off' }
],
@@ -24,24 +21,24 @@ export const dogWalkingContent: ServicePageContent = {
},
highlight: {
eyebrow: 'One dog. Full attention.',
title: 'Built for dogs who need a more individual kind of walk',
title: 'Built around your dog. Not a pack schedule.',
imageUrl: '/images/goodwalk-dogs-outdoor-pack-auckland.webp',
imageAlt: 'Goodwalk dogs gathered together outdoors',
points: [
{
title: 'A quieter setup from the start',
title: 'A quieter setup',
body:
'1:1 walks suit dogs who feel better without the pressure, pace, or unpredictability of a group environment.'
'No group pressure. No unpredictable pace. Just your dog and their walker.'
},
{
title: 'Handled around your dog, not a pack routine',
title: 'Shaped around your dog',
body:
'We can adjust the route, timing, and tempo around your dogs confidence, energy, and what helps them stay settled.'
'Route, timing, and tempo all adjust to their confidence, energy, and what keeps them settled.'
},
{
title: 'A more considered fit for larger or more sensitive dogs',
title: 'For larger or more sensitive dogs',
body:
'For dogs who need more space, more clarity, or more personal attention, 1:1 walks give us room to do things properly.'
'For dogs who need more space, clarity, or attention. We have room to give it.'
}
],
collageImages: [
@@ -59,118 +56,143 @@ export const dogWalkingContent: ServicePageContent = {
}
]
},
decision: {
title: 'Is a solo walk right for your dog?',
fitTitle: 'A solo walk fits best if your dog:',
fitItems: [
'Is reactive or anxious around other dogs',
'Is senior or recovering from injury / surgery',
'Needs a slower, steadier pace',
'Has a play style that does not suit a group',
'Does better with one familiar handler'
],
notFitTitle: 'Tiny Gang is a better fit if your dog:',
notFitItems: [
'Is sociable and enjoys other dogs',
'Is small or medium-sized',
'Has good on-lead manners',
'Would benefit from regular group exercise'
],
footnote: 'Unsure which fits? The free Meet & Greet decides.'
},
pricing: {
title: 'Choose the walk length that suits your dog',
title: 'Pick the walk length that fits',
intro:
'Our 1:1 walks are shaped around your dog, not a group schedule. Ideal for dogs who need extra attention, a steadier pace, or a more personalised walking routine.',
'Shaped around your dog. For dogs who need extra attention, a steadier pace, or more personal time.',
plans: [
{
title: '30 Minute 1:1 Walk',
title: '30 Minute Solo Walk',
price: '$45',
period: 'Per Walk',
features: ['Free pickup and drop-off', 'Shorter one-on-one walk', 'Personal attention throughout', 'Good fit for lower-energy dogs']
features: ['Free pickup and drop-off', 'Shorter one-on-one walk', 'Personal attention throughout', 'For seniors and lower-energy dogs']
},
{
title: '45 Minute 1:1 Walk',
title: '45 Minute Solo Walk',
price: '$55',
period: 'Per Walk',
popular: true,
features: ['Free pickup and drop-off', 'Balanced walk length for most dogs', 'Time for calm handling and structure', 'Best fit for many routines']
features: ['Free pickup and drop-off', 'Balanced walk length for most dogs', 'Time for calm handling and structure', 'Most popular length']
},
{
title: '60 Minute 1:1 Walk',
title: '60 Minute Solo Walk',
price: '$65',
period: 'Per Walk',
features: ['Free pickup and drop-off', 'Longer individual walk', 'More time for movement and engagement', 'Best for dogs needing a fuller outing']
features: ['Free pickup and drop-off', 'Longer individual walk', 'More time for movement and engagement', 'For dogs who want a longer outing']
}
],
extras: [
{ label: 'Extra Dog', note: 'Same household', price: '$20' },
{ label: 'Muddy Wash', price: '$35' }
],
scarcityNote: 'A limited number of 1:1 slots are available each week.'
scarcityNote: 'Solo slots are limited each week.'
},
benefits: {
title: 'Why some dogs do better on 1:1 walks',
title: 'Why some dogs do better on solo walks',
intro:
'For dogs who need more space, steadier handling, or a more personalised pace, one-on-one walks can make the whole week feel easier.',
'More space. Steadier handling. A pace that fits. The whole week feels easier.',
items: [
{
title: 'They get the walkers full attention',
body: 'One-on-one walks give your dog focused handling and a calmer experience without competing with the needs of a group.'
title: 'Full attention. No competition.',
body: 'Focused handling. A calmer walk. No group needs pulling on the walker.'
},
{
title: 'The walk matches their pace',
body: 'We can tailor the route, speed, and duration to suit your dogs energy, confidence, and physical needs.'
title: 'A walk at their pace',
body: 'Route, speed, and duration shaped around your dog — not a fixed schedule.'
},
{
title: 'They have more space to relax',
body: 'Dogs who are not suited to pack walks often feel more comfortable when they can move through the world without the pressure of a group.'
title: 'Room to relax',
body: 'Without group pressure, anxious dogs move through the world more easily.'
},
{
title: 'You get a more tailored routine',
body: 'A 1:1 setup gives us more flexibility to build a walking routine around what works best for your dog and your week.'
title: 'A routine built around you both',
body: 'We shape the routine around your dog and your week.'
},
{
title: 'There is room for better habits',
body: 'One-on-one walks create more opportunity to reinforce calm walking, better focus, and the practical behaviours that make daily life easier.'
title: 'Room to build better habits',
body: 'More space to reinforce calm walking, better focus, and the practical behaviours that make daily life easier.'
},
{
title: 'You can feel more confident leaving them with us',
body: 'For owners of dogs who need a bit more care, 1:1 walks offer reassurance that your dog is getting a more considered, individual approach.'
title: 'Confidence in who has your dog',
body: 'For dogs who need extra care, solo walks mean a considered, individual approach — every time.'
}
]
},
faq: {
title: '1:1 Dog Walk FAQs',
intro: 'The questions Auckland owners ask most before booking a one-on-one walker.',
title: 'Solo Dog Walk FAQs',
intro: 'What Auckland owners ask before booking a one-on-one walker.',
items: [
{
question: 'Which dogs is a 1:1 walk best for?',
question: 'How much does a solo dog walk cost in Auckland?',
answer:
'1:1 walks suit dogs who are nervous around other dogs, older, recovering from injury, reactive, or simply do better with full undivided attention. They are also the right starting point for dogs we dont recommend for a pack walk after their assessment.'
'Solo walks start from $45 per walk for a 30-minute outing. A 45-minute walk is $55, and a 60-minute walk is $65. Pickup and drop-off across Auckland Central is included in every booking.'
},
{
question: 'How long does a 1:1 walk last?',
question: 'Which dogs is a solo walk best for?',
answer:
'Walks are tailored to your dog: 30 minutes for lower-energy or senior dogs, 45 minutes for most routines, and 60 minutes for dogs who genuinely benefit from a longer outing. Door-to-door times include pickup and drop-off.'
'Dogs who are nervous around others, senior, recovering from injury, reactive, or simply better with undivided attention. Also the right starting point for dogs we do not recommend for a pack after assessment.'
},
{
question: 'How long does a solo walk last?',
answer:
'30 minutes for senior or lower-energy dogs. 45 for most routines. 60 for dogs who want a longer outing. Door-to-door, pickup and drop-off included.'
},
{
question: 'Is pickup and drop-off included?',
answer:
'Yes — free door-to-door pickup and drop-off is included across Auckland Central (Mt Eden, Ponsonby, Kingsland, Grey Lynn, Herne Bay and surrounding suburbs). Your dog stays in their own routine end-to-end.'
'Yes. Free door-to-door across Auckland Central Mt Eden, Ponsonby, Kingsland, Grey Lynn, Herne Bay and surrounds.'
},
{
question: 'Will my dog get the same walker every time?',
answer:
'Yes. Building a relationship with one trusted handler is the whole point of 1:1 walks. Your dog sees the same familiar face at the door every visit.'
'Yes. Same face at the door, every walk. That\'s the whole point of solo walks.'
},
{
question: 'Will you send updates after the walk?',
answer:
'Youll get photos and a short update after every walk so you can see exactly how your dog is doing — useful especially during the workday when you cant check in yourself.'
'Photos and a short update after every walk. Useful during the workday when you cannot check in yourself.'
},
{
question: 'How do you handle dogs that are reactive or anxious?',
answer:
'We start with a free Meet & Greet at home and adjust pace, route, and handling to suit your dog. We use calm, positive reinforcement and avoid forcing interactions. If we dont think we can give your dog a good walk, well be straight with you.'
'We start with a free Meet & Greet at home. Pace, route, and handling adjust to your dog. Calm, positive reinforcement, no forced interactions. If we cannot give your dog a good walk, we will tell you.'
},
{
question: 'Can I book a 1:1 walk as a casual one-off?',
question: 'Can I book a solo walk as a casual one-off?',
answer:
'Casual 1:1 walks are available at a higher rate, but most owners get the best fit (and best price) from a regular weekly routine, which lets your dog settle into a predictable rhythm.'
'Casual solo walks are available at a higher rate. A regular weekly routine is better value — and lets your dog settle into a predictable rhythm.'
}
]
},
testimonialsHeading: 'What our clients say',
booking: {
title: 'See if 1:1 walks are the right fit',
subtitle: 'Fill out your details below and well arrange a free Meet & Greet to learn more about your dog.',
title: 'See if solo walks are the right fit',
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
formAction: '/contact-us',
serviceOptions: [],
ownerStepLabel: 'Your details',
dogStepLabel: 'Your dog',
dogIntro:
'Tell us about your dog, your area, and anything important we should know so we can see whether a 1:1 walk is the right fit.'
}
'Tell us about your dog. Where you are. Anything we should know. We\'ll come back about the fit.'
},
lastUpdated: '2026-05-18'
};
+43 -43
View File
@@ -16,7 +16,7 @@ export const homepageContent: HomePageContent = {
mobileLinks: [
{ label: 'Home', href: '/' },
{ label: 'Tiny Gang Pack Walks', href: '/pack-walks' },
{ label: '1:1 Walks', href: '/dog-walking' },
{ label: 'Solo Walks', href: '/dog-walking' },
{ label: 'Puppy Visits', href: '/puppy-visits' },
{ label: 'Testimonials', href: '/testimonials' },
{ label: 'Our Pricing', href: '/our-pricing' },
@@ -27,7 +27,7 @@ export const homepageContent: HomePageContent = {
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
megaMenuServices: [
{ icon: 'fas fa-paw', label: 'Tiny Gang Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' },
{ icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' },
{ icon: 'fas fa-person-walking', label: 'Solo Walks', description: 'One dog. One walker.', href: '/dog-walking' },
{ icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' }
],
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
@@ -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 for busy Auckland owners who want happier dogs, calmer evenings, and a team they can trust.',
'Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings.',
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
secondaryCta: {
label: 'See how it works',
@@ -64,9 +64,9 @@ export const homepageContent: HomePageContent = {
title: 'Not just dog walking.',
subtitle: 'Built around trust.',
body: [
'Most dog walking companies sell walks. Goodwalk was built for owners who want a calmer, more personal experience for their dog, especially small dogs who thrive on routine, familiarity, and gentle handling.',
'That means familiar walkers, safe group dynamics, reliable communication, and a team your dog genuinely builds a relationship with. We know we are not just collecting dogs for a walk. We are being trusted with part of your family and access to your home.',
'You know exactly who is caring for your dog. Your dog knows who is at the door. And you come home to a calmer, happier dog. Ready to'
'Most companies sell walks. We sell a calmer evening at home.',
'Same walker. Small groups. Real attention. Your dog learns to trust one face at the door — not a rotating roster.',
'You know who has your dog. Your dog knows who is collecting them. And you come home to a tired, happy one. Ready to'
],
emphasis: 'join the Tiny Gang?',
cta: { label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'green' },
@@ -77,21 +77,21 @@ export const homepageContent: HomePageContent = {
{
icon: 'fas fa-dog',
title: 'Tiny Gang Pack Walks',
body: 'Small-group walks for dogs who love company, routine, and a familiar little crew.',
body: 'Small groups. Same walker. For dogs who love the right kind of company.',
priceFrom: 'From $49.50 / walk',
href: '/pack-walks'
},
{
icon: 'fas fa-person-walking',
title: '1:1 Walks',
body: "One-on-one walks for dogs who need a quieter pace, focused attention, or a more tailored routine.",
title: 'Solo Walks',
body: "One dog. One walker. For dogs who do better with full attention.",
priceFrom: 'From $45 / walk',
href: '/dog-walking'
},
{
icon: 'fas fa-house',
title: 'Puppy Visits',
body: 'In-home visits that help puppies with company, routine, and a calmer day while you are out.',
body: 'In-home visits while you are at work. Company, play, calm routine.',
priceFrom: 'From $39 / visit',
href: '/puppy-visits'
}
@@ -99,30 +99,30 @@ export const homepageContent: HomePageContent = {
howItWorks: {
title: 'How it works',
intro:
'We keep the start simple and calm. First we meet your dog properly. Then we ease them into a routine that feels right.',
'Meet your dog. Settle them in. Let the routine do the rest.',
steps: [
{
phase: 'Meet',
benefit: 'No pressure, just clarity',
title: 'We start with a proper Meet & Greet',
title: 'A proper Meet & Greet at home',
body:
'You get to show us your dog, talk through routine and temperament, and make sure the fit feels right before anything starts.',
'You show us your dog. We talk through routine and temperament. Everyone decides if the fit is right before anything starts.',
icon: 'fas fa-handshake'
},
{
phase: 'Settle',
benefit: 'A smoother start for nervous dogs',
title: 'Your dog settles in without being rushed',
title: 'Your dog settles in. No rushing.',
body:
'We use assessment walks to build confidence, get the pacing right, and make sure your dog feels comfortable before moving into a regular spot.',
'Two assessment walks to build confidence, find the right pace, and make sure your dog feels comfortable before they take a regular spot.',
icon: 'fas fa-clipboard-check'
},
{
phase: 'Thrive',
benefit: 'The outcome you actually want',
title: 'Then the routine starts doing its job',
title: 'Then the routine does the work',
body:
'Once your dog is settled, the week gets easier. They come home happier, evenings feel calmer, and you stop carrying the same guilt through the workday.',
'Your dog comes home tired and happy. Evenings get quieter. The workday stops carrying the same low-grade guilt.',
icon: 'fas fa-heart'
}
]
@@ -132,40 +132,40 @@ export const homepageContent: HomePageContent = {
icon: 'fas fa-heart',
title: 'Calm, kind handling',
body:
'We use positive reinforcement, gentle handling, and patient routines so dogs build confidence instead of stress.'
'Positive reinforcement. Patient routines. Dogs build confidence — not stress.'
},
{
icon: 'fas fa-camera',
title: 'Daily updates you will actually want',
title: 'Updates you actually want',
body:
"You get to see your dog out enjoying the day, which means less wondering and more peace of mind while you're at work."
"See your dog out enjoying the day. Less wondering through the workday."
},
{
icon: 'fas fa-users',
title: 'Small Pack Sizes',
title: 'Small pack sizes',
order: 2,
body:
'With just 4-8 dogs per group, walks stay calm, structured, and manageable, with enough attention for every dog.'
'4 to 8 dogs. Always. Calm, structured walks with real attention for every dog.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety-first by default',
title: 'Safety, by default',
order: 1,
body:
'Pet first aid, careful screening, and proactive handling are built into every walk, not added on as a nice extra.'
'Pet first aid certified. Careful screening. Proactive handling. Not extras — the baseline.'
},
{
icon: 'fas fa-calendar-check',
title: 'Built for real schedules',
body:
"We specialise in regular walks, but we know life changes. Give us notice and we'll do our best to help keep things running smoothly."
"We specialise in regular walks. Life changes — give us notice and we'll work with it."
},
{
icon: 'fas fa-clock',
title: 'Reliable pickup, clear communication',
title: 'Reliable. Clear. Easy.',
order: 3,
body:
"You should not have to chase your dog walker. We keep things consistent, communicate clearly, and make the practical side feel easy."
"You should not have to chase your dog walker. Consistent pickup. Clear communication. Nothing to manage."
}
],
testimonials: [
@@ -175,7 +175,7 @@ export const homepageContent: HomePageContent = {
reviewer: 'Kate',
detail: "Archie's Owner",
type: 'Google',
service: '1:1 Walk',
service: 'Solo Walk',
showInSlider: true,
imageUrl: '/images/archie-goodwalk-dog-walking-review-auckland.webp'
},
@@ -185,7 +185,7 @@ export const homepageContent: HomePageContent = {
reviewer: 'Estelle',
detail: "Monty's Owner",
type: 'Google',
service: '1:1 Walk',
service: 'Solo Walk',
showInSlider: true,
imageUrl: '/images/monty-goodwalk-dog-walking-review-auckland.webp'
},
@@ -195,7 +195,7 @@ export const homepageContent: HomePageContent = {
reviewer: 'Ross',
detail: "Otis's Owner",
type: 'Google',
service: '1:1 Walk',
service: 'Solo Walk',
showInSlider: true,
imageUrl: '/images/otis-goodwalk-dog-walking-review-auckland.webp'
},
@@ -254,11 +254,11 @@ export const homepageContent: HomePageContent = {
booking: {
title: "Let's meet!",
subtitle:
"A few contact details and well be in touch within 24 hours to arrange your free, no-obligation Meet & Greet.",
"A few details. We reply within 24 hours to set up your free, no-obligation Meet & Greet.",
generalSubtitle:
"A few contact details and well reply properly within 24 hours.",
"A few details. We reply properly within 24 hours.",
formAction: '/contact-us',
serviceOptions: ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
serviceOptions: ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services'],
ownerStepLabel: 'Your details',
dogStepLabel: 'Your dog',
generalIntro:
@@ -268,7 +268,7 @@ export const homepageContent: HomePageContent = {
title: 'Locations & Hours',
intro: "We cover most of Auckland Central's suburbs:",
suburbs:
'Morningside, Kingsland, Ponsonby, Grey Lynn, Mt Albert, Mt Eden, Sandringham, Mt Roskill, Arch Hill, Freemans Bay, Herne Bay, Pt Chevalier, Avondale, Three Kings, Hillsborough, Eden Terrace, Balmoral.',
'Morningside, Kingsland, Ponsonby, Grey Lynn, Mt Albert, Mt Eden, Sandringham, Mt Roskill, Freemans Bay, Herne Bay, Pt Chevalier, Remuera, Greenlane, Onehunga, Three Kings, Hillsborough, Balmoral.',
nearbyText: 'Live in a nearby suburb?',
nearbyCta: { label: 'Book a Meet & Greet', href: '#newlead' },
hoursLabel: 'Opening Hours',
@@ -278,41 +278,41 @@ export const homepageContent: HomePageContent = {
{
question: 'Can any dog use your service?',
answer:
'All dogs that are onboarded with us must go through our screening process, which includes a minimum of two assessment walks.'
'Every dog goes through a screening process with a minimum of two assessment walks. We confirm the fit before anything regular starts.'
},
{
question: 'How does payment work?',
answer: 'All walks are paid for a week in advance, via invoice.'
answer: 'Weekly, in advance, by invoice.'
},
{
question: 'Do you provide a casual service?',
answer:
'Yes, we do offer casual rates, but they are priced higher. The best value for money is regular walks.'
'Yes, at a higher rate. Regular weekly walks are the best value — and suit most dogs better.'
},
{
question: 'What requirements does my dog need?',
answer:
'All dogs onboarding with Goodwalk need to have a current Auckland Council dog registration and be up to date with vaccinations to ensure the health and safety of other dogs.'
'A current Auckland Council dog registration and up-to-date vaccinations. That keeps every dog in our care safe.'
},
{
question: 'Do you have insurance or First Aid training?',
answer:
'All walkers are covered by public liability insurance, and all walkers hold a current First Aid training certificate.'
'Yes. Public liability insurance and current First Aid certificates — every walker, every walk.'
},
{
question: 'Do I need to leave keys with you?',
answer:
'Usually, yes, if no one will be home when we collect or return your dog. We can go over the best option for access during your Meet & Greet.'
'Usually yes, if no one is home for pickup or drop-off. We sort the best access option together at the Meet & Greet.'
},
{
question: 'What happens if the weather is bad?',
answer:
"We operate in all weather conditions, except when there is a danger to the dog's health and safety."
"We walk in most conditions. If it is genuinely unsafe — heat, storms, flooding — we contact you to reschedule."
}
]
},
instagram: {
title: 'Follow the Tiny Gang adventures on Instagram!',
title: 'Follow the Tiny Gang on Instagram',
label: '@goodwalk.nz',
href: 'https://www.instagram.com/goodwalk.nz/',
variant: 'green',
@@ -323,7 +323,7 @@ export const homepageContent: HomePageContent = {
navigationLinks: [
{ label: 'Home', href: '/' },
{ label: 'Tiny Gang Pack Walks', href: '/pack-walks' },
{ label: '1:1 Walks', href: '/dog-walking' },
{ label: 'Solo Walks', href: '/dog-walking' },
{ label: 'Puppy Visits', href: '/puppy-visits' },
{ label: 'Our Pricing', href: '/our-pricing' },
{ label: 'Testimonials', href: '/testimonials' },
+76 -76
View File
@@ -5,7 +5,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Mt Eden',
slug: 'mt-eden',
intro:
'Mt Eden is one of Auckland Central\'s most walked neighbourhoods — and for good reason. The volcanic cone, leafy streets, and mix of open reserves and quiet paths make it an ideal place for small dogs who thrive on a proper daily outing. Goodwalk runs pack walks and 1:1 walks through Mt Eden as part of a regular weekly routine, with free pickup and drop-off included.',
'Mt Eden is one of Auckland Central\'s most walked neighbourhoods — and for good reason. The volcanic cone, leafy streets, and mix of open reserves and quiet paths make it an ideal place for small dogs who thrive on a proper daily outing. Goodwalk runs pack walks and solo walks through Mt Eden as part of a regular weekly routine, with free pickup and drop-off included.',
parks: [
{
name: 'Maungawhau / Mt Eden Domain',
@@ -31,7 +31,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Kingsland',
slug: 'kingsland',
intro:
'Kingsland sits right in the heart of Auckland Central, with easy access to some of the area\'s best parks and green corridors. Its central position makes it one of our most efficient pickup stops, and dogs from Kingsland are a regular part of the Tiny Gang. Goodwalk covers Kingsland for pack walks, 1:1 walks, and puppy visits.',
'Kingsland sits right in the heart of Auckland Central, with easy access to some of the area\'s best parks and green corridors. Its central position makes it one of our most efficient pickup stops, and dogs from Kingsland are a regular part of the Tiny Gang. Goodwalk covers Kingsland for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Fowlds Park',
@@ -57,7 +57,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Ponsonby',
slug: 'ponsonby',
intro:
'Ponsonby\'s tree-lined streets and proximity to several of Auckland\'s best parks make it a natural home for dog-loving households. Many of the small dogs in our Tiny Gang are based in Ponsonby, and we know the neighbourhood well. Goodwalk offers pack walks, 1:1 walks, and puppy visits across Ponsonby.',
'Ponsonby\'s tree-lined streets and proximity to several of Auckland\'s best parks make it a natural home for dog-loving households. Many of the small dogs in our Tiny Gang are based in Ponsonby, and we know the neighbourhood well. Goodwalk offers pack walks, solo walks, and puppy visits across Ponsonby.',
parks: [
{
name: 'Western Park',
@@ -83,7 +83,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Grey Lynn',
slug: 'grey-lynn',
intro:
'Grey Lynn is a dog-friendly suburb with one of Auckland Central\'s most popular parks right at its centre. It\'s a densely walkable area with good access to open green space, and a regular part of our Tiny Gang routes. Goodwalk serves Grey Lynn for all services — pack walks, 1:1 walks, and puppy visits.',
'Grey Lynn is a dog-friendly suburb with one of Auckland Central\'s most popular parks right at its centre. It\'s a densely walkable area with good access to open green space, and a regular part of our Tiny Gang routes. Goodwalk serves Grey Lynn for all services — pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Grey Lynn Park',
@@ -109,7 +109,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Sandringham',
slug: 'sandringham',
intro:
'Sandringham is a well-connected central suburb with good access to several parks used by our Tiny Gang. Its quiet residential streets are easy to navigate for pickups, and its proximity to Mt Eden and Morningside means dogs based here have a range of walking routes available. Goodwalk covers Sandringham for pack walks, 1:1 walks, and puppy visits.',
'Sandringham is a well-connected central suburb with good access to several parks used by our Tiny Gang. Its quiet residential streets are easy to navigate for pickups, and its proximity to Mt Eden and Morningside means dogs based here have a range of walking routes available. Goodwalk covers Sandringham for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Potters Park',
@@ -135,7 +135,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Mt Albert',
slug: 'mt-albert',
intro:
'Mt Albert combines a relaxed suburban feel with excellent access to parks and green corridors that make it one of the better areas for dog walking in Auckland Central. The Oakley Creek walkway alone is a standout route for small dogs. Goodwalk serves Mt Albert for pack walks, 1:1 walks, and puppy visits.',
'Mt Albert combines a relaxed suburban feel with excellent access to parks and green corridors that make it one of the better areas for dog walking in Auckland Central. The Oakley Creek walkway alone is a standout route for small dogs. Goodwalk serves Mt Albert for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Oakley Creek / Te Auaunga Walkway',
@@ -161,7 +161,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Herne Bay',
slug: 'herne-bay',
intro:
'Herne Bay is one of Auckland\'s most picturesque suburbs, with waterfront reserves, harbour views, and a calm residential feel that suits small dogs perfectly. It\'s a natural fit for our Tiny Gang, and we regularly pick up from the area. Goodwalk covers Herne Bay for pack walks, 1:1 walks, and puppy visits.',
'Herne Bay is one of Auckland\'s most picturesque suburbs, with waterfront reserves, harbour views, and a calm residential feel that suits small dogs perfectly. It\'s a natural fit for our Tiny Gang, and we regularly pick up from the area. Goodwalk covers Herne Bay for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Cox\'s Bay Reserve',
@@ -187,7 +187,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Morningside',
slug: 'morningside',
intro:
'Morningside is home to some of the best park access in the central Auckland area — Fowlds Park and Western Springs are both on the doorstep. It\'s a regular part of our Tiny Gang routes, and pickup logistics are straightforward. Goodwalk serves Morningside for pack walks, 1:1 walks, and puppy visits.',
'Morningside is home to some of the best park access in the central Auckland area — Fowlds Park and Western Springs are both on the doorstep. It\'s a regular part of our Tiny Gang routes, and pickup logistics are straightforward. Goodwalk serves Morningside for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Fowlds Park',
@@ -213,7 +213,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Freemans Bay',
slug: 'freemans-bay',
intro:
'Freemans Bay sits just below Ponsonby and within easy reach of Victoria Park and the waterfront. Its compact streets and central location make it one of our quickest pickup stops. Goodwalk serves Freemans Bay for pack walks, 1:1 walks, and puppy visits.',
'Freemans Bay sits just below Ponsonby and within easy reach of Victoria Park and the waterfront. Its compact streets and central location make it one of our quickest pickup stops. Goodwalk serves Freemans Bay for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Victoria Park',
@@ -239,7 +239,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Pt Chevalier',
slug: 'pt-chevalier',
intro:
'Pt Chevalier has a relaxed, community-oriented feel and excellent park access — including beach reserves, wetland walkways, and open fields. It\'s a genuinely good suburb for dog walking and a regular part of our routes. Goodwalk serves Pt Chevalier for pack walks, 1:1 walks, and puppy visits.',
'Pt Chevalier has a relaxed, community-oriented feel and excellent park access — including beach reserves, wetland walkways, and open fields. It\'s a genuinely good suburb for dog walking and a regular part of our routes. Goodwalk serves Pt Chevalier for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Pt Chevalier Beach Reserve',
@@ -262,36 +262,88 @@ export const locationPages: LocationPageContent[] = [
]
},
{
suburb: 'Avondale',
slug: 'avondale',
suburb: 'Remuera',
slug: 'remuera',
intro:
'Avondale offers solid access to green space and the Oakley Creek walkway, one of the better dog walking routes in West Auckland. It sits on the western edge of our service area and is well-suited to dogs who enjoy more varied terrain. Goodwalk covers Avondale for pack walks, 1:1 walks, and puppy visits.',
'Remuera is a leafy, well-established eastern suburb with quiet streets and direct access to some of the best green space in central Auckland. The mix of generous local reserves and the wider Hobson Bay edge makes it well-suited to dogs who do best on calm, structured walks. Goodwalk serves Remuera for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Oakley Creek / Te Auaunga Walkway',
name: 'Waiatarua Reserve',
description:
'The Oakley Creek walkway stretches through Avondale along a stream-side path lined with native plantings. One of the most enjoyable walking routes in the area — calm, green, and well away from traffic.',
leashNote: 'On-leash in most sections.'
'A large restored wetland reserve with looping paths, open grassland, and native planting. One of the standout walking spots in the eastern suburbs and a regular destination for our longer Tiny Gang outings.',
leashNote: 'On-leash area. Stay on formed paths through the wetland sections.'
},
{
name: 'Avondale Domain',
name: 'Remuera Domain',
description:
'A local domain with open grass and easy paths — a reliable neighbourhood option for a straightforward, unhurried walk.',
'A well-kept neighbourhood domain with open lawns, mature trees, and easy paths — a reliable everyday option for dogs based in Remuera.',
leashNote: 'On-leash area.'
},
{
name: 'Hendon Park',
name: 'Hapua Thomson Park',
description:
'A quieter local reserve in Avondale with grassy open areas and a low-key atmosphere. Good for dogs who prefer a smaller, less busy setting.',
'A quieter local reserve tucked into the residential streets of Remuera. Calm, lower-traffic, and a good choice for dogs who prefer a less busy environment.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Greenlane',
slug: 'greenlane',
intro:
'Greenlane sits right next to one of Auckland\'s best walking parks — Cornwall Park — making it one of the most rewarding suburbs for a regular dog walking routine. Its central position keeps pickups efficient, and dogs from Greenlane are a regular part of our Tiny Gang circuit. Goodwalk serves Greenlane for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Cornwall Park / One Tree Hill Domain',
description:
'Right on Greenlane\'s doorstep, Cornwall Park is one of Auckland\'s most expansive green spaces — sweeping lawns, mature trees, and wide walking paths that suit longer, more varied outings. A staple route for our pack walks.',
leashNote: 'Dogs must be on leash and are not permitted in fenced farm animal areas.'
},
{
name: 'Greenwoods Corner Reserve',
description:
'A small neighbourhood reserve at the heart of Greenlane with open grass and a relaxed local feel — useful for a shorter midday walk for dogs based nearby.',
leashNote: 'On-leash area.'
},
{
name: 'Withiel Thomas Reserve',
description:
'A native bush reserve with shaded paths and a quieter, more natural setting — a nice change of pace from the open expanses of Cornwall Park.',
leashNote: 'On-leash area. Stay on formed tracks.'
}
]
},
{
suburb: 'Onehunga',
slug: 'onehunga',
intro:
'Onehunga has a working-village feel with excellent coastal access and some of the more characterful parks in the south-central suburbs. The combination of foreshore reserves and historic walking grounds makes it a genuinely good area for dogs. Goodwalk serves Onehunga for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Jellicoe Park',
description:
'Onehunga\'s landmark park, set on the slopes of the Te Hopua volcanic crater with open grass, mature trees, and good walking paths. A regular stop for dogs based in the south-central area.',
leashNote: 'On-leash area.'
},
{
name: 'Onehunga Bay Reserve',
description:
'A waterfront reserve along the Manukau Harbour edge with grassy areas, sea air, and a relaxed coastal atmosphere. A favourite for dogs who enjoy a change of scene from suburban parks.',
leashNote: 'On-leash. Check Auckland Council signage for foreshore access rules.'
},
{
name: 'Waikaraka Park',
description:
'A large coastal park along the Onehunga foreshore with wide open paths and uninterrupted harbour views — useful for dogs who need more room and a longer, more rhythmic walk.',
leashNote: 'On-leash area in most sections.'
}
]
},
{
suburb: 'Three Kings',
slug: 'three-kings',
intro:
'Three Kings is a quieter residential suburb with some genuinely interesting walking terrain — most notably the old quarry reserve that gives the suburb its character. It\'s well-positioned for pickup and sits within easy reach of Monte Cecilia Park. Goodwalk serves Three Kings for pack walks, 1:1 walks, and puppy visits.',
'Three Kings is a quieter residential suburb with some genuinely interesting walking terrain — most notably the old quarry reserve that gives the suburb its character. It\'s well-positioned for pickup and sits within easy reach of Monte Cecilia Park. Goodwalk serves Three Kings for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Three Kings Reserve',
@@ -317,7 +369,7 @@ export const locationPages: LocationPageContent[] = [
suburb: 'Hillsborough',
slug: 'hillsborough',
intro:
'Hillsborough sits on the southern edge of our service area with access to Monte Cecilia Park and a network of quieter residential streets. It\'s a relaxed, lower-density suburb well-suited to dogs who do better on calmer, less congested walks. Goodwalk serves Hillsborough for pack walks, 1:1 walks, and puppy visits.',
'Hillsborough sits on the southern edge of our service area with access to Monte Cecilia Park and a network of quieter residential streets. It\'s a relaxed, lower-density suburb well-suited to dogs who do better on calmer, less congested walks. Goodwalk serves Hillsborough for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Monte Cecilia Park',
@@ -339,37 +391,11 @@ export const locationPages: LocationPageContent[] = [
}
]
},
{
suburb: 'Eden Terrace',
slug: 'eden-terrace',
intro:
'Eden Terrace is a compact, centrally-located suburb with quick access to Myers Park and the Auckland Domain. Its urban density makes it an efficient pickup point, and dogs from Eden Terrace often join Tiny Gang outings to nearby Mt Eden and Grey Lynn parks. Goodwalk serves Eden Terrace for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Myers Park',
description:
'An urban park in the heart of the city with terraced gardens, shaded paths, and a pedestrian-friendly layout. A good option for a quick midday walk for dogs based in Eden Terrace.',
leashNote: 'On-leash area.'
},
{
name: 'Auckland Domain',
description:
'Auckland\'s oldest park, the Domain offers expansive lawns, tree-lined paths, and some of the city\'s best open green space. Just a short drive from Eden Terrace and a regular destination for our longer Tiny Gang outings.',
leashNote: 'On-leash area. Dogs are not permitted in the formal garden sections.'
},
{
name: 'Basque Park',
description:
'A small pocket park near the Newton Gully with quiet paths and native planting — useful as a local walking option for dogs in the immediate area.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Balmoral',
slug: 'balmoral',
intro:
'Balmoral is a well-established residential suburb with easy access to several parks used by our Tiny Gang. Its quiet streets and proximity to Mt Eden and Sandringham make it a natural part of our regular routes. Goodwalk serves Balmoral for pack walks, 1:1 walks, and puppy visits.',
'Balmoral is a well-established residential suburb with easy access to several parks used by our Tiny Gang. Its quiet streets and proximity to Mt Eden and Sandringham make it a natural part of our regular routes. Goodwalk serves Balmoral for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Balmoral Reserve',
@@ -391,37 +417,11 @@ export const locationPages: LocationPageContent[] = [
}
]
},
{
suburb: 'Arch Hill',
slug: 'arch-hill',
intro:
'Arch Hill sits between Grey Lynn and Kingsland with good access to both Grey Lynn Park and the surrounding reserves. It\'s a compact suburb with a strong dog-owning community and a regular part of our pickup circuit. Goodwalk serves Arch Hill for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Arch Hill Reserve',
description:
'A hilltop reserve with native planting and elevated views — quieter than the main parks nearby and a good option for dogs who prefer a less busy environment.',
leashNote: 'On-leash area.'
},
{
name: 'Grey Lynn Park',
description:
'A short walk from Arch Hill, Grey Lynn Park is one of Auckland Central\'s most popular dog walking destinations with open lawns, wide paths, and a lively community feel.',
leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage.'
},
{
name: 'Fowlds Park',
description:
'Easily reached from Arch Hill via Kingsland, Fowlds Park provides generous open space and a reliable walking environment for dogs who need more room to move.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Mt Roskill',
slug: 'mt-roskill',
intro:
'Mt Roskill sits on the southern edge of our service area with access to Monte Cecilia Park and the Richardson Domain — two of the larger parks in the south-central Auckland belt. It\'s a well-connected suburb and a regular part of our extended routes. Goodwalk serves Mt Roskill for pack walks, 1:1 walks, and puppy visits.',
'Mt Roskill sits on the southern edge of our service area with access to Monte Cecilia Park and the Richardson Domain — two of the larger parks in the south-central Auckland belt. It\'s a well-connected suburb and a regular part of our extended routes. Goodwalk serves Mt Roskill for pack walks, solo walks, and puppy visits.',
parks: [
{
name: 'Monte Cecilia Park',
+4 -4
View File
@@ -5,7 +5,7 @@ import { packWalksContent } from './pack-walks';
import { puppyVisitsContent } from './puppy-visits';
const packWalksService = sharedServices.find((service) => service.title === 'Tiny Gang Pack Walks');
const oneToOneService = sharedServices.find((service) => service.title === '1:1 Walks');
const oneToOneService = sharedServices.find((service) => service.title === 'Solo Walks');
const puppyVisitsService = sharedServices.find((service) => service.title === 'Puppy Visits');
export const ourPricingContent: PricingPageContent = {
@@ -17,7 +17,7 @@ export const ourPricingContent: PricingPageContent = {
'Three services, designed for three different stages and temperaments. Here is how to think about which one fits your dog best.',
paragraphs: [
'Tiny Gang Pack Walks are our specialty and the best value option for sociable small and medium dogs who do well in company. Most owners use them as a regular weekday routine — two to four walks a week, with the per-walk price dropping as frequency goes up. If your dog enjoys other dogs, has the temperament for shared outings, and would benefit from a consistent weekly rhythm, this is almost always the right starting point.',
'1:1 Walks are the right fit when a group setting is not. Reactive dogs, senior dogs, dogs recovering from injury, anxious rescues still building confidence, larger dogs whose play style does not suit a pack, and dogs who simply prefer a quieter walk all do better one-on-one. The cost per walk is higher than Tiny Gang because the walk is fully tailored — pace, route, and duration shaped around your dog rather than a group schedule.',
'Solo Walks are the right fit when a group setting is not. Reactive dogs, senior dogs, dogs recovering from injury, anxious rescues still building confidence, larger dogs whose play style does not suit a pack, and dogs who simply prefer a quieter walk all do better one-on-one. The cost per walk is higher than Tiny Gang because the walk is fully tailored — pace, route, and duration shaped around your dog rather than a group schedule.',
'Puppy Visits are for puppies under roughly 12 to 18 months who are not yet ready for full walks. Vets generally advise against long pavement walks while growth plates are still developing, so visits focus on toilet breaks, feeding, gentle play, and calm company at home. Many of our Tiny Gang dogs started here as puppies — visits build the early familiarity that makes the transition to pack walks much smoother later on.',
'Still not sure? The free Meet & Greet is the best way to work it out. Aless will meet your dog at home, talk through routine and temperament, and give you a straight recommendation — including telling you when none of our services are the right fit.'
]
@@ -41,7 +41,7 @@ export const ourPricingContent: PricingPageContent = {
plans: packWalksContent.pricing.plans
},
{
title: '1:1 Walks',
title: 'Solo Walks',
icon: oneToOneService?.icon ?? 'fas fa-person-walking',
eyebrow: 'Tailored support',
lede: 'When a group setting is not the right fit.',
@@ -51,7 +51,7 @@ export const ourPricingContent: PricingPageContent = {
{ label: 'Pricing note', value: 'Higher per-walk than Tiny Gang because the walk is fully individual' }
],
detailCta: {
label: 'View 1:1 Walks',
label: 'View Solo Walks',
href: '/dog-walking',
variant: 'green'
},
+71 -43
View File
@@ -3,15 +3,19 @@ import type { ServicePageContent } from '$lib/types';
export const packWalksContent: ServicePageContent = {
hero: {
eyebrow: 'Tiny Gang Pack Walks',
title: 'Tiny Gang Pack Walks for sociable small and medium dogs',
subtitle: 'Tiny Gang walks are social, active, and carefully matched for dogs who love the right company.',
title: 'Pack walks that actually suit small & medium dogs',
subtitle: 'Small groups. Same walker. A real walk, every time.',
introEyebrow: 'How Tiny Gang works',
introHeading: 'What joining the pack 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: [
'Goodwalk Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday.',
'Our Tiny Gang packs stay small, calm, and carefully matched, so sociable dogs can build confidence, enjoy safe group outings, and come home settled instead of overstimulated.',
'Every dog joining the Tiny Gang starts with a free Meet & Greet at home, followed by a minimum of two assessment walks. This gives us time to learn how your dog handles new dogs, new environments, and being collected from your home, and gives your dog the chance to settle in without pressure. Only once we are confident the fit is right does your dog move into a regular weekly slot.',
'Tiny Gang is best suited to sociable small and medium dogs who enjoy being around other dogs. If your dog would be better with a quieter, more individual setup, our 1:1 walks may be a better fit.',
'A typical Tiny Gang outing runs about 60 to 75 minutes on the ground, with door-to-door times that include pickup, the walk itself, and drop-off. Walks happen across a rotation of central Auckland parks — Western Springs, Fowlds Park, Cornwall Park, Grey Lynn Park, the Oakley Creek walkway — chosen for the day based on weather, group make-up, and what each dog handles best.',
'We run pack walks across Auckland Central — including Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, and surrounding suburbs — with free pickup and drop-off included in every booking.'
'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.',
'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',
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park",
@@ -24,56 +28,75 @@ export const packWalksContent: ServicePageContent = {
},
highlight: {
eyebrow: 'What Tiny Gang is',
title: 'A small-group walking routine for sociable dogs who love the right company',
title: 'Small groups. Right dogs. Real walks.',
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',
body:
'Tiny Gang walks run in carefully matched groups of 4-8 dogs, with real play, movement, and social time without the chaos of oversized packs.'
'4 to 8 dogs, carefully matched. Real play, movement, and social time — no oversized-pack chaos.'
},
{
title: 'Best for the right dogs',
title: 'Built for the right dogs',
body:
'These walks suit sociable small and medium dogs who enjoy company and tend to do well in a shared, active environment. If your dog needs more space, 1:1 walks may be a better fit.'
'Sociable small and medium dogs who enjoy company. If your dog needs more space, solo walks are the better fit.'
},
{
title: 'Exercise with a weekly rhythm',
title: 'A weekly rhythm that sticks',
body:
'Most owners use Tiny Gang as a regular weekday routine, with pickup and drop-off included across Auckland Central to make the whole thing easier to stick to.'
'Regular weekday routine. Pickup and drop-off across Auckland Central. Easy to keep going.'
}
]
},
decision: {
title: 'Is Tiny Gang right for your dog?',
fitTitle: 'Tiny Gang is a great fit if your dog:',
fitItems: [
'Is small or medium-sized',
'Enjoys other dogs',
'Is sociable and adaptable',
'Has settled basic on-lead manners',
'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',
'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.'
},
pricing: {
title: 'Choose the weekly routine that suits your dog',
intro:
'Choose the routine that gives your dog the right amount of exercise, social time, and consistency each week.',
title: 'Pick the weekly routine that fits',
intro: 'Right exercise. Right social time. Same walker every week.',
plans: [
{
title: '1 Walk Per Week',
price: '$58',
period: 'Per Walk',
features: ['One regular walk each week', 'Free pickup and drop-off', 'Calm small-group outing', 'Best for dogs starting out']
features: ['One regular walk each week', 'Free pickup and drop-off', 'Calm small-group outing', 'For dogs starting out']
},
{
title: '2-3 Walks Per Week',
price: '$55',
period: 'Per Walk',
popular: true,
features: ['Two to three regular walks each week', 'Free pickup and drop-off', 'Consistent exercise and social time', 'Best fit for busy owners']
features: ['Two to three regular walks each week', 'Free pickup and drop-off', 'Consistent exercise and social time', 'Most popular routine']
},
{
title: '4-5 Walks Per Week',
price: '$49.50',
period: 'Per Walk',
features: ['Four to five regular walks each week', 'Free pickup and drop-off', 'Maximum consistency and structure', 'Best for high-energy social dogs']
features: ['Four to five regular walks each week', 'Free pickup and drop-off', 'Maximum consistency and structure', 'For high-energy social dogs']
},
{
title: 'Casual Pack Walk',
price: '$65',
period: 'Per Walk',
features: ['Casual availability only', 'Free pickup and drop-off', 'For dogs already suited to pack walks', 'Higher rate than weekly routines']
features: ['Casual availability only', 'Free pickup and drop-off', 'For dogs already suited to pack walks', 'No weekly commitment']
}
],
extras: [
@@ -85,42 +108,41 @@ export const packWalksContent: ServicePageContent = {
},
benefits: {
title: 'Why the right dogs thrive in Tiny Gang',
intro:
'Small, compatible groups give dogs the exercise, confidence, and routine they need without the chaos of oversized pack walks.',
intro: 'Small groups. Compatible dogs. No chaos. That\'s why it works.',
items: [
{
title: 'Calmer evenings at home',
body: 'Small, structured outings help dogs burn energy without overstimulation, so they come home settled, content, and ready to rest.',
body: 'Structured outings burn energy without overstimulating. Your dog comes home tired and settled.',
badge: 'Structured weekly walks',
icon: 'fas fa-house'
},
{
title: 'Confidence with the right dogs',
body: 'Carefully matched groups help sociable dogs enjoy company at the right pace, without the pressure of bigger mixed packs.',
body: 'Matched groups. Right pace. No pressure from bigger mixed packs.',
badge: 'Carefully matched groups',
icon: 'fas fa-user-group'
},
{
title: 'No overwhelming pack dynamics',
body: 'Tiny Gang is designed for small and medium dogs, with group size, pace, and play style matched to help them feel safe.',
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.',
badge: 'Small & medium dogs',
icon: 'fas fa-shield-dog'
},
{
title: 'A routine owners can rely on',
body: 'Regular weekly slots give busy owners dependable exercise support and give dogs the comfort of a familiar rhythm.',
title: 'A weekly routine that sticks',
body: 'Regular weekly slots. Dependable for you. A familiar rhythm for your dog.',
badge: 'Reliable weekly slots',
icon: 'fas fa-calendar-check'
},
{
title: 'Individual attention still matters',
body: 'Smaller groups mean our walkers can notice confidence, handling, behaviour, and the little details that make a difference.',
title: 'Eyes on every dog',
body: 'Smaller groups mean we notice confidence, handling, and the small details that matter.',
badge: '48 dogs per walk',
icon: 'fas fa-eye'
},
{
title: 'Safety is built into the group',
body: 'Calm, compatible packs reduce intimidation and create a safer walking environment than a one-size-fits-all approach.',
title: 'Safer than a one-size pack',
body: 'Calm, compatible packs. Less intimidation. A safer walk than a one-size-fits-all approach.',
badge: 'Calm, compatible packs',
icon: 'fas fa-shield-heart'
}
@@ -128,42 +150,47 @@ export const packWalksContent: ServicePageContent = {
},
faq: {
title: 'Tiny Gang Pack Walk FAQs',
intro: 'The questions Auckland owners ask most before joining the Tiny Gang.',
intro: 'What Auckland owners ask before joining the Tiny Gang.',
items: [
{
question: 'How much does a Tiny Gang pack walk cost in Auckland?',
answer:
'Tiny Gang Pack Walks start from $49.50 per walk on a 45 walks per week routine. Casual pack walks are $65 per walk. Pickup and drop-off across Auckland Central is included in every booking.'
},
{
question: 'How big are the pack walks?',
answer:
'Tiny Gang outings run with 4-8 dogs maximum, carefully matched on size, energy, and play style. We never run oversized packs — the small group size is the whole point.'
'4 to 8 dogs, carefully matched on size, energy, and play style. We never run oversized packs — that is the whole point.'
},
{
question: 'What size and type of dog suits a Tiny Gang walk?',
answer:
'Tiny Gang is built for sociable small and medium dogs who genuinely enjoy other dogs. If your dog is reactive, anxious in groups, or much larger than the typical pack, a 1:1 walk is usually a better fit and we will tell you so honestly at the Meet & Greet.'
'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.'
},
{
question: 'How long is a Tiny Gang walk and where do you go?',
answer:
'Each outing is roughly 60-75 minutes on the ground, plus pickup and drop-off either side. We rotate across central Auckland parks like Western Springs, Fowlds Park, Cornwall Park, Grey Lynn Park, and the Oakley Creek walkway, choosing the spot based on weather and the dogs in that days group.'
'60 to 75 minutes on the ground, plus pickup and drop-off. We rotate Western Springs, Fowlds Park, Cornwall Park, Grey Lynn Park, and the Oakley Creek walkway — picked on the day for weather and group.'
},
{
question: 'Is pickup and drop-off included?',
answer:
'Yes — free door-to-door pickup and drop-off is included across Auckland Central suburbs (Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert and surrounds). You dont need to drive your dog anywhere.'
'Yes. Free door-to-door across Auckland Central Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert and surrounds. You drive nowhere.'
},
{
question: 'How does my dog join the Tiny Gang?',
answer:
'Every new dog starts with a free Meet & Greet at your home, then two assessment walks before joining a regular slot. This lets us see how your dog handles new dogs and being collected, and gives them time to settle in without pressure.'
'Free Meet & Greet at home, then two assessment walks before a regular slot. Time for your dog to settle in without pressure.'
},
{
question: 'Can I book casual or one-off pack walks?',
answer:
'Casual pack walks are available at a higher rate ($65/walk) and only for dogs already known to fit our packs. The best value, and what suits dogs most, is a regular weekly routine.'
'Casual walks are available at $65/walk, for dogs we already know fit. Regular weekly walks are better value and suit most dogs more.'
},
{
question: 'What happens if it rains on a pack walk day?',
answer:
'We walk in most weather. If conditions are genuinely unsafe for dogs (extreme heat, thunderstorms, flooding), we contact you to reschedule or substitute with a shorter, calmer outing.'
'We walk in most weather. If conditions are unsafe — extreme heat, thunderstorms, flooding we contact you to reschedule.'
}
]
},
@@ -176,6 +203,7 @@ export const packWalksContent: ServicePageContent = {
ownerStepLabel: 'Your details',
dogStepLabel: 'Dog details',
dogIntro:
'Tell us about your small or medium dog, where you are based, and anything important we should know so we can see if Tiny Gang is the right fit.'
}
'Tell us about your dog. Where you are. Anything we should know. We\'ll come back about the fit.'
},
lastUpdated: '2026-05-18'
};
+61 -40
View File
@@ -4,14 +4,11 @@ export const puppyVisitsContent: ServicePageContent = {
hero: {
eyebrow: 'Puppy Visits',
title: 'Give your puppy a calmer start while you are out',
subtitle: 'Toilet breaks, play, feeding, and calm one-on-one attention — at home, while you\'re out.',
subtitle: 'While you\'re at work, your puppy is fed, played with, and looked after. At home.',
paragraphs: [
'Goodwalk Puppy Visits are designed for busy owners who want their puppy cared for properly during the day, with toilet breaks, play, feeding, and calm one-on-one attention at home.',
'They are also the first stage of the Goodwalk journey. For puppies who may later join our Tiny Gang Pack Walks, these visits help build familiarity, confidence, and the early routines that make that transition much smoother.',
'A typical visit includes a toilet break in the garden or on a short on-lead walk, fresh water and a feed if scheduled, gentle play and enrichment to use up some puppy energy in the right way, and calm settling time before we leave so your puppy is more likely to rest after we go. You receive a short update with photos after each visit so you know how your puppy got on.',
'Puppy Visits are the better choice over a full walk while your puppy is still growing. Vets generally recommend keeping structured exercise short and avoiding pavement-heavy walks until growth plates have matured — typically 12 to 18 months depending on breed. Our visits give your puppy company, mental stimulation, and toilet support without the risk of overworking developing joints.',
'Instead of just getting through the day, your puppy gets a more thoughtful start, and you get more peace of mind while you are away.',
'We offer puppy visits across Auckland Central including Mt Eden, Ponsonby, Grey Lynn, Kingsland, Sandringham, Herne Bay, and surrounding suburbs.'
'A visit means a toilet break, fresh water, a feed if scheduled, play, and calm settling time before we leave. Photo update lands in your phone.',
'Short visits beat long walks while your puppy is growing. Vets recommend low-impact exercise until growth plates settle — usually 12 to 18 months. Visits give them company and stimulation without the joint stress.',
'Visits are also where Goodwalk usually starts. We know your puppy early, so the move to solo walks or Tiny Gang later is smooth.'
],
imageUrl: '/images/goodwalk-puppy-home-visit-auckland.webp',
imageAlt: 'Puppy receiving a calm Goodwalk home visit in Auckland',
@@ -24,126 +21,149 @@ export const puppyVisitsContent: ServicePageContent = {
},
highlight: {
eyebrow: 'Start well. Grow well.',
title: 'A home visit now can help set your puppy up for calmer routines and future Tiny Gang Pack Walks later on',
title: 'Calm routines now. A smoother Tiny Gang later.',
imageUrl: '/images/goodwalk-puppy-visit-cavalier-king-charles-spaniel-auckland.webp',
imageAlt: 'Young Cavalier King Charles Spaniel puppy resting at home before future Goodwalk Pack Walk training in Auckland',
points: [
{
title: 'Practical support in the middle of the day',
title: 'Real support in the middle of the day',
body:
'Toilet breaks, feeding, play, and calming company help puppies get through the day in a way that feels properly cared for.'
'Toilet break. Feed if scheduled. Play. Calm company. Your puppy gets through the day properly looked after.'
},
{
title: 'Early routines that actually matter later',
title: 'Early routines that pay off later',
body:
'Puppy Visits help build familiarity, confidence, and handling habits before your puppy is ready for bigger social adventures.'
'Familiarity, confidence, and handling habits built before your puppy is ready for bigger adventures.'
},
{
title: 'A more personal start with Goodwalk',
title: 'A calmer start with Goodwalk',
body:
'Your puppy gets to know us early in a calm, familiar environment, which makes any future transition into walks feel much smoother.'
'Your puppy learns us early, in a familiar environment. The transition into walks later is smooth, not sudden.'
}
]
},
decision: {
title: 'Is a Puppy Visit right for you?',
fitTitle: 'Puppy Visits fit best if:',
fitItems: [
'Your puppy is 8 weeks or older',
'You are out of the house during the day',
'Your puppy needs toilet breaks, feeding, or company',
'You want a calm, in-home start to a longer Goodwalk journey',
'You live in Auckland Central'
],
notFitTitle: 'A different service may suit better if:',
notFitItems: [
'Your dog is fully grown and confident on walks (try solo walks or Tiny Gang)',
'You need overnight or weekend boarding',
'Your puppy needs medical or veterinary care during the day'
],
footnote: 'Free Meet & Greet first. Always.'
},
pricing: {
title: 'Choose the visit length that suits your puppy',
title: 'Pick the visit length that fits',
intro:
'Puppy Visits are built around your puppys age, routine, and energy levels, with practical support now and foundations for later social walking if they are a good fit for our Tiny Gang.',
'Built around your puppy. Real support now. Foundations for whatever comes next.',
plans: [
{
title: '20 Minute Visit',
price: '$39',
period: 'Per Visit',
features: ['Toilet break and check-in', 'Feeding if needed', 'Gentle one-on-one attention', 'Good for shorter midday support']
features: ['Toilet break and check-in', 'Feeding if needed', 'Gentle one-on-one attention', 'For shorter midday support']
},
{
title: '45 Minute Visit',
price: '$49',
period: 'Per Visit',
features: ['Toilet break and feeding if needed', 'Play and enrichment time', 'Early routine-building support', 'Best fit for many puppies']
features: ['Toilet break and feeding if needed', 'Play and enrichment time', 'Early routine-building support', 'Most popular visit length']
},
{
title: '60 Minute Visit',
price: '$55',
period: 'Per Visit',
features: ['Longer home visit', 'More play, settling, and engagement', 'Extra support for younger puppies', 'Best for pups needing more time']
features: ['Longer home visit', 'More play, settling, and engagement', 'Extra support for younger puppies', 'For pups who need more time']
}
],
extras: [
{ label: 'Extra Puppy', note: 'Same household', price: '$15' },
{ label: 'Photo & update check-in', note: 'Included', price: '$0' }
],
scarcityNote: 'Puppy Visit spaces are limited so we can keep care consistent.'
scarcityNote: 'Puppy Visit spaces are limited.'
},
benefits: {
title: 'Why Puppy Visits matter early',
intro:
'The puppy stage moves fast. Regular daytime visits give your puppy support now while also building the calm routines and familiarity that make later life easier.',
'The puppy stage moves fast. Daytime visits help now, and build routines that make later life easier.',
items: [
{
title: 'Fewer long stretches alone',
body: 'Regular visits break up the day, help with toilet timing, and give your puppy company, care, and comfort while you are out.'
body: 'Regular visits break up the day. Toilet timing handled. Company, care, comfort while you are out.'
},
{
title: 'Better foundations for future Tiny Gang Pack Walks',
body: 'For puppies who may later join our Tiny Gang, early visits help build confidence, familiarity, and the routines that support a smoother next step.'
title: 'Foundations for Tiny Gang later',
body: 'For puppies who\'ll join Tiny Gang later, early visits build the confidence and routines that make the next step smooth.'
},
{
title: 'A calmer puppy at home',
body: 'Play, enrichment, and routine help use up some puppy energy in the right way, which can mean a more settled puppy through the rest of the day.'
body: 'Play, enrichment, and routine burn puppy energy the right way. The rest of the day is more settled.'
},
{
title: 'Support for busy owners too',
body: 'You get practical help during a demanding stage, plus guidance from a team that understands how much consistency matters when puppies are learning fast.'
body: 'Real help when puppies are learning fast. Guidance from someone who\'s been through this stage with dozens of dogs.'
},
{
title: 'Early habits start taking shape',
body: 'Visits give us time to reinforce the basics around handling, routine, and calm engagement before those small habits become bigger problems.'
title: 'Early habits, shaped right',
body: 'Visits reinforce calm handling, routine, and engagement before small habits turn into bigger problems.'
},
{
title: 'A more personal start with Goodwalk',
body: 'Puppy Visits help your puppy get to know us early, which builds trust and makes any future transition into other Goodwalk services feel more natural.'
title: 'A personal start with Goodwalk',
body: 'Your puppy learns us early. Trust builds. The transition into other Goodwalk services feels natural, not new.'
}
]
},
faq: {
title: 'Puppy Visit FAQs',
intro: 'The questions Auckland puppy owners ask most before booking a home visit.',
intro: 'What Auckland puppy owners ask before booking a home visit.',
items: [
{
question: 'How much does a puppy visit cost in Auckland?',
answer:
'Puppy Visits start from $39 per 20-minute visit. A 45-minute visit is $49 and a 60-minute visit is $55. Visits cover toilet breaks, feeding if scheduled, play, and calm one-on-one attention in your home.'
},
{
question: 'How young can my puppy start with a visit?',
answer:
'We routinely visit puppies from around 8-10 weeks. Early visits focus on calm handling, gentle company, and easy routine support — not formal training — so they work even before your puppy is fully vaccinated.'
'From 8 to 10 weeks. Early visits focus on calm handling, gentle company, and easy routine support — not formal training. They work even before full vaccination.'
},
{
question: 'What actually happens during a Puppy Visit?',
answer:
'A visit typically includes a toilet break, fresh water, calm play or settle time, gentle handling, and any feeding or routine support youve asked for. Well send a short update with photos after every visit.'
'Toilet break. Fresh water. Calm play or settle time. Gentle handling. Feeding or routine support if you have asked for it. Photos and a short update after every visit.'
},
{
question: 'How long is each visit?',
answer:
'Standard visits run around 30 minutes, which is the sweet spot for most young puppies. Longer visits are available if your puppy needs more time, but we keep the session-length appropriate to their age and stamina.'
'20 minutes for shorter midday support. 45 minutes for most puppies. 60 minutes if your pup needs more time.'
},
{
question: 'Do you take my puppy outside the house?',
answer:
'Most visits are entirely in-home until your puppy is fully vaccinated. After that, short, calm outings can be added. We never take young puppies on group walks — that comes much later, if and when theyre ready.'
'In-home only until full vaccination. After that, short calm outings can be added. We never take young puppies on group walks — that comes later, if and when they are ready.'
},
{
question: 'Will I get the same person each visit?',
answer:
'Yes — consistency matters most for puppies. Youll have the same trusted handler so your puppy learns to feel safe with a familiar person showing up at the door.'
'Yes. Consistency matters most for puppies. Same trusted handler at the door, every time.'
},
{
question: 'Can Puppy Visits lead into Tiny Gang Pack Walks later?',
answer:
'Thats exactly what theyre designed for. By the time your puppy is old enough and the right temperament fit, we already know them well — so the transition into 1:1 walks or Tiny Gang Pack Walks is much smoother.'
'Exactly what they\'re for. By the time your puppy is old enough, we already know them. The next step is smooth, not new.'
},
{
question: 'Do I need to be home for the visit?',
answer:
'No — most visits happen while owners are at work. We can sort key access, lockboxes, or smart-lock entry at your Meet & Greet so the visit happens reliably whether youre home or not.'
'No. Most visits happen while owners are at work. Key access, lockboxes, or smart-lock entry — sorted at the Meet & Greet.'
}
]
},
@@ -156,6 +176,7 @@ export const puppyVisitsContent: ServicePageContent = {
ownerStepLabel: 'Your details',
dogStepLabel: 'Puppy details',
dogIntro:
'Tell us about your puppy, your area, routine, and any special needs so we can plan the right visit and see what support fits best.'
}
'Tell us about your puppy. Where you are. Their routine. We\'ll plan the right visit.'
},
lastUpdated: '2026-05-18'
};
+4 -4
View File
@@ -1,5 +1,5 @@
export interface SharedServiceDefinition {
title: 'Tiny Gang Pack Walks' | '1:1 Walks' | 'Puppy Visits';
title: 'Tiny Gang Pack Walks' | 'Solo Walks' | 'Puppy Visits';
href: string;
icon: string;
megaMenuDescription: string;
@@ -19,11 +19,11 @@ export const sharedServices: SharedServiceDefinition[] = [
priceFrom: 'From $49.50 / walk'
},
{
title: '1:1 Walks',
title: 'Solo Walks',
href: '/dog-walking',
icon: 'fas fa-person-walking',
megaMenuDescription: 'Personalised solo walks',
cardBody: "One-on-one walks tailored to your dog's individual pace, personality, and needs.",
megaMenuDescription: 'One dog. One walker.',
cardBody: "Solo walks tailored to your dog's pace, personality, and needs.",
locationDescription: 'One dog, full attention, tailored pace.',
priceFrom: 'From $45 / walk'
},
+4 -4
View File
@@ -6,9 +6,9 @@ export const staticPages = {
canonicalPath: '/pack-walks'
},
'dog-walking': {
title: '1:1 Dog Walks Auckland | Mt Eden, Ponsonby & Kingsland | Goodwalk',
title: 'Solo Dog Walks Auckland | Mt Eden, Ponsonby & Kingsland | Goodwalk',
description:
'One-on-one dog walks across Auckland Central — Mt Eden, Kingsland, Ponsonby, Grey Lynn and more. For dogs who need more space, attention, and a calmer routine.',
'Solo, one-on-one dog walks across Auckland Central — Mt Eden, Kingsland, Ponsonby, Grey Lynn and more. For dogs who need more space, attention, and a calmer routine.',
canonicalPath: '/dog-walking'
},
'puppy-visits': {
@@ -18,9 +18,9 @@ export const staticPages = {
canonicalPath: '/puppy-visits'
},
'our-pricing': {
title: 'Dog Walking Prices Auckland | Tiny Gang Pack Walks & 1:1 Walks | Goodwalk',
title: 'Dog Walking Prices Auckland | Tiny Gang Pack Walks & Solo Walks | Goodwalk',
description:
'Transparent pricing for Goodwalk pack walks, 1:1 dog walks, and puppy visits across Auckland Central. From $49.50 per walk. Free Meet & Greet included.',
'Transparent pricing for Goodwalk pack walks, solo dog walks, and puppy visits across Auckland Central. From $49.50 per walk. Free Meet & Greet included.',
canonicalPath: '/our-pricing'
},
about: {
+9 -2
View File
@@ -2,15 +2,22 @@ import type { Picture } from '@sveltejs/enhanced-img';
// In dev, imagetools can be painfully slow for large local assets.
// Fall back to the already-served static files and keep enhanced variants for production builds.
const modules: Record<string, { default: Picture }> = import.meta.env.DEV
// We intentionally exclude .webp here because most Goodwalk assets are already exported as
// production-ready WebP files. Reprocessing them during deploy/build adds churn for little gain.
// This is additionally guarded by a public env flag so production deploys can skip
// eager content-image processing unless we explicitly need it for non-WebP assets.
const ENABLE_ENHANCED_CONTENT_IMAGES = import.meta.env.PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES === 'true';
const modules: Record<string, { default: Picture }> = import.meta.env.DEV || !ENABLE_ENHANCED_CONTENT_IMAGES
? {}
: (import.meta.glob('./images/**/*.{jpg,jpeg,png,webp,avif,gif}', {
: (import.meta.glob('./images/**/*.{jpg,jpeg,png,avif,gif}', {
eager: true,
query: { enhanced: true }
}) as Record<string, { default: Picture }>);
export function getEnhancedImage(src: string | undefined | null): Picture | null {
if (!src) return null;
if (src.toLowerCase().endsWith('.webp')) return null;
// '/images/foo.webp' -> './images/foo.webp' (relative to src/lib/)
const key = '.' + src;
return modules[key]?.default ?? null;
+1 -1
View File
@@ -61,7 +61,7 @@ export function buildLocationSeo(location: LocationPageContent) {
location.seo?.title ?? `Dog Walkers in ${location.suburb} | Goodwalk Auckland`;
const description =
location.seo?.description ??
`Goodwalk provides pack walks, 1:1 walks, and puppy visits in ${location.suburb}, Auckland Central. Small dog specialists with free pickup and drop-off. Book a free Meet & Greet.`;
`Goodwalk provides pack walks, solo walks, and puppy visits in ${location.suburb}, Auckland Central. Small dog specialists with free pickup and drop-off. Book a free Meet & Greet.`;
const image = getLocationSeoImage(location);
const imageAlt = getLocationSeoImageAlt(location);
const breadcrumbLabel = location.seo?.breadcrumbLabel ?? location.suburb;
+1 -1
View File
@@ -10,7 +10,7 @@
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
border-radius: 40px;
border-radius: 32px;
cursor: pointer;
transition:
background 0.2s,
+6 -6
View File
@@ -121,7 +121,7 @@
gap: 28px;
margin-top: 24px;
padding: 18px 22px;
border-radius: 28px;
border-radius: 24px;
background: linear-gradient(180deg, var(--surface-page) 0%, var(--surface-panel-soft) 100%);
box-shadow:
var(--shadow-inset-soft),
@@ -208,7 +208,7 @@
radial-gradient(circle at top right, var(--surface-highlight-soft), transparent 32%),
linear-gradient(180deg, var(--surface-page) 0%, var(--surface-panel-cream-strong) 100%);
color: var(--text-heading-soft);
border-radius: 28px 28px 0 0;
border-radius: 24px 24px 0 0;
padding: 20px 24px 22px;
text-align: center;
font-family: var(--font-body);
@@ -244,7 +244,7 @@
.booking-field-card {
background: linear-gradient(180deg, var(--surface-page) 0%, var(--surface-panel-warm) 100%);
border-radius: 28px;
border-radius: 24px;
padding: 28px 32px 26px;
box-shadow:
var(--shadow-inset-soft),
@@ -339,7 +339,7 @@
align-items: center;
min-height: 44px;
padding: 10px 16px;
border-radius: 18px;
border-radius: 16px;
background: linear-gradient(180deg, var(--surface-brand-muted), rgba(var(--brand-rgb), 0.05));
color: var(--text-brand);
font-family: var(--font-body);
@@ -356,7 +356,7 @@
.booking-field-card textarea {
width: 100%;
border: 2px solid var(--border-brand-strong);
border-radius: 16px;
border-radius: 14px;
background: var(--surface-input);
padding: 13px 18px;
font-size: 16px;
@@ -392,7 +392,7 @@
align-items: center;
gap: 24px;
background: var(--surface-page);
border-radius: 28px;
border-radius: 24px;
padding: 22px 28px;
box-shadow:
var(--shadow-inset-soft),
+5 -5
View File
@@ -167,7 +167,7 @@ nav {
gap: 6px;
white-space: nowrap;
padding: 8px 16px;
border-radius: 40px;
border-radius: 32px;
transition: background 0.15s, color 0.15s;
}
@@ -207,7 +207,7 @@ nav {
.mega-menu-inner {
background: var(--surface-panel);
border-radius: 20px;
border-radius: 18px;
padding: 16px;
display: flex;
flex-direction: column;
@@ -230,7 +230,7 @@ nav {
justify-content: space-between;
margin-top: 12px;
padding: 12px 14px;
border-radius: 12px;
border-radius: 10px;
background: rgba(var(--brand-rgb), 0.04);
color: var(--text-brand);
font-size: 13px;
@@ -259,7 +259,7 @@ nav {
align-items: center;
gap: 10px;
padding: 16px 14px 14px;
border-radius: 14px;
border-radius: 12px;
flex: 1;
text-decoration: none;
color: var(--text);
@@ -278,7 +278,7 @@ nav {
width: 64px;
height: 64px;
background: var(--surface-brand);
border-radius: 16px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
+17
View File
@@ -746,6 +746,23 @@
}
}
/* Mid-range laptops (13"15"): the dog mascot otherwise drifts close
enough to the hero headline that its ears clip the "happy dog" line.
Shrink the image footprint slightly and pull it further left so there's
a clear gap before the text column begins. */
@media (min-width: 769px) and (max-width: 1599px) {
.hero-img img {
width: 54%;
transform: translateX(-200px);
}
}
@media (min-width: 1280px) and (max-width: 1599px) {
.hero-img img {
transform: translateX(-240px);
}
}
@media (min-width: 1200px) {
body:has(#hero) .hero-inner {
padding-bottom: 120px;
+4 -4
View File
@@ -140,10 +140,10 @@
--shadow-input-inset: inset 0 1px 2px rgba(var(--ink-rgb), 0.03);
/* Radius scale */
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-xl: 24px;
--radius-pill: 999px;
/* Motion */
+15
View File
@@ -148,6 +148,15 @@ export interface ServiceHighlightPoint {
body: string;
}
export interface ServiceDecisionBlock {
title?: string;
fitTitle?: string;
fitItems: string[];
notFitTitle?: string;
notFitItems: string[];
footnote?: string;
}
export interface ServicePageContent {
hero: {
eyebrow: string;
@@ -156,6 +165,10 @@ export interface ServicePageContent {
paragraphs: string[];
introEyebrow?: string;
introHeading?: string;
detailHighlights?: {
value: string;
label: string;
}[];
imageUrl: string;
imageAlt: string;
chips?: HeroChip[];
@@ -169,6 +182,7 @@ export interface ServicePageContent {
collageImages?: ServiceHighlightImage[];
points?: ServiceHighlightPoint[];
};
decision?: ServiceDecisionBlock;
pricing: {
title: string;
intro?: string;
@@ -188,6 +202,7 @@ export interface ServicePageContent {
};
testimonialsHeading: string;
booking: BookingContent;
lastUpdated?: string;
}
export interface PricingPageSectionMeta {
+46 -3
View File
@@ -6,7 +6,7 @@
import HowItWorksSection from '$lib/components/HowItWorksSection.svelte';
import InfoSection from '$lib/components/InfoSection.svelte';
import InstagramSection from '$lib/components/InstagramSection.svelte';
import BookingSection from '$lib/components/BookingSection.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';
@@ -43,7 +43,7 @@
'@id': 'https://www.goodwalk.co.nz/#business',
name: 'Goodwalk',
description:
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
'Professional dog walking services across Auckland Central, including pack walks, solo walks, and puppy visits.',
url: siteUrl,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.webp`,
image: absoluteUrl(data.content.hero.imageUrl),
@@ -96,6 +96,49 @@
name: testimonial.reviewer
},
reviewBody: testimonial.quote
})),
founder: { '@id': `${siteUrl}/#alessandra` },
employee: [{ '@id': `${siteUrl}/#alessandra` }]
},
{
'@context': 'https://schema.org',
'@type': 'Person',
'@id': `${siteUrl}/#alessandra`,
name: 'Alessandra',
jobTitle: 'Founder & lead dog walker',
worksFor: { '@id': `${siteUrl}/#business` },
image: absoluteUrl('/images/alessandra-goodwalk-founder-auckland.webp'),
url: `${siteUrl}/about`,
knowsAbout: [
'Dog walking',
'Small and medium dog handling',
'Puppy socialisation',
'Pet first aid'
],
hasCredential: [
{
'@type': 'EducationalOccupationalCredential',
credentialCategory: 'certification',
name: 'Pet First Aid Certified'
},
{
'@type': 'EducationalOccupationalCredential',
credentialCategory: 'insurance',
name: 'Public Liability Insurance'
}
]
},
{
'@context': 'https://schema.org',
'@type': 'HowTo',
name: 'How to start with Goodwalk',
description: data.content.howItWorks.intro,
totalTime: 'P14D',
step: data.content.howItWorks.steps.map((step, index) => ({
'@type': 'HowToStep',
position: index + 1,
name: step.title,
text: step.body
}))
}
]
@@ -132,7 +175,7 @@
<TestimonialsSection testimonials={content.testimonials} seedKey="/" />
<FounderStorySection founderStory={content.founderStory} />
<InfoSection info={content.info} />
<BookingSection booking={content.booking} variant="card-stepper" />
<BookingWizard booking={content.booking} pagePath="/" />
<InstagramSection instagram={content.instagram} />
<Footer footer={content.footer} />
{/if}
+23 -5
View File
@@ -47,6 +47,15 @@
};
}
const sharedServiceRating = {
'@type': 'AggregateRating',
ratingValue: '5.0',
bestRating: '5',
worstRating: '1',
reviewCount: '30'
};
const expertProvider = { '@id': `${siteUrl}/#alessandra` };
const areaServed = buildAreaServed();
let seoImage = defaultSeoImage;
@@ -87,7 +96,10 @@
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
offers: aggregateOfferSchema(packWalksContent.pricing.plans),
aggregateRating: sharedServiceRating,
author: expertProvider,
dateModified: packWalksContent.lastUpdated
},
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
@@ -102,18 +114,21 @@
{
'@context': 'https://schema.org',
'@type': 'Service',
name: 'Goodwalk 1:1 Dog Walks',
name: 'Goodwalk Solo Dog Walks',
description: data.page.description,
serviceType: 'One-on-one dog walking',
provider: { '@id': 'https://www.goodwalk.co.nz/#business' },
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans),
aggregateRating: sharedServiceRating,
author: expertProvider,
dateModified: dogWalkingContent.lastUpdated
},
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: '1:1 Walks', path: data.page.canonicalPath }
{ name: 'Solo Walks', path: data.page.canonicalPath }
])
];
} else if (data.slug === 'puppy-visits') {
@@ -131,7 +146,10 @@
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans),
aggregateRating: sharedServiceRating,
author: expertProvider,
dateModified: puppyVisitsContent.lastUpdated
},
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
+2 -2
View File
@@ -10,7 +10,7 @@
const title = 'Auckland Dog Walking Locations | Goodwalk Service Areas';
const description =
'Goodwalk provides pack walks, 1:1 walks, and puppy visits across 17 Auckland Central suburbs. Find your suburb and see local parks, walking routes, and how we serve your area.';
'Goodwalk provides pack walks, solo walks, and puppy visits across 17 Auckland Central suburbs. Find your suburb and see local parks, walking routes, and how we serve your area.';
const canonicalPath = '/locations';
const structuredData = [
@@ -31,7 +31,7 @@
<p class="hub-kicker">Service Areas</p>
<h1>Where Goodwalk walks dogs in Auckland</h1>
<p class="hub-lead">
We cover 17 suburbs across Auckland Central with pack walks, 1:1 walks, and
We cover 17 suburbs across Auckland Central with pack walks, solo walks, and
puppy visits — including free pickup and drop-off. Choose your suburb below
to see local parks and walking routes.
</p>
@@ -1,9 +1,17 @@
import { error } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import { locationsBySlug } from '$lib/content/locations';
import { getSharedPageContent } from '$lib/server/content';
import type { PageServerLoad } from './$types';
// Retired location slugs that were previously indexed. We send a 301 to the
// locations index so any inbound traffic or search ranking is preserved.
const retiredSlugs = new Set(['avondale', 'arch-hill', 'eden-terrace']);
export const load: PageServerLoad = async ({ params }) => {
if (retiredSlugs.has(params.suburb)) {
throw redirect(301, '/locations/');
}
const location = locationsBySlug[params.suburb];
if (!location) {
+1 -1
View File
@@ -25,7 +25,7 @@
const journeyStorageKey = 'goodwalk_journey';
const maxJourneyEntries = 8;
const serviceOptions = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'];
const serviceOptions = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services'];
const dogShortcuts = ['puppy', 'senior', 'rescue', 'shy'];
let step = 1;
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import AdminDashboard from '$lib/components/admin-dashboard/AdminDashboard.svelte';
</script>
<AdminDashboard />
+2
View File
@@ -6,6 +6,8 @@ export const GET: RequestHandler = () => {
'Allow: /',
'Disallow: /api/',
'Disallow: /contract',
'Disallow: /meet-greet-v2',
'Disallow: /variants-contact-form',
'',
'# AI crawlers — explicitly permitted',
'User-agent: GPTBot',
@@ -49,9 +49,10 @@
<svelte:head>
<title>Contact Form Variants | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
<meta
name="description"
content="Six conversion-focused Goodwalk contact form variants for internal review before updating the live Meet and Greet flow."
content="Internal contact form variant review. Not indexed."
/>
</svelte:head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+15 -5
View File
@@ -11,15 +11,22 @@ Goodwalk is Auckland Central's small dog walking specialist, run personally by A
- Hours: MondayFriday, 8am4pm
- 30+ five-star Google reviews
- Speciality: Small and medium dogs
- Insured: Public liability insurance
- Certified: Current pet first aid certificate
## Pricing summary
- Tiny Gang Pack Walks: from $49.50 per walk
- Solo Dog Walks: from $45 per walk
- Puppy Visits: from $39 per visit
## Services
- Tiny Gang Pack Walks: https://www.goodwalk.co.nz/pack-walks
- 1:1 Dog Walks: https://www.goodwalk.co.nz/dog-walking
- Solo Dog Walks: https://www.goodwalk.co.nz/dog-walking
- Puppy Visits: https://www.goodwalk.co.nz/puppy-visits
- Pricing: https://www.goodwalk.co.nz/our-pricing
## Suburbs served
Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, Herne Bay, Morningside, Freemans Bay, Pt Chevalier, Avondale, Three Kings, Hillsborough, Eden Terrace, Balmoral, Arch Hill, Mt Roskill
Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, Herne Bay, Morningside, Freemans Bay, Pt Chevalier, Remuera, Greenlane, Onehunga, Three Kings, Hillsborough, Balmoral, Mt Roskill
## Location pages
- https://www.goodwalk.co.nz/locations/mt-eden
@@ -32,13 +39,16 @@ Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, Herne Bay, Morn
- https://www.goodwalk.co.nz/locations/morningside
- https://www.goodwalk.co.nz/locations/freemans-bay
- https://www.goodwalk.co.nz/locations/pt-chevalier
- https://www.goodwalk.co.nz/locations/avondale
- https://www.goodwalk.co.nz/locations/remuera
- https://www.goodwalk.co.nz/locations/greenlane
- https://www.goodwalk.co.nz/locations/onehunga
- https://www.goodwalk.co.nz/locations/three-kings
- https://www.goodwalk.co.nz/locations/hillsborough
- https://www.goodwalk.co.nz/locations/eden-terrace
- https://www.goodwalk.co.nz/locations/balmoral
- https://www.goodwalk.co.nz/locations/arch-hill
- https://www.goodwalk.co.nz/locations/mt-roskill
## Homepage
https://www.goodwalk.co.nz
## Last updated
2026-05-18
+4
View File
@@ -215,6 +215,10 @@ export default defineConfig(() => {
target: submitProxyTarget,
rewrite: (path) => path.replace('/api/auth', '/auth'),
},
'/api/owner': {
target: submitProxyTarget,
rewrite: (path) => path.replace('/api/owner', '/owner'),
},
'/api/save-draft': {
target: submitProxyTarget,
rewrite: (path) => path.replace('/api/save-draft', '/auth/save-draft'),