This commit is contained in:
2026-05-19 23:36:58 +12:00
parent 5172588488
commit a7f8a619b1
68 changed files with 4486 additions and 1430 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"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/**)"
"Read(//c/Users/mattc/.claude/plugins/cache/impeccable/impeccable/3.1.1/skills/impeccable/reference/**)",
"Bash(node *)"
]
}
}
+41 -15
View File
@@ -8,17 +8,19 @@ 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) |
| `clients.goodwalk.co.nz` | New-client onboarding + contract portal |
| `cp.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 shared nginx container reads TLS material from its host bind mount at
`/docker/certbot/conf`, exposed inside the container as `/etc/letsencrypt`.
The deploy scripts now auto-bootstrap any missing certificates referenced by
the shared nginx config, including `clients.goodwalk.co.nz`,
`cp.goodwalk.co.nz`, and the legacy HTTPS redirect aliases
`onboarding.goodwalk.co.nz` and `admin.goodwalk.co.nz`. They do that by
temporarily loading an HTTP-only nginx config and running `certbot/certbot`
against the mounted ACME webroot. The only prerequisite is that each
hostname's DNS A record already points at the droplet and port `80` is
reachable.
The dashboard's data (`client_profiles`, `allowed_emails`, `drafts`) lives in
the shared postgres database alongside the marketing site content, in a single
@@ -47,7 +49,7 @@ The production server currently runs multiple separate Docker Compose projects:
- Main public site WordPress stack:
- project: `goodwalkconz`
- path: `/docker/wordpress/goodwalk.co.nz`
- Onboarding WordPress stack:
- Legacy onboarding WordPress stack:
- project: `onboardinggoodwalkconz`
- path: `/docker/wordpress/onboarding.goodwalk.co.nz`
- Shared nginx:
@@ -92,7 +94,7 @@ containers untouched.
- Repo-local SSH config used by the deployment script.
- [nginx/goodwalk.co.nz.svelte.conf.example](nginx/goodwalk.co.nz.svelte.conf.example)
- Example shared-nginx config for routing the main public site to the new
Svelte app and mail API, including the onboarding subdomain.
Svelte app and mail API, including the `clients` and `cp` subdomains.
## First-time server preparation
@@ -113,7 +115,7 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env
APP_VERSION=4.0.0
APP_VERSION=4.0.1
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false
@@ -126,6 +128,8 @@ POSTGRES_PASSWORD_URLENCODED=gw_Pg_7Jm9%21Qx4%23Ld2%40Vr8
RESEND_API_KEY=replace-me
OWNER_EMAIL=replace-me
SECONDARY_CP_EMAIL=
SECONDARY_CP_EMAILS=
FROM_EMAIL=GoodWalk <bookings@goodwalk.co.nz>
REPLY_TO=aless@goodwalk.co.nz
MAIL_API_DATA_DIR=/app/data
@@ -136,6 +140,7 @@ RATE_LIMIT_WINDOW_SECONDS=900
RATE_LIMIT_MAX_PER_IP=5
RATE_LIMIT_MAX_PER_EMAIL=3
RATE_LIMIT_MIN_INTERVAL_SECONDS=20
EMAIL_SEND_TIMEOUT_SECONDS=20
```
After the first deploy, edit `/docker/goodwalk-svelte/.env` on the server and replace:
@@ -143,6 +148,14 @@ After the first deploy, edit `/docker/goodwalk-svelte/.env` on the server and re
- `RESEND_API_KEY=replace-me`
- `OWNER_EMAIL=replace-me`
Optional CP dashboard admins:
- `SECONDARY_CP_EMAIL=person@example.com`
- `SECONDARY_CP_EMAILS=bob@smith.com;bobsmith2@smith.com`
`OWNER_EMAIL` always keeps CP access. The secondary values are optional and may be
semicolon-, comma-, or whitespace-separated.
Frontend flags:
- `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false` keeps the sticky mobile booking CTA hidden.
@@ -157,6 +170,17 @@ docker network ls | grep webnet
Your server already uses `webnet`, so this should already be present.
5. Confirm the shared nginx compose mounts still point at the host certbot
paths expected by the deploy scripts:
```yaml
- /docker/certbot/conf:/etc/letsencrypt:ro
- /docker/certbot/www:/var/www/certbot:ro
```
The scripts inspect the running `nginx` container to derive those host paths
before checking or issuing certificates.
## First deploy
From Windows PowerShell in the repo root:
@@ -293,8 +317,10 @@ nginx/goodwalk.co.nz.svelte.conf.example
Important:
- `deploy.ps1` now copies the repo nginx config to `/docker/nginx/conf.d/goodwalk.co.nz.conf` and reloads the shared nginx container as part of deployment.
- The repo nginx config uses Docker's internal resolver so future app/mail container rebuilds will not leave nginx pinned to stale upstream IPs.
- The same nginx config now also routes `onboarding.goodwalk.co.nz` to the Svelte app and `/api/onboarding-submit` to the shared mail API.
- Before cutover, confirm the server has a valid certificate for `onboarding.goodwalk.co.nz`, or adjust the onboarding certificate paths in the nginx config to match your cert layout.
- The same nginx config now also routes `clients.goodwalk.co.nz` to the Svelte app and `/api/onboarding-submit` to the shared mail API.
- The owner dashboard is now served on `cp.goodwalk.co.nz`.
- `onboarding.goodwalk.co.nz` and `admin.goodwalk.co.nz` should be kept only as redirect aliases once their DNS and TLS are in place.
- The deploy script will attempt to issue any missing certificates for `clients.goodwalk.co.nz`, `cp.goodwalk.co.nz`, and `onboarding.goodwalk.co.nz` before the final nginx reload.
Manual nginx commands, if you ever need them:
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.0
ARG APP_VERSION=4.0.1
FROM node:22-alpine AS builder
ARG APP_VERSION
+57
View File
@@ -0,0 +1,57 @@
{
"info@goodwalk.co.nz": {
"onboarding": {
"currentStep": 5,
"ownerFirstName": "test@test",
"ownerLastName": "test@test",
"email": "test@test.com",
"phone": "test",
"address": "test",
"dogName": "test",
"dogLastName": "test",
"dogBreed": "test",
"dogDateOfBirth": "2026-05-19",
"servicesNeeded": [
"Tiny Gang Pack Walks"
],
"temperament": "test",
"accessInstructions": "",
"vetName": "test",
"vetAddress": "test",
"vetPhone": "test",
"emergencyContactName": "test",
"emergencyContactPhone": "test",
"isVaccinated": "yes",
"hasFoodAllergies": "no",
"foodAllergiesDetail": "",
"hasEnvAllergies": "no",
"envAllergiesDetail": "",
"onSpecialDiet": "no",
"specialDietDetail": "",
"onMedication": "no",
"medicationDetail": "",
"wellSocialised": "yes",
"dogsInteractedWeekly": 1,
"visitsBeach": "no",
"visitsDogParks": "no",
"dogParksFrequency": "",
"biteHistory": "no",
"reactiveToDogs": "no",
"reactiveToAnimals": "no",
"reactiveToChildren": "no",
"reactiveToPeople": "no",
"isDesexed": "yes",
"isRegistered": "yes",
"leashTrained": "yes",
"recallRating": 5,
"ranAwayBefore": "no",
"carBehaviour": "test",
"knownCommands": "test",
"additionalNotes": "test",
"socialMediaAccount": "test",
"howDidYouHear": "test",
"emergencyVetConsent": true,
"termsAccepted": true
}
}
}
+10 -1
View File
@@ -1,4 +1,4 @@
APP_VERSION=4.0.0
APP_VERSION=4.0.1
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -8,6 +8,8 @@ POSTGRES_PASSWORD_URLENCODED=gw_Pg_7Jm9%21Qx4%23Ld2%40Vr8
RESEND_API_KEY=re_hcDByLp8_HEBW93wDirr7o9g16FgCeYNF
OWNER_EMAIL=info@goodwalk.co.nz
SECONDARY_CP_EMAIL=mattcohen0@gmail.com
SECONDARY_CP_EMAILS=
OWNER_BCC=mattcohen0@gmail.com
CLIENT_BCC=mattcohen0@gmail.com
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
@@ -23,3 +25,10 @@ RATE_LIMIT_WINDOW_SECONDS=900
RATE_LIMIT_MAX_PER_IP=5
RATE_LIMIT_MAX_PER_EMAIL=3
RATE_LIMIT_MIN_INTERVAL_SECONDS=20
EMAIL_SEND_TIMEOUT_SECONDS=20
# Security hardening — sensible defaults are in mail_api/config.py.
# Override only if the public domains change or you need to allow extra origins.
# CORS_ALLOWED_ORIGINS=https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz
# TRUSTED_HOSTS=goodwalk.co.nz,www.goodwalk.co.nz,clients.goodwalk.co.nz,cp.goodwalk.co.nz,localhost,127.0.0.1
# MAX_REQUEST_BODY_BYTES=2097152
+13 -3
View File
@@ -180,10 +180,19 @@ function Invoke-SiteCheck {
try {
$response = Invoke-WebRequest -Uri $Url -MaximumRedirection 5 -TimeoutSec 30
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) {
Write-Host "[deploy] Site responded with HTTP $($response.StatusCode)"
return
}
throw "Unexpected HTTP $($response.StatusCode) from $Url"
}
catch {
Write-Warning "Production site check failed: $($_.Exception.Message)"
$message = "Post-deploy site check failed: $($_.Exception.Message). Verify URL: $Url"
if ($SkipSiteCheck) {
Write-Warning $message
} else {
throw $message
}
}
}
@@ -265,8 +274,9 @@ 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)'
Write-Host ' clients.goodwalk.co.nz (client onboarding + contracts)'
Write-Host ' cp.goodwalk.co.nz (owner admin dashboard)'
Write-Host ' onboarding/admin remain legacy redirect aliases'
if ($SeedAdminData) {
Write-Host ' - Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.'
}
+17 -4
View File
@@ -3,10 +3,10 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
container_name: goodwalk_svelte_app
environment:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: 3000
@@ -27,14 +27,16 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
container_name: goodwalk_svelte_mail_api
depends_on:
- db
environment:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
SECONDARY_CP_EMAIL: ${SECONDARY_CP_EMAIL:-}
SECONDARY_CP_EMAILS: ${SECONDARY_CP_EMAILS:-}
OWNER_BCC: ${OWNER_BCC:-}
CLIENT_BCC: ${CLIENT_BCC:-}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <info@goodwalk.co.nz>}
@@ -51,6 +53,9 @@ services:
RATE_LIMIT_MAX_PER_IP: ${RATE_LIMIT_MAX_PER_IP:-5}
RATE_LIMIT_MAX_PER_EMAIL: ${RATE_LIMIT_MAX_PER_EMAIL:-3}
RATE_LIMIT_MIN_INTERVAL_SECONDS: ${RATE_LIMIT_MIN_INTERVAL_SECONDS:-20}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-}
MAX_REQUEST_BODY_BYTES: ${MAX_REQUEST_BODY_BYTES:-}
PYTHONUNBUFFERED: '1'
TZ: ${TZ:-Pacific/Auckland}
expose:
@@ -58,6 +63,14 @@ services:
volumes:
- mail_api_data:${MAIL_API_DATA_DIR:-/app/data}
restart: unless-stopped
healthcheck:
# Hits /health via the container's own loopback so TrustedHostMiddleware
# (allowed_hosts) does not need to know about the bridge-network IP.
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status == 200 else 1)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- default
- webnet
+4 -4
View File
@@ -3,9 +3,9 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
environment:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: ${APP_PORT:-3000}
@@ -21,9 +21,9 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
environment:
APP_VERSION: ${APP_VERSION:-4.0.0}
APP_VERSION: ${APP_VERSION:-4.0.1}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
+93
View File
@@ -34,3 +34,96 @@
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)
19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 06:05:18 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
19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
19/05/2026 06:05:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 11:08:15 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' cp_admins=['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
19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
19/05/2026 11:08:15 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 11:28:27 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 11:28:27 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' cp_admins=['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 send_timeout=20s
19/05/2026 11:28:27 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
19/05/2026 11:28:28 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 11:28:34 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' cp_admins=['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 send_timeout=20s
19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
19/05/2026 11:28:34 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 11:36:54 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 11:36:54 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' cp_admins=['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 send_timeout=20s
19/05/2026 11:36:54 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
19/05/2026 11:36:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 11:37:03 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' cp_admins=['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 send_timeout=20s
19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
19/05/2026 11:37:03 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 11:53:00 New Zealand Standard Time INFO mail-api: [664ade9a] auth: code issued for email=info@goodwalk.co.nz
19/05/2026 11:53:00 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 200354
19/05/2026 11:53:00 New Zealand Standard Time INFO mail-api: [664ade9a] POST /auth/request-code → 200 (4ms)
19/05/2026 11:54:09 New Zealand Standard Time INFO mail-api: [89885efc] auth: code issued for email=info@goodwalk.co.nz
19/05/2026 11:54:09 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 672952
19/05/2026 11:54:09 New Zealand Standard Time INFO mail-api: [89885efc] POST /auth/request-code → 200 (1ms)
19/05/2026 11:54:17 New Zealand Standard Time INFO mail-api: [a8f63301] auth: session created for email=info@goodwalk.co.nz
19/05/2026 11:54:17 New Zealand Standard Time INFO mail-api: [a8f63301] POST /auth/verify-code → 200 (1ms)
19/05/2026 11:54:17 New Zealand Standard Time INFO mail-api: [a55d43ec] GET /auth/verify → 200 (1ms)
19/05/2026 11:54:22 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:54:22 New Zealand Standard Time INFO mail-api: [4f6216a6] POST /auth/save-draft → 200 (12ms)
19/05/2026 11:54:30 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:54:30 New Zealand Standard Time INFO mail-api: [8e9fc8f9] POST /auth/save-draft → 200 (2ms)
19/05/2026 11:54:40 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:54:40 New Zealand Standard Time INFO mail-api: [3664fac8] POST /auth/save-draft → 200 (2ms)
19/05/2026 11:54:54 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:54:54 New Zealand Standard Time INFO mail-api: [44f478bd] POST /auth/save-draft → 200 (2ms)
19/05/2026 11:55:30 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:55:30 New Zealand Standard Time INFO mail-api: [2f362178] POST /auth/save-draft → 200 (2ms)
19/05/2026 11:58:50 New Zealand Standard Time INFO mail-api: [f6201361] GET /auth/verify → 200 (1ms)
19/05/2026 11:59:10 New Zealand Standard Time INFO mail-api: [2009256f] GET /auth/verify → 200 (0ms)
19/05/2026 11:59:12 New Zealand Standard Time INFO mail-api: [586443ba] GET /auth/verify → 200 (0ms)
19/05/2026 11:59:23 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:59:23 New Zealand Standard Time INFO mail-api: [3d4c66fa] POST /auth/save-draft → 200 (2ms)
19/05/2026 11:59:35 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 11:59:35 New Zealand Standard Time INFO mail-api: [f3254bc3] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:00:04 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:00:04 New Zealand Standard Time INFO mail-api: [536cbb34] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:00:24 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:00:24 New Zealand Standard Time INFO mail-api: [9eeeab2a] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:00:46 New Zealand Standard Time INFO mail-api: [210b3442] GET /auth/verify → 200 (0ms)
19/05/2026 12:02:08 New Zealand Standard Time INFO mail-api: [8036af11] GET /auth/verify → 200 (0ms)
19/05/2026 12:02:10 New Zealand Standard Time INFO mail-api: [5d6362f3] GET /auth/verify → 200 (0ms)
19/05/2026 12:02:49 New Zealand Standard Time INFO mail-api: [f7be21d6] GET /auth/verify → 200 (1ms)
19/05/2026 12:03:05 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:03:05 New Zealand Standard Time INFO mail-api: [b94d0e50] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:03:20 New Zealand Standard Time INFO mail-api: [b7cdc5fc] GET /auth/verify → 200 (0ms)
19/05/2026 12:03:33 New Zealand Standard Time INFO mail-api: [e2f2cafc] GET /auth/verify → 200 (1ms)
19/05/2026 12:03:41 New Zealand Standard Time INFO mail-api: [9d174f3c] GET /auth/verify → 200 (0ms)
19/05/2026 12:03:49 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:03:49 New Zealand Standard Time INFO mail-api: [bf06d193] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:04:23 New Zealand Standard Time INFO mail-api: [0d971ecb] GET /auth/verify → 200 (0ms)
19/05/2026 12:04:29 New Zealand Standard Time INFO mail-api: [0ac2045c] GET /auth/verify → 200 (0ms)
19/05/2026 12:04:53 New Zealand Standard Time INFO mail-api: [f53afe82] GET /auth/verify → 200 (1ms)
19/05/2026 12:05:04 New Zealand Standard Time INFO mail-api: [a8af4e31] GET /auth/verify → 200 (1ms)
19/05/2026 12:05:09 New Zealand Standard Time INFO mail-api: [72b6b2a6] GET /auth/verify → 200 (0ms)
19/05/2026 12:05:21 New Zealand Standard Time INFO mail-api: [e2489ceb] GET /auth/verify → 200 (0ms)
19/05/2026 12:05:31 New Zealand Standard Time INFO mail-api: [68abdad1] GET /auth/verify → 200 (0ms)
19/05/2026 12:05:37 New Zealand Standard Time INFO mail-api: [b878ea5f] GET /auth/verify → 200 (0ms)
19/05/2026 12:05:48 New Zealand Standard Time INFO mail-api: [8dc7536f] GET /auth/verify → 200 (0ms)
19/05/2026 12:07:50 New Zealand Standard Time INFO mail-api: [1631f302] GET /auth/verify → 200 (0ms)
19/05/2026 12:08:05 New Zealand Standard Time INFO mail-api: [864b59cf] GET /auth/verify → 200 (0ms)
19/05/2026 12:09:31 New Zealand Standard Time INFO mail-api: [d7044384] GET /auth/verify → 200 (0ms)
19/05/2026 12:09:47 New Zealand Standard Time INFO mail-api: [5db32466] GET /auth/verify → 200 (0ms)
19/05/2026 12:09:51 New Zealand Standard Time INFO mail-api: [f4ba7f01] GET /auth/verify → 200 (0ms)
19/05/2026 12:11:04 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:11:04 New Zealand Standard Time INFO mail-api: [c08fe213] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:11:11 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:11:11 New Zealand Standard Time INFO mail-api: [54224bbc] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:11:18 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:11:18 New Zealand Standard Time INFO mail-api: [7b3c3b07] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:11:34 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:11:34 New Zealand Standard Time INFO mail-api: [78a70cba] POST /auth/save-draft → 200 (2ms)
19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: Draft saved: email=info@goodwalk.co.nz form=onboarding
19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [36fcde7c] POST /auth/save-draft → 200 (3ms)
19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [dcc57378] POST /auth/save-draft → 200 (4ms)
19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [0f97ff9b] POST /auth/logout → 200 (1ms)
19/05/2026 12:29:54 New Zealand Standard Time INFO mail-api: [ab3757e1] POST /auth/logout → 200 (0ms)
+13 -3
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.0
ARG APP_VERSION=4.0.1
FROM python:3.12-slim
ARG APP_VERSION
@@ -10,10 +10,20 @@ LABEL org.opencontainers.image.version="${APP_VERSION}"
COPY requirements.txt .
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& apt-get install -y --no-install-recommends \
tzdata \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libfribidi0 \
libcairo2 \
shared-mime-info \
fonts-dejavu-core \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY main.py db.py ./
COPY mail_api ./mail_api
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -3,12 +3,12 @@
"fullName": "Matt Test",
"phone": "02124347477",
"dogName": "Geoffrey",
"welcomePackSentAt": "2026-05-18T20:37:14",
"welcomePackSentAt": "2026-05-19T22:59:53",
"welcomePackOffer": {
"serviceType": "test",
"priceDetails": "45",
"startDate": "2026-05-18",
"sentAt": "2026-05-18T20:37:14"
"sentAt": "2026-05-19T22:59:53"
}
},
"natalie@desseinparke.com": {
+177
View File
@@ -79,11 +79,188 @@ async def _ensure_schema() -> None:
value jsonb not null,
updated_at timestamptz not null default now()
);
create table if not exists events (
id bigserial primary key,
created_at timestamptz not null default now(),
request_id text,
event_type text not null,
actor_email text,
ip text,
status text,
detail jsonb
);
create index if not exists events_created_at_idx on events (created_at desc);
create index if not exists events_event_type_idx on events (event_type);
create index if not exists events_actor_email_idx on events (actor_email);
create table if not exists submissions (
id bigserial primary key,
created_at timestamptz not null default now(),
request_id text,
kind text not null,
email text not null,
full_name text,
phone text,
ip text,
payload jsonb not null
);
create index if not exists submissions_created_at_idx on submissions (created_at desc);
create index if not exists submissions_email_idx on submissions (email);
create index if not exists submissions_kind_idx on submissions (kind);
"""
)
_schema_ensured = True
async def record_event(
*,
event_type: str,
request_id: str | None = None,
actor_email: str | None = None,
ip: str | None = None,
status: str | None = None,
detail: dict | None = None,
) -> None:
"""Append a single business event to the events table. Best-effort:
failures are logged and swallowed so they never block request handling."""
try:
pool = await get_pool()
if pool is None:
return
await _ensure_schema()
payload = json.dumps(detail or {})
async with pool.acquire() as conn:
await conn.execute(
"""
insert into events (request_id, event_type, actor_email, ip, status, detail)
values ($1, $2, $3, $4, $5, $6::jsonb)
""",
request_id, event_type, actor_email, ip, status, payload,
)
except Exception as exc:
logger.warning("record_event(%s) failed: %s", event_type, exc)
async def record_submission(
*,
kind: str,
email: str,
full_name: str | None,
phone: str | None,
ip: str | None,
request_id: str | None,
payload: dict,
) -> None:
"""Persist a contact-form submission (booking / onboarding / contract)."""
try:
pool = await get_pool()
if pool is None:
return
await _ensure_schema()
async with pool.acquire() as conn:
await conn.execute(
"""
insert into submissions (request_id, kind, email, full_name, phone, ip, payload)
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
""",
request_id, kind, email, full_name, phone, ip, json.dumps(payload),
)
except Exception as exc:
logger.warning("record_submission(%s) failed: %s", kind, exc)
async def list_events(
*,
limit: int = 100,
before_id: int | None = None,
event_type: str | None = None,
actor_email: str | None = None,
) -> list[dict]:
pool = await get_pool()
if pool is None:
return []
await _ensure_schema()
clauses: list[str] = []
params: list[Any] = []
if before_id is not None:
params.append(before_id)
clauses.append(f"id < ${len(params)}")
if event_type:
params.append(event_type)
clauses.append(f"event_type = ${len(params)}")
if actor_email:
params.append(actor_email.strip().lower())
clauses.append(f"actor_email = ${len(params)}")
where = ("where " + " and ".join(clauses)) if clauses else ""
params.append(max(1, min(500, limit)))
sql = (
f"select id, created_at, request_id, event_type, actor_email, ip, status, detail "
f"from events {where} order by id desc limit ${len(params)}"
)
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
return [
{
"id": r["id"],
"createdAt": r["created_at"].isoformat() if r["created_at"] else None,
"requestId": r["request_id"],
"eventType": r["event_type"],
"actorEmail": r["actor_email"],
"ip": r["ip"],
"status": r["status"],
"detail": (json.loads(r["detail"]) if isinstance(r["detail"], (str, bytes, bytearray)) else r["detail"]) or {},
}
for r in rows
]
async def list_submissions(
*,
limit: int = 100,
before_id: int | None = None,
kind: str | None = None,
email: str | None = None,
) -> list[dict]:
pool = await get_pool()
if pool is None:
return []
await _ensure_schema()
clauses: list[str] = []
params: list[Any] = []
if before_id is not None:
params.append(before_id)
clauses.append(f"id < ${len(params)}")
if kind:
params.append(kind)
clauses.append(f"kind = ${len(params)}")
if email:
params.append(email.strip().lower())
clauses.append(f"email = ${len(params)}")
where = ("where " + " and ".join(clauses)) if clauses else ""
params.append(max(1, min(500, limit)))
sql = (
f"select id, created_at, request_id, kind, email, full_name, phone, ip, payload "
f"from submissions {where} order by id desc limit ${len(params)}"
)
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
return [
{
"id": r["id"],
"createdAt": r["created_at"].isoformat() if r["created_at"] else None,
"requestId": r["request_id"],
"kind": r["kind"],
"email": r["email"],
"fullName": r["full_name"],
"phone": r["phone"],
"ip": r["ip"],
"payload": (json.loads(r["payload"]) if isinstance(r["payload"], (str, bytes, bytearray)) else r["payload"]) or {},
}
for r in rows
]
async def get_kv(key: str) -> Any | None:
pool = await get_pool()
if pool is None:
+59
View File
@@ -1377,3 +1377,62 @@ resend.exceptions.ResendError: API key is invalid
18/05/2026 22:22:05 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 22:22:05 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['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 send_timeout=20s
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 22:50:51 New Zealand Standard Time INFO mail-api: [26b6b10d] POST /auth/request-code → 400 (0ms)
19/05/2026 22:50:52 New Zealand Standard Time INFO mail-api: [e939d522] POST /auth/request-code → 400 (0ms)
19/05/2026 22:50:56 New Zealand Standard Time INFO mail-api: [6fe39f49] POST /auth/request-code → 400 (0ms)
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['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 send_timeout=20s
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 22:57:42 New Zealand Standard Time INFO mail-api: [0677fc9e] auth: code issued for email=info@goodwalk.co.nz
19/05/2026 22:57:42 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 237030
19/05/2026 22:57:42 New Zealand Standard Time INFO mail-api: [0677fc9e] POST /auth/request-code → 200 (3ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [19adbd31] auth: session created for email=info@goodwalk.co.nz
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [19adbd31] POST /auth/verify-code → 200 (2ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [40f46274] GET /auth/verify → 200 (1ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [83064e1b] GET /owner/pending-onboarding → 200 (1ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [b24a0321] GET /owner/all-clients → 200 (5ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [ad234ec3] GET /owner/birthdays → 200 (5ms)
19/05/2026 22:57:56 New Zealand Standard Time INFO mail-api: [8bbcb582] GET /owner/activity → 200 (1ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [11032d19] GET /auth/verify → 200 (0ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [6e3af292] GET /owner/pending-onboarding → 200 (1ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [ffa04e1b] GET /owner/birthdays → 200 (1ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [fc51922d] GET /owner/all-clients → 200 (1ms)
19/05/2026 22:57:58 New Zealand Standard Time INFO mail-api: [1219dba0] GET /owner/activity → 200 (0ms)
19/05/2026 22:57:59 New Zealand Standard Time INFO mail-api: [755e00f2] GET /owner/activity → 200 (0ms)
19/05/2026 22:57:59 New Zealand Standard Time INFO mail-api: [230cd479] GET /owner/activity → 200 (0ms)
19/05/2026 22:58:01 New Zealand Standard Time INFO mail-api: [b0c81ebf] GET /owner/message-templates → 200 (1ms)
19/05/2026 22:58:01 New Zealand Standard Time INFO mail-api: [da344121] POST /owner/render-message → 200 (1ms)
19/05/2026 22:58:04 New Zealand Standard Time INFO mail-api: [4ecf56ff] GET /owner/activity → 200 (0ms)
19/05/2026 22:58:30 New Zealand Standard Time INFO mail-api: [ed0b1740] GET /owner/client-enquiry → 200 (1ms)
19/05/2026 22:58:33 New Zealand Standard Time INFO mail-api: [d06c7a3b] GET /owner/client-enquiry → 200 (0ms)
19/05/2026 22:58:54 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=bulk_message to=['info@goodwalk.co.nz'] subject='A note from Goodwalk'
19/05/2026 22:58:54 New Zealand Standard Time INFO mail-api: [272f52a5] bulk message sent: template=general recipients=1
19/05/2026 22:58:54 New Zealand Standard Time INFO mail-api: [272f52a5] POST /owner/send-message → 200 (18ms)
19/05/2026 22:59:33 New Zealand Standard Time INFO mail-api: [f52b4acc] GET /owner/activity → 200 (0ms)
19/05/2026 22:59:53 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=welcome_pack_email to=['mattcohen0@gmail.com'] subject='Welcome to the pack | Goodwalk'
19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [a82128ad] welcome pack sent: email=mattcohen0@gmail.com service=test start=2026-05-18
19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [a82128ad] POST /owner/send-welcome-pack → 200 (13ms)
19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [9e00fe59] GET /owner/all-clients → 200 (0ms)
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['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 send_timeout=20s
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 23:32:02 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint')
19/05/2026 23:32:02 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['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 send_timeout=20s
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 23:32:33 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint')
19/05/2026 23:32:33 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['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 send_timeout=20s
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 23:32:40 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint')
19/05/2026 23:32:40 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
+1
View File
@@ -0,0 +1 @@
"""Mail API package — split out of the legacy single-file main.py."""
Binary file not shown.
Binary file not shown.
+257
View File
@@ -0,0 +1,257 @@
"""Runtime configuration for the mail API.
Loads environment variables once at import time, validates required values,
and exposes a frozen :class:`Settings` dataclass plus module-level constants
for compatibility with code that still imports the legacy names.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
import resend
# ── Logging ──────────────────────────────────────────────────────────────────
def setup_logging() -> logging.Logger:
log_dir = Path(os.environ.get("LOG_DIR", "logs"))
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "mail-api.log"
fmt = logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%d/%m/%Y %H:%M:%S %Z",
)
root = logging.getLogger()
root.setLevel(logging.DEBUG)
for handler in list(root.handlers):
root.removeHandler(handler)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
root.addHandler(console)
rotating = logging.handlers.RotatingFileHandler(
log_file, maxBytes=2_000_000, backupCount=5, encoding="utf-8"
)
rotating.setLevel(logging.DEBUG)
rotating.setFormatter(fmt)
root.addHandler(rotating)
log = logging.getLogger("mail-api")
log.info("Logging initialised → console=INFO, file=%s (DEBUG, rotating)", log_file)
return log
logger = setup_logging()
# ── Settings ─────────────────────────────────────────────────────────────────
DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"}
REQUIRED_ENV = {
"RESEND_API_KEY": "API key from https://resend.com/api-keys",
"OWNER_EMAIL": "Email address that receives new lead notifications",
}
def _parse_email_env_list(*values: str | None) -> list[str]:
emails: list[str] = []
seen: set[str] = set()
for raw in values:
if not raw:
continue
for part in re.split(r"[;,\s]+", raw.strip()):
normalized = part.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
emails.append(normalized)
return emails
@dataclass(frozen=True)
class Settings:
resend_api_key: str
owner_email: str
cp_admin_emails: tuple[str, ...]
from_email: str
reply_to: str
owner_bcc: str
client_bcc: str
enable_general_enquiries: bool
max_attempts: int
form_min_seconds: int
form_max_seconds: int
rate_limit_window_seconds: int
rate_limit_max_per_ip: int
rate_limit_max_per_email: int
rate_limit_min_interval_seconds: int
email_send_timeout_seconds: int
def _load_settings() -> Settings:
if DEV_MODE:
owner_email = os.environ.get("OWNER_EMAIL", "dev@localhost").strip().lower()
return Settings(
resend_api_key=os.environ.get("RESEND_API_KEY", "dev"),
owner_email=owner_email,
cp_admin_emails=tuple(_parse_email_env_list(
owner_email,
os.environ.get("SECONDARY_CP_EMAIL"),
os.environ.get("SECONDARY_CP_EMAILS"),
)),
from_email=os.environ.get("FROM_EMAIL", "GoodWalk <info@goodwalk.co.nz>"),
reply_to=os.environ.get("REPLY_TO", "info@goodwalk.co.nz"),
owner_bcc="",
client_bcc="",
enable_general_enquiries=False,
max_attempts=3,
form_min_seconds=1,
form_max_seconds=7200,
rate_limit_window_seconds=900,
rate_limit_max_per_ip=50,
rate_limit_max_per_email=50,
rate_limit_min_interval_seconds=1,
email_send_timeout_seconds=20,
)
missing = [(name, hint) for name, hint in REQUIRED_ENV.items() if not os.environ.get(name)]
if missing:
lines = [
"",
"Mail API cannot start — required environment variables are not set:",
"",
]
for name, hint in missing:
lines.append(f"{name} ({hint})")
lines += [
"",
"Set them in your shell and try again. For example, in PowerShell:",
"",
]
for name, _ in missing:
lines.append(f' $env:{name} = "..."')
lines.append("")
message = "\n".join(lines)
logger.critical("Startup aborted: missing env vars: %s", [n for n, _ in missing])
print(message, file=sys.stderr)
sys.exit(1)
owner_email = os.environ["OWNER_EMAIL"].strip().lower()
return Settings(
resend_api_key=os.environ["RESEND_API_KEY"],
owner_email=owner_email,
cp_admin_emails=tuple(_parse_email_env_list(
owner_email,
os.environ.get("SECONDARY_CP_EMAIL"),
os.environ.get("SECONDARY_CP_EMAILS"),
)),
from_email=os.environ.get("FROM_EMAIL", "GoodWalk <info@goodwalk.co.nz>"),
reply_to=os.environ.get("REPLY_TO", "info@goodwalk.co.nz"),
owner_bcc=os.environ.get("OWNER_BCC", "example@example.com").strip(),
client_bcc=os.environ.get("CLIENT_BCC", "").strip(),
enable_general_enquiries=os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower()
in {"1", "true", "yes", "on", "enabled"},
max_attempts=max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
form_min_seconds=max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))),
form_max_seconds=max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))),
rate_limit_window_seconds=max(60, int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "900"))),
rate_limit_max_per_ip=max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_IP", "5"))),
rate_limit_max_per_email=max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_EMAIL", "3"))),
rate_limit_min_interval_seconds=max(1, int(os.environ.get("RATE_LIMIT_MIN_INTERVAL_SECONDS", "20"))),
email_send_timeout_seconds=max(5, int(os.environ.get("EMAIL_SEND_TIMEOUT_SECONDS", "20"))),
)
settings = _load_settings()
resend.api_key = settings.resend_api_key
APP_VERSION = os.environ.get("APP_VERSION", "unknown")
AUTH_CODE_TTL_SECONDS = max(60, int(os.environ.get("AUTH_CODE_TTL_SECONDS", "600")))
AUTH_SESSION_TTL_SECONDS = max(3600, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(7 * 24 * 3600))))
AUTH_CODE_MAX_ATTEMPTS = 5
AUTH_CODE_REQUESTS_PER_HOUR = 5
AUTH_IP_MAX_FAILURES = max(3, int(os.environ.get("AUTH_IP_MAX_FAILURES", "10")))
AUTH_IP_FAILURE_WINDOW = max(60, int(os.environ.get("AUTH_IP_FAILURE_WINDOW", "600")))
AUTH_IP_BLOCK_DURATION = max(60, int(os.environ.get("AUTH_IP_BLOCK_DURATION", "3600")))
BIRTHDAY_CHECK_INTERVAL_SECONDS = max(3600, int(os.environ.get("BIRTHDAY_CHECK_INTERVAL_SECONDS", str(12 * 3600))))
def _split_csv_env(name: str, default: str) -> tuple[str, ...]:
# Treat empty-string env (e.g. compose `${VAR:-}`) the same as "unset" so
# an unset compose key falls back to the default, not to an empty allowlist.
raw = (os.environ.get(name) or "").strip() or default
parts = [p.strip() for p in raw.split(",")]
return tuple(p for p in parts if p)
CORS_ALLOWED_ORIGINS = _split_csv_env(
"CORS_ALLOWED_ORIGINS",
"https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz",
)
TRUSTED_HOSTS = _split_csv_env(
"TRUSTED_HOSTS",
"goodwalk.co.nz,www.goodwalk.co.nz,clients.goodwalk.co.nz,cp.goodwalk.co.nz,localhost,127.0.0.1",
)
# Hard cap on request body size. Signed contracts include a base64 PNG of the
# signature (~30 KB) plus form fields, so 2 MB is a generous ceiling.
MAX_REQUEST_BODY_BYTES = max(64 * 1024, int((os.environ.get("MAX_REQUEST_BODY_BYTES") or str(2 * 1024 * 1024)).strip() or str(2 * 1024 * 1024)))
_DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
ALLOWED_EMAILS_FILE = _DATA_DIR / "allowed_emails.json"
CLIENT_PROFILES_FILE = _DATA_DIR / "client_profiles.json"
DRAFTS_FILE = _DATA_DIR / "drafts.json"
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
# ── Legacy module constants (kept for compatibility with the existing main.py) ──
OWNER_EMAIL = settings.owner_email
CP_ADMIN_EMAILS = set(settings.cp_admin_emails)
OWNER_BCC = settings.owner_bcc
CLIENT_BCC = settings.client_bcc
FROM_EMAIL = settings.from_email
REPLY_TO = settings.reply_to
ENABLE_GENERAL_ENQUIRIES = settings.enable_general_enquiries
MAX_SEND_ATTEMPTS = settings.max_attempts
FORM_MIN_SECONDS = settings.form_min_seconds
FORM_MAX_SECONDS = settings.form_max_seconds
RATE_LIMIT_WINDOW_SECONDS = settings.rate_limit_window_seconds
RATE_LIMIT_MAX_PER_IP = settings.rate_limit_max_per_ip
RATE_LIMIT_MAX_PER_EMAIL = settings.rate_limit_max_per_email
RATE_LIMIT_MIN_INTERVAL_SECONDS = settings.rate_limit_min_interval_seconds
EMAIL_SEND_TIMEOUT_SECONDS = settings.email_send_timeout_seconds
# Owner-BCC placeholder used by deploy.env.template; treat it as "unset" for the smoke email.
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else ""
logger.info(
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r cp_admins=%r owner_bcc=%r client_bcc=%r general_enquiries=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss send_timeout=%ss",
APP_VERSION,
os.environ.get("TZ", "system-default"),
FROM_EMAIL,
REPLY_TO,
OWNER_EMAIL,
sorted(CP_ADMIN_EMAILS),
OWNER_BCC,
CLIENT_BCC,
ENABLE_GENERAL_ENQUIRIES,
MAX_SEND_ATTEMPTS,
FORM_MIN_SECONDS,
FORM_MAX_SECONDS,
RATE_LIMIT_WINDOW_SECONDS,
RATE_LIMIT_MAX_PER_IP,
RATE_LIMIT_MAX_PER_EMAIL,
RATE_LIMIT_MIN_INTERVAL_SECONDS,
EMAIL_SEND_TIMEOUT_SECONDS,
)
+117
View File
@@ -0,0 +1,117 @@
"""Pydantic request/response models for the mail API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr
class BaseSubmission(BaseModel):
fullName: str
email: EmailStr
phone: str
website: str = ""
formStartedAt: int | None = None
visitStartedAt: int | None = None
pageEnteredAt: int | None = None
firstInteractionAt: int | None = None
sendClickedAt: int | None = None
referrer: str = ""
page: str = ""
class BookingSubmission(BaseSubmission):
enquiryType: str = "booking"
petName: str = ""
location: str = ""
message: str = ""
services: list[str] = []
stepChanges: int = 0
journey: list[str] = []
class OnboardingSubmission(BaseSubmission):
address: str
dogName: str
dogBreed: str
dogAge: str = ""
servicesNeeded: list[str] = []
temperament: str = ""
medicalNotes: str = ""
accessInstructions: str = ""
vetName: str
vetPhone: str
emergencyContactName: str
emergencyContactPhone: str
councilRegistrationConfirmed: bool = False
vaccinationsConfirmed: bool = False
emergencyVetConsent: bool = False
termsAccepted: bool = False
signatureDataUrl: str
submissionSnapshot: dict[str, Any] = {}
class WelcomePackEmailRequest(BaseModel):
email: EmailStr
serviceType: str
priceDetails: str
startDate: str
preview: bool = False
class BirthdayEmailRequest(BaseModel):
email: EmailStr
preview: bool = False
class BirthdayAutoSendRequest(BaseModel):
email: EmailStr
enabled: bool
class ContractSubmission(BaseSubmission):
address: str
dogName: str
dogBreed: str
dogAge: str = ""
serviceType: str
startDate: str
walkFrequency: str = ""
additionalNotes: str = ""
agreeServiceTerms: bool = False
agreeCancellation: bool = False
agreePayment: bool = False
agreeEmergency: bool = False
agreeLiability: bool = False
agreeAccuracy: bool = False
signatureDataUrl: str
class RenderMessageRequest(BaseModel):
templateId: str
heading: str = ""
body: str = ""
ctaLabel: str = ""
ctaUrl: str = ""
subHeading: str = ""
highlightText: str = ""
signOff: str = ""
footerNote: str = ""
fontId: str = "system"
class SendMessageRequest(BaseModel):
templateId: str
subject: str
heading: str = ""
body: str = ""
ctaLabel: str = ""
ctaUrl: str = ""
subHeading: str = ""
highlightText: str = ""
signOff: str = ""
footerNote: str = ""
fontId: str = "system"
recipients: list[EmailStr] = []
preview: bool = False
+550 -309
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.32
resend>=2.0
pydantic[email]>=2.10
asyncpg>=0.30
weasyprint>=63
+53 -7
View File
@@ -16,7 +16,7 @@ server {
server {
listen 80;
server_name onboarding.goodwalk.co.nz;
server_name clients.goodwalk.co.nz onboarding.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -24,13 +24,13 @@ server {
}
location / {
return 301 https://onboarding.goodwalk.co.nz$request_uri;
return 301 https://clients.goodwalk.co.nz$request_uri;
}
}
server {
listen 80;
server_name admin.goodwalk.co.nz;
server_name cp.goodwalk.co.nz admin.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -38,7 +38,7 @@ server {
}
location / {
return 301 https://admin.goodwalk.co.nz$request_uri;
return 301 https://cp.goodwalk.co.nz$request_uri;
}
}
@@ -70,6 +70,8 @@ server {
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
client_max_body_size 2m;
gzip on;
gzip_vary on;
@@ -113,6 +115,16 @@ server {
return 404;
}
location = /api/health {
access_log off;
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
proxy_pass http://$goodwalk_mail_api/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
location /api/submit {
if (-f /etc/nginx/conf.d/maintenance.flag) {
return 503;
@@ -152,6 +164,16 @@ server {
ssl_certificate /etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem;
return 301 https://clients.goodwalk.co.nz$request_uri;
}
server {
listen 443 ssl;
server_name clients.goodwalk.co.nz;
ssl_certificate /etc/letsencrypt/live/clients.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/clients.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;
@@ -163,6 +185,8 @@ server {
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
client_max_body_size 2m;
gzip on;
gzip_vary on;
@@ -176,6 +200,16 @@ server {
deny all;
}
location = /api/health {
access_log off;
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
proxy_pass http://$goodwalk_mail_api/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
location /api/onboarding-submit {
if (-f /etc/nginx/conf.d/maintenance.flag) {
return 503;
@@ -222,10 +256,10 @@ server {
server {
listen 443 ssl;
server_name admin.goodwalk.co.nz;
server_name cp.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_certificate /etc/letsencrypt/live/cp.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cp.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;
@@ -238,7 +272,9 @@ server {
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header X-Robots-Tag "noindex, nofollow" always;
client_max_body_size 2m;
gzip on;
gzip_vary on;
@@ -252,6 +288,16 @@ server {
deny all;
}
location = /api/health {
access_log off;
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
proxy_pass http://$goodwalk_mail_api/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
# Auth endpoints proxied to mail-api (verify / login / logout).
location /api/auth/ {
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
+99 -2
View File
@@ -14,14 +14,27 @@ server {
server {
listen 80;
server_name onboarding.goodwalk.co.nz;
server_name clients.goodwalk.co.nz onboarding.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
return 301 https://clients.goodwalk.co.nz$request_uri;
}
}
server {
listen 80;
server_name cp.goodwalk.co.nz admin.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://cp.goodwalk.co.nz$request_uri;
}
}
@@ -72,6 +85,18 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
return 301 https://clients.goodwalk.co.nz$request_uri;
}
server {
listen 443 ssl;
server_name clients.goodwalk.co.nz;
ssl_certificate /etc/letsencrypt/live/clients.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/clients.goodwalk.co.nz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
@@ -89,6 +114,78 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/auth/ {
rewrite ^/api/auth/(.*)$ /auth/$1 break;
proxy_pass http://mail-api:8000;
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 / {
proxy_pass http://app:3000;
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";
}
}
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 HIGH:!aNULL:!MD5;
return 301 https://cp.goodwalk.co.nz$request_uri;
}
server {
listen 443 ssl;
server_name cp.goodwalk.co.nz;
ssl_certificate /etc/letsencrypt/live/cp.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cp.goodwalk.co.nz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
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_types text/plain text/css application/javascript application/json image/svg+xml;
location /api/auth/ {
rewrite ^/api/auth/(.*)$ /auth/$1 break;
proxy_pass http://mail-api:8000;
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 /api/owner/ {
rewrite ^/api/owner/(.*)$ /owner/$1 break;
proxy_pass http://mail-api:8000;
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 / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gw-svelte",
"version": "4.0.0",
"version": "4.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gw-svelte",
"version": "4.0.0",
"version": "4.0.1",
"dependencies": {
"canvas-confetti": "^1.9.4",
"pg": "^8.13.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gw-svelte",
"version": "4.0.0",
"version": "4.0.1",
"private": true,
"type": "module",
"scripts": {
+151 -16
View File
@@ -43,6 +43,108 @@ fail() {
exit 1
}
get_mount_source_for_destination() {
local container_id="$1"
local destination="$2"
docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$container_id" \
| awk -F'|' -v wanted="$destination" '$2 == wanted { print $1; exit }'
}
write_acme_bootstrap_config() {
local source_config="$1"
local target_config="$2"
shift 2
awk -v skip_domains="$*" '
BEGIN {
split(skip_domains, items, " ")
for (i in items) {
if (items[i] != "") skip[items[i]] = 1
}
in_server = 0
depth = 0
block = ""
is_ssl = 0
has_skipped_domain = 0
}
function emit_block() {
if (!(is_ssl && has_skipped_domain)) {
printf "%s", block
}
in_server = 0
depth = 0
block = ""
is_ssl = 0
has_skipped_domain = 0
}
{
line = $0 ORS
if (!in_server) {
if ($0 ~ /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/) {
in_server = 1
depth = 1
block = line
next
}
printf "%s", line
next
}
block = block line
if ($0 ~ /^[[:space:]]*listen[[:space:]]+443[[:space:]]+ssl([[:space:]]|;)/) {
is_ssl = 1
}
if ($0 ~ /^[[:space:]]*server_name[[:space:]]+/) {
count = split($0, parts, /[[:space:];]+/)
for (i = 2; i <= count; i++) {
if (parts[i] in skip) {
has_skipped_domain = 1
}
}
}
opens = gsub(/\{/, "{", $0)
closes = gsub(/\}/, "}", $0)
depth += opens - closes
if (depth == 0) {
emit_block()
}
}
END {
if (in_server) {
emit_block()
}
}
' "$source_config" > "$target_config"
}
obtain_certificate() {
local domain="$1"
local cert_root="$2"
local acme_webroot="$3"
local certbot_email="info@goodwalk.co.nz"
echo "[deploy-git] Obtaining TLS certificate for $domain"
docker run --rm \
-v "$cert_root:/etc/letsencrypt" \
-v "$acme_webroot:/var/www/certbot" \
certbot/certbot:latest \
certonly \
--webroot \
-w /var/www/certbot \
--cert-name "$domain" \
-d "$domain" \
--non-interactive \
--agree-tos \
-m "$certbot_email"
}
assert_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server"
}
@@ -429,22 +531,6 @@ if (( nginx_args_present )); then
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
# Pre-flight: SSL certificate for onboarding.goodwalk.co.nz must exist before
# nginx can reload — the config references this cert path directly.
ONBOARDING_CERT="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem"
ONBOARDING_KEY="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem"
if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then
fail "SSL certificate for onboarding.goodwalk.co.nz is not present on this server.
Expected: $ONBOARDING_CERT
One-time setup on the droplet:
1. Ensure the DNS A record for onboarding.goodwalk.co.nz points to this server's IP
2. Bring nginx up so the ACME webroot is reachable, then obtain the certificate:
certbot certonly --webroot -w /var/www/certbot \\
-d onboarding.goodwalk.co.nz \\
--non-interactive --agree-tos -m info@goodwalk.co.nz
3. Re-run this deploy script"
fi
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
@@ -453,6 +539,55 @@ if (( nginx_args_present )); then
NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)"
[[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying."
CERT_ROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/etc/letsencrypt")"
[[ -n "$CERT_ROOT_HOST_DIR" ]] || fail "nginx container is missing the certificate bind mount for /etc/letsencrypt."
CERTBOT_WEBROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/var/www/certbot")"
[[ -n "$CERTBOT_WEBROOT_HOST_DIR" ]] || fail "nginx container is missing the ACME webroot bind mount for /var/www/certbot."
ONBOARDING_CERT="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/fullchain.pem"
ONBOARDING_KEY="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/privkey.pem"
CLIENTS_CERT="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/fullchain.pem"
CLIENTS_KEY="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/privkey.pem"
CP_CERT="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/fullchain.pem"
CP_KEY="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/privkey.pem"
MISSING_CERT_DOMAINS=()
if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then
MISSING_CERT_DOMAINS+=("onboarding.goodwalk.co.nz")
fi
if [[ ! -f "$CLIENTS_CERT" || ! -f "$CLIENTS_KEY" ]]; then
MISSING_CERT_DOMAINS+=("clients.goodwalk.co.nz")
fi
if [[ ! -f "$CP_CERT" || ! -f "$CP_KEY" ]]; then
MISSING_CERT_DOMAINS+=("cp.goodwalk.co.nz")
fi
if (( ${#MISSING_CERT_DOMAINS[@]} > 0 )); then
echo "[deploy-git] Missing TLS certificates detected: ${MISSING_CERT_DOMAINS[*]}"
echo "[deploy-git] Bootstrapping nginx HTTP config so ACME challenges can be served"
mkdir -p "$(dirname "$NGINX_TARGET")"
BOOTSTRAP_CONFIG="$(mktemp "${TMPDIR:-/tmp}/goodwalk-nginx-acme.XXXXXX.conf")"
write_acme_bootstrap_config "$DEPLOY_PATH/$NGINX_SOURCE" "$BOOTSTRAP_CONFIG" "${MISSING_CERT_DOMAINS[@]}"
cp "$BOOTSTRAP_CONFIG" "$NGINX_TARGET"
rm -f "$BOOTSTRAP_CONFIG"
echo "[deploy-git] Validating bootstrap nginx configuration"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
echo "[deploy-git] Reloading shared nginx with bootstrap config"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
for domain in "${MISSING_CERT_DOMAINS[@]}"; do
echo "[deploy-git] Ensure the DNS A record for $domain points to this server before certificate issuance"
obtain_certificate "$domain" "$CERT_ROOT_HOST_DIR" "$CERTBOT_WEBROOT_HOST_DIR" \
|| fail "Automatic certificate issuance failed for $domain. Confirm DNS resolves here and port 80 is reachable."
done
[[ -f "$ONBOARDING_CERT" && -f "$ONBOARDING_KEY" ]] || fail "Automatic certificate issuance did not create onboarding.goodwalk.co.nz at $ONBOARDING_CERT"
[[ -f "$CLIENTS_CERT" && -f "$CLIENTS_KEY" ]] || fail "Automatic certificate issuance did not create clients.goodwalk.co.nz at $CLIENTS_CERT"
[[ -f "$CP_CERT" && -f "$CP_KEY" ]] || fail "Automatic certificate issuance did not create cp.goodwalk.co.nz at $CP_CERT"
fi
if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \
| grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then
fail "nginx container is missing the maintenance bind mount.
+151 -16
View File
@@ -35,6 +35,108 @@ fail() {
exit 1
}
get_mount_source_for_destination() {
local container_id="$1"
local destination="$2"
docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$container_id" \
| awk -F'|' -v wanted="$destination" '$2 == wanted { print $1; exit }'
}
write_acme_bootstrap_config() {
local source_config="$1"
local target_config="$2"
shift 2
awk -v skip_domains="$*" '
BEGIN {
split(skip_domains, items, " ")
for (i in items) {
if (items[i] != "") skip[items[i]] = 1
}
in_server = 0
depth = 0
block = ""
is_ssl = 0
has_skipped_domain = 0
}
function emit_block() {
if (!(is_ssl && has_skipped_domain)) {
printf "%s", block
}
in_server = 0
depth = 0
block = ""
is_ssl = 0
has_skipped_domain = 0
}
{
line = $0 ORS
if (!in_server) {
if ($0 ~ /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/) {
in_server = 1
depth = 1
block = line
next
}
printf "%s", line
next
}
block = block line
if ($0 ~ /^[[:space:]]*listen[[:space:]]+443[[:space:]]+ssl([[:space:]]|;)/) {
is_ssl = 1
}
if ($0 ~ /^[[:space:]]*server_name[[:space:]]+/) {
count = split($0, parts, /[[:space:];]+/)
for (i = 2; i <= count; i++) {
if (parts[i] in skip) {
has_skipped_domain = 1
}
}
}
opens = gsub(/\{/, "{", $0)
closes = gsub(/\}/, "}", $0)
depth += opens - closes
if (depth == 0) {
emit_block()
}
}
END {
if (in_server) {
emit_block()
}
}
' "$source_config" > "$target_config"
}
obtain_certificate() {
local domain="$1"
local cert_root="$2"
local acme_webroot="$3"
local certbot_email="info@goodwalk.co.nz"
echo "[deploy-remote] Obtaining TLS certificate for $domain"
docker run --rm \
-v "$cert_root:/etc/letsencrypt" \
-v "$acme_webroot:/var/www/certbot" \
certbot/certbot:latest \
certonly \
--webroot \
-w /var/www/certbot \
--cert-name "$domain" \
-d "$domain" \
--non-interactive \
--agree-tos \
-m "$certbot_email"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--archive)
@@ -292,22 +394,6 @@ if (( nginx_args_present )); then
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
# Pre-flight: SSL certificate for onboarding.goodwalk.co.nz must exist before
# nginx can reload — the config references this cert path directly.
ONBOARDING_CERT="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem"
ONBOARDING_KEY="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem"
if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then
fail "SSL certificate for onboarding.goodwalk.co.nz is not present on this server.
Expected: $ONBOARDING_CERT
One-time setup on the droplet:
1. Ensure the DNS A record for onboarding.goodwalk.co.nz points to this server's IP
2. Bring nginx up so the ACME webroot is reachable, then obtain the certificate:
certbot certonly --webroot -w /var/www/certbot \\
-d onboarding.goodwalk.co.nz \\
--non-interactive --agree-tos -m info@goodwalk.co.nz
3. Re-run this deploy script"
fi
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
@@ -320,6 +406,55 @@ if (( nginx_args_present )); then
NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)"
[[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying."
CERT_ROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/etc/letsencrypt")"
[[ -n "$CERT_ROOT_HOST_DIR" ]] || fail "nginx container is missing the certificate bind mount for /etc/letsencrypt."
CERTBOT_WEBROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/var/www/certbot")"
[[ -n "$CERTBOT_WEBROOT_HOST_DIR" ]] || fail "nginx container is missing the ACME webroot bind mount for /var/www/certbot."
ONBOARDING_CERT="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/fullchain.pem"
ONBOARDING_KEY="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/privkey.pem"
CLIENTS_CERT="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/fullchain.pem"
CLIENTS_KEY="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/privkey.pem"
CP_CERT="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/fullchain.pem"
CP_KEY="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/privkey.pem"
MISSING_CERT_DOMAINS=()
if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then
MISSING_CERT_DOMAINS+=("onboarding.goodwalk.co.nz")
fi
if [[ ! -f "$CLIENTS_CERT" || ! -f "$CLIENTS_KEY" ]]; then
MISSING_CERT_DOMAINS+=("clients.goodwalk.co.nz")
fi
if [[ ! -f "$CP_CERT" || ! -f "$CP_KEY" ]]; then
MISSING_CERT_DOMAINS+=("cp.goodwalk.co.nz")
fi
if (( ${#MISSING_CERT_DOMAINS[@]} > 0 )); then
echo "[deploy-remote] Missing TLS certificates detected: ${MISSING_CERT_DOMAINS[*]}"
echo "[deploy-remote] Bootstrapping nginx HTTP config so ACME challenges can be served"
mkdir -p "$(dirname "$NGINX_TARGET")"
BOOTSTRAP_CONFIG="$(mktemp "${TMPDIR:-/tmp}/goodwalk-nginx-acme.XXXXXX.conf")"
write_acme_bootstrap_config "$DEPLOY_PATH/$NGINX_SOURCE" "$BOOTSTRAP_CONFIG" "${MISSING_CERT_DOMAINS[@]}"
cp "$BOOTSTRAP_CONFIG" "$NGINX_TARGET"
rm -f "$BOOTSTRAP_CONFIG"
echo "[deploy-remote] Validating bootstrap nginx configuration"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
echo "[deploy-remote] Reloading shared nginx with bootstrap config"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
for domain in "${MISSING_CERT_DOMAINS[@]}"; do
echo "[deploy-remote] Ensure the DNS A record for $domain points to this server before certificate issuance"
obtain_certificate "$domain" "$CERT_ROOT_HOST_DIR" "$CERTBOT_WEBROOT_HOST_DIR" \
|| fail "Automatic certificate issuance failed for $domain. Confirm DNS resolves here and port 80 is reachable."
done
[[ -f "$ONBOARDING_CERT" && -f "$ONBOARDING_KEY" ]] || fail "Automatic certificate issuance did not create onboarding.goodwalk.co.nz at $ONBOARDING_CERT"
[[ -f "$CLIENTS_CERT" && -f "$CLIENTS_KEY" ]] || fail "Automatic certificate issuance did not create clients.goodwalk.co.nz at $CLIENTS_CERT"
[[ -f "$CP_CERT" && -f "$CP_KEY" ]] || fail "Automatic certificate issuance did not create cp.goodwalk.co.nz at $CP_CERT"
fi
if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \
| grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then
fail "nginx container is missing the maintenance bind mount.
+10 -15
View File
@@ -1,31 +1,26 @@
import type { Handle } from '@sveltejs/kit';
import { resolveSurface } from '$lib/server/surface';
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 { surface } = resolveSurface(event.url, event.cookies);
const path = event.url.pathname;
// The admin host serves the dashboard at its root.
if (onAdminHost && (path === '/' || path === '')) {
// The admin host (cp.*) serves the dashboard at its root.
if (surface === 'cp' && (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/')) {
// Block the admin dashboard from the public marketing site so /owner/*
// only renders on the cp surface (or on the clients surface during the
// legacy onboarding-host transition window). Localhost dev preview is
// allowed: resolveSurface returns 'cp' there too when ?preview=cp or
// ?preview=admin is set.
if (surface === 'marketing' && path.startsWith('/owner/')) {
return new Response('Not Found', { status: 404 });
}
+15 -38
View File
@@ -2,8 +2,8 @@
import { accordion } from '$lib/actions/accordion';
import { reveal } from '$lib/actions/reveal';
import CtaCard from '$lib/components/CtaCard.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Icon from '$lib/components/Icon.svelte';
import ServiceHero from '$lib/components/ServiceHero.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { AboutPageContent } from '$lib/types';
@@ -11,33 +11,25 @@
$: standardSections = pageContent.sections.filter((s) => s.accent !== 'founder');
$: founderSection = pageContent.sections.find((s) => s.accent === 'founder') ?? null;
const heroChips = [
{ icon: 'fas fa-star', label: '30+ five-star Google reviews' },
{ icon: 'fas fa-location-dot', label: 'Auckland Central' },
{ icon: 'fas fa-paw', label: 'Small dog specialists' }
];
const founderHeadingLead = 'Meet Aless,';
const founderHeadingHighlight = 'the heart of Goodwalk';
</script>
<main class="about-page">
<!-- ── Hero ── -->
<PageHeader
variant="green"
<ServiceHero
eyebrow="About Goodwalk"
title={pageContent.title}
subtitle="Small dog specialists serving Auckland Central. A team your dog knows by name."
>
<div class="ph-chips">
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="ph-chip ph-chip--link"
>
<span class="about-chip-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
<span class="ph-chip">Auckland Central</span>
<span class="ph-chip">Small dog specialists</span>
</div>
</PageHeader>
imageUrl="/images/about-good-walk.webp"
imageAlt="Goodwalk dogs gathered together in the back of the car before a walk"
chips={heroChips}
cta={{ label: 'Book a Meet & Greet', href: '/contact-us', variant: 'yellow' }}
/>
<!-- ── Standard sections (Who we are, Our impact) ── -->
{#each standardSections as section}
@@ -50,7 +42,7 @@
<div class="page-inner about-section-grid" class:about-section-reverse={section.reverse}>
<div class="about-copy">
{#if section.eyebrow}
<span class="about-eyebrow">{section.eyebrow}</span>
<span class="eyebrow about-eyebrow">{section.eyebrow}</span>
{/if}
<h2>{section.title}</h2>
{#each section.body as paragraph}
@@ -93,7 +85,7 @@
<div class="about-founder-copy">
<article class="about-founder-note">
{#if founderSection.eyebrow}
<span class="about-eyebrow about-founder-kicker">{founderSection.eyebrow}</span>
<span class="eyebrow about-eyebrow about-founder-kicker">{founderSection.eyebrow}</span>
{/if}
<h2 class="about-founder-heading">
<span class="about-founder-heading-desktop">
@@ -144,7 +136,7 @@
<section use:reveal={{ delay: 30 }} class="about-faq reveal-block">
<div class="page-inner">
<div class="about-faq-header">
<span class="about-eyebrow">FAQ</span>
<span class="eyebrow about-eyebrow">FAQ</span>
<h2>{pageContent.faqTitle ?? 'Common questions'}</h2>
</div>
<div use:accordion class="faq about-faq-list">
@@ -187,21 +179,6 @@
.about-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.about-chip-stars {
color: var(--yellow);
letter-spacing: 1px;
font-size: 13px;
}
/* ── Standard sections ── */
+26 -12
View File
@@ -2,7 +2,7 @@
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';
import ServiceHero from '$lib/components/ServiceHero.svelte';
import type { BookingContent, InfoContent } from '$lib/types';
export let booking: BookingContent;
@@ -18,12 +18,22 @@
</script>
<main class="booking-page">
<PageHeader
variant="green"
<ServiceHero
eyebrow="Contact Goodwalk"
title="Contact Us"
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">
imageUrl="/images/happy-customer-anna.webp"
imageAlt="Happy Goodwalk customer Anna with her dog in Auckland"
chips={[
{ icon: 'fas fa-bolt', label: 'Reply within 24 hours' },
{ icon: 'fas fa-handshake', label: 'Free Meet & Greet' },
{ icon: 'fas fa-location-dot', label: 'Auckland Central' }
]}
cta={{ label: 'Start your enquiry', href: '#newlead', variant: 'yellow' }}
/>
<div class="booking-page-contact-strip">
<div class="page-inner booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link">
<Icon name="fas fa-envelope" />
{email}
@@ -33,7 +43,7 @@
{phone}
</a>
</div>
</PageHeader>
</div>
<BookingWizard {booking} pagePath="/contact-us" />
<InfoSection {info} />
@@ -44,13 +54,16 @@
background: var(--off-white);
}
.booking-page-contact-strip {
padding: 18px 0 0;
}
.booking-page-contact {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
margin-top: 28px;
}
.booking-contact-link {
@@ -59,19 +72,20 @@
gap: 8px;
padding: 10px 20px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(17, 20, 24, 0.08);
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
color: #fff;
transition: background 0.2s;
color: var(--text-heading);
transition: background 0.2s, transform 0.2s;
}
.booking-contact-link:hover {
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
@media (max-width: 768px) {
+256 -20
View File
@@ -10,6 +10,7 @@
export let booking: BookingContent;
export let pagePath = '';
$: isCompactContactPage = pagePath === '/contact-us';
const defaultServices = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services'];
$: serviceOptions = booking.serviceOptions && booking.serviceOptions.length > 0
@@ -204,6 +205,22 @@
return Object.keys(next).length === 0;
}
function validateCompactContactForm(): boolean {
const next: Record<string, string> = {};
if (!petName.trim()) next.petName = "Please tell us your dog's name.";
if (selectedServices.length === 0) next.services = 'Pick at least one service.';
if (message.trim().length < 10) {
next.message = 'Tell us a little about your dog so we can prepare properly.';
}
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailErr = validateEmail(email);
if (emailErr) next.email = emailErr;
if (!phone.trim()) next.phone = 'Please enter your phone number';
if (!location.trim()) next.location = 'Please enter your suburb';
errors = next;
return Object.keys(next).length === 0;
}
function goNext() {
noteInteraction();
if (!validateStep(step)) return;
@@ -222,7 +239,9 @@
async function handleSubmit() {
noteInteraction();
if (!validateStep(2)) return;
if (isCompactContactPage) {
if (!validateCompactContactForm()) return;
} else if (!validateStep(2)) return;
submitting = true;
sendClickedAt = Date.now();
@@ -252,7 +271,7 @@
journey,
referrer: typeof document !== 'undefined' ? document.referrer : '',
page: typeof window !== 'undefined' ? window.location.href : '',
variant: 'booking-wizard'
variant: isCompactContactPage ? 'contact-compact' : 'booking-wizard'
})
});
@@ -276,7 +295,7 @@
</script>
<section id="newlead" use:reveal={{ delay: 70 }} class="wiz reveal-block">
<div class="wiz-inner">
<div class="wiz-inner" class:wiz-inner--compact={isCompactContactPage}>
{#if submitted && SuccessModalComponent}
<svelte:component
this={SuccessModalComponent}
@@ -298,6 +317,7 @@
/>
{/if}
{#if !isCompactContactPage}
<div class="wiz-header">
<span class="wiz-eyebrow">Free Meet &amp; Greet</span>
<h2 class="wiz-title">
@@ -335,6 +355,7 @@
<span class="wiz-step-label">{booking.ownerStepLabel || 'Your details'}</span>
</span>
</div>
{/if}
<form
class="wiz-form"
@@ -355,10 +376,189 @@
/>
</div>
<article class="wiz-card">
<article class="wiz-card" class:wiz-card--compact={isCompactContactPage}>
{#key step}
<div class="wiz-step" in:fade={{ duration: 200 }}>
{#if step === 1}
<div class="wiz-step" class:wiz-step--compact={isCompactContactPage} in:fade={{ duration: 200 }}>
{#if isCompactContactPage}
<span class="wiz-step-eyebrow">Start here</span>
<h3 class="wiz-step-heading">Tell us about your dog</h3>
<p class="wiz-step-helper">A few details now. We come back with the right next step.</p>
<label class="wiz-field">
<span class="wiz-label">
<Icon name="fas fa-dog" />&nbsp;Your dog's name
</span>
<input
bind:value={petName}
on:input={() => clearError('petName')}
type="text"
placeholder="For example, Teddy"
class:invalid={errors.petName}
autocomplete="off"
/>
{#if errors.petName}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</span>
{/if}
</label>
<fieldset class="wiz-fieldset">
<legend class="wiz-label">
<Icon name="fas fa-paw" />&nbsp;Which service are you interested in?
</legend>
<div
class="wiz-service-grid"
class:wiz-service-grid--compact={isCompactContactPage}
class:invalid={errors.services}
role="group"
aria-label="Service interest"
>
{#each serviceOptions as service}
{@const checked = selectedServices.includes(service)}
<button
type="button"
class="wiz-service"
class:wiz-service--compact={isCompactContactPage}
class:active={checked}
aria-pressed={checked}
on:click={() => toggleService(service)}
>
<span class="wiz-service-check" aria-hidden="true">
{#if checked}<Icon name="fas fa-check" />{/if}
</span>
<span class="wiz-service-text">
<span class="wiz-service-label">{service}</span>
{#if serviceDescriptions[service]}
<span class="wiz-service-desc">{serviceDescriptions[service]}</span>
{/if}
</span>
</button>
{/each}
</div>
{#if errors.services}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.services}
</span>
{/if}
</fieldset>
<div class="wiz-grid-two">
<label class="wiz-field">
<span class="wiz-label">
<Icon name="fas fa-user" />&nbsp;Full name
</span>
<input
bind:value={fullName}
on:input={() => clearError('fullName')}
type="text"
placeholder="Your full name"
class:invalid={errors.fullName}
autocomplete="name"
/>
{#if errors.fullName}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.fullName}
</span>
{/if}
</label>
<label class="wiz-field">
<span class="wiz-label">
<Icon name="fas fa-envelope" />&nbsp;Email
</span>
<input
bind:value={email}
on:input={() => clearError('email')}
type="email"
placeholder="you@example.com"
class:invalid={errors.email}
autocomplete="email"
inputmode="email"
/>
{#if errors.email}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.email}
</span>
{/if}
</label>
<label class="wiz-field">
<span class="wiz-label">
<Icon name="fas fa-phone" />&nbsp;Phone
</span>
<input
bind:value={phone}
on:input={() => clearError('phone')}
type="tel"
placeholder="(021) 234 5678"
class:invalid={errors.phone}
autocomplete="tel"
inputmode="tel"
/>
{#if errors.phone}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.phone}
</span>
{/if}
</label>
<label class="wiz-field">
<span class="wiz-label">
<Icon name="fas fa-location-dot" />&nbsp;Your suburb
</span>
<input
bind:value={location}
on:input={() => clearError('location')}
type="text"
placeholder="For example, Grey Lynn"
class:invalid={errors.location}
autocomplete="address-level2"
/>
{#if errors.location}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
</span>
{/if}
</label>
</div>
<label class="wiz-field">
<span class="wiz-label">
<Icon name="fas fa-comment" />&nbsp;What else should we know?
</span>
<textarea
bind:value={message}
on:input={() => clearError('message')}
rows="3"
placeholder="Age, breed, temperament around other dogs, any health quirks, anything that helps us prepare."
class:invalid={errors.message}
></textarea>
{#if errors.message}
<span class="wiz-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</span>
{/if}
</label>
<div class="wiz-actions">
<button type="submit" class="wiz-btn wiz-btn-primary wiz-btn-primary--wide" disabled={submitting}>
{#if submitting}
Sending
{:else}
Send my details
{/if}
<Icon name="fas fa-paper-plane" />
</button>
</div>
{:else if step === 1}
<span class="wiz-step-eyebrow">Step one of two</span>
<h3 class="wiz-step-heading">Tell us about your dog</h3>
<p class="wiz-step-helper">Just the basics. Pick everything you are open to.</p>
@@ -556,7 +756,7 @@
</article>
</form>
<p class="wiz-reassurance" aria-live="polite">
<p class="wiz-reassurance" class:wiz-reassurance--compact={isCompactContactPage} aria-live="polite">
<Icon name="fas fa-bolt" />
A real reply within 24 hours, usually sooner.
</p>
@@ -575,6 +775,11 @@
margin: 0 auto;
}
.wiz-inner--compact {
max-width: 60rem;
margin-top: 8px;
}
.wiz-header {
text-align: center;
margin-bottom: 28px;
@@ -780,12 +985,21 @@
box-shadow: 0 30px 60px rgba(var(--ink-rgb), 0.08);
}
.wiz-card--compact {
max-width: 52rem;
padding: clamp(22px, 3vw, 32px);
}
.wiz-step {
display: flex;
flex-direction: column;
gap: 18px;
}
.wiz-step--compact {
gap: 16px;
}
.wiz-step-eyebrow {
align-self: flex-start;
padding: 6px 12px;
@@ -831,19 +1045,6 @@
color: var(--text-heading);
}
.wiz-optional {
margin-left: 6px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(var(--brand-rgb), 0.06);
color: var(--text-subtle);
font-family: var(--font-body);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.wiz-field input,
.wiz-field textarea {
width: 100%;
@@ -942,6 +1143,10 @@
border-color: rgba(var(--brand-rgb), 0.32);
}
.wiz-service--compact {
padding: 12px 14px;
}
.wiz-service.active {
border-color: var(--gw-green);
background: rgba(var(--accent-rgb), 0.1);
@@ -1022,6 +1227,11 @@
background: oklch(0.88 0.18 95);
}
.wiz-btn-primary--wide {
width: 100%;
justify-content: center;
}
.wiz-btn-back {
background: transparent;
color: var(--text-subtle);
@@ -1041,6 +1251,10 @@
text-align: center;
}
.wiz-reassurance--compact {
margin-top: 14px;
}
.wiz-reassurance :global(.icon) {
color: var(--yellow);
font-size: 13px;
@@ -1056,6 +1270,10 @@
padding-left: var(--space-container-x-mobile);
padding-right: var(--space-container-x-mobile);
}
.wiz-inner--compact {
margin-top: 0;
}
}
@media (max-width: 640px) {
@@ -1085,6 +1303,24 @@
grid-template-columns: 1fr;
}
.wiz-service-grid--compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wiz-service--compact {
min-height: 100%;
padding: 12px;
}
.wiz-service--compact .wiz-service-desc {
display: none;
}
.wiz-card--compact {
padding: 20px 18px;
border-radius: 22px;
}
.wiz-grid-two {
grid-template-columns: 1fr;
}
+1 -1
View File
@@ -387,7 +387,7 @@
content="Sign your Goodwalk service agreement online."
/>
<meta name="robots" content="noindex, nofollow" />
<link rel="canonical" href="https://onboarding.goodwalk.co.nz/contract/" />
<link rel="canonical" href="https://clients.goodwalk.co.nz/contract/" />
</svelte:head>
<main class="contract-page">
@@ -139,13 +139,10 @@
margin-bottom: 18px;
}
/* Layout-only override; typography and colour live on the shared
.eyebrow utility. */
.founder-kicker {
display: inline-block;
color: var(--text-subtle);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.founder-greeting {
+38 -32
View File
@@ -8,7 +8,7 @@
const journeyChips = [
{ icon: 'fas fa-handshake', label: 'Free Meet & Greet' },
{ icon: 'fas fa-clipboard-check', label: 'Assessment walks' },
{ icon: 'fas fa-calendar-check', label: 'A regular weekly rhythm' }
{ icon: 'fas fa-calendar-check', label: 'Weekly rhythm' }
];
</script>
@@ -59,7 +59,7 @@
Book your free Meet &amp; Greet
<Icon name="fas fa-arrow-right" />
</a>
<p class="hiw-cta-note">Free, no-obligation. We reply within 24 hours.</p>
<p class="hiw-cta-note">No obligation. We reply within 24 hours.</p>
</div>
</div>
@@ -74,7 +74,7 @@
.hiw-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
padding: 0 var(--space-container-x);
}
/* ── Header ── */
@@ -82,11 +82,9 @@
margin-bottom: 36px;
}
/* Layout-only override; typography lives on the shared .eyebrow utility. */
.hiw-eyebrow {
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
display: inline-block;
}
.hiw-intro {
@@ -103,14 +101,22 @@
margin-top: 28px;
}
/* Connector tracks through the vertical centre of the phase pills
(card padding 40px + half pill height ~12px). Reads as a timeline
running through the three steps, not a floating decorative rule. */
.hiw-steps::before {
content: '';
position: absolute;
top: 32px;
top: 52px;
left: 13%;
right: 13%;
height: 1px;
background: linear-gradient(90deg, rgba(33, 48, 33, 0.16), rgba(242, 191, 47, 0.4), rgba(33, 48, 33, 0.16));
background: linear-gradient(
90deg,
rgba(var(--brand-rgb), 0.16),
rgba(var(--accent-rgb), 0.4),
rgba(var(--brand-rgb), 0.16)
);
pointer-events: none;
}
@@ -119,12 +125,12 @@
flex-direction: column;
align-items: center;
text-align: center;
padding: 40px 40px 36px;
padding: clamp(28px, 2.6vw, 40px) clamp(24px, 2.6vw, 40px) clamp(26px, 2.4vw, 36px);
background:
radial-gradient(circle at top center, rgba(255, 209, 0, 0.12), transparent 34%),
#fff;
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
radial-gradient(circle at top center, rgba(var(--accent-rgb), 0.12), transparent 34%),
var(--surface-panel);
border: 1px solid var(--border-soft-strong);
box-shadow: var(--shadow-card);
transition: box-shadow 0.22s ease, transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
border-radius: 28px;
overflow: hidden;
@@ -133,7 +139,7 @@
@media (hover: hover) {
.hiw-step:hover {
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.09);
box-shadow: var(--shadow-xl);
transform: translateY(-4px);
z-index: 1;
}
@@ -151,9 +157,9 @@
.hiw-phase {
display: inline-block;
padding: 5px 13px;
border-radius: 999px;
border-radius: var(--radius-pill);
background: var(--yellow);
color: #000;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 11px;
font-weight: 800;
@@ -165,7 +171,7 @@
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
color: rgba(33, 48, 33, 0.28);
color: rgba(var(--brand-rgb), 0.28);
letter-spacing: 0.04em;
}
@@ -178,8 +184,8 @@
height: 64px;
margin-bottom: 22px;
border-radius: 20px;
background: var(--gw-green);
box-shadow: 0 10px 28px rgba(33, 48, 33, 0.2);
background: var(--surface-brand);
box-shadow: var(--shadow-badge);
}
.hiw-icon-wrap :global(.hiw-step-icon) {
@@ -194,12 +200,12 @@
font-size: var(--heading-card-size);
font-weight: 700;
line-height: 1.2;
color: #0d1a0d;
color: var(--text-heading);
}
.hiw-body {
margin: 0 0 20px;
color: #4c5056;
color: var(--text-muted);
font-size: 15px;
line-height: 1.65;
flex: 1;
@@ -210,8 +216,8 @@
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
border-radius: var(--radius-pill);
background: var(--surface-brand-muted);
color: var(--gw-green);
font-size: 13px;
font-weight: 700;
@@ -234,7 +240,7 @@
.hiw-cta-note {
margin: 0;
color: #888;
color: var(--text-softest);
font-size: 13px;
}
@@ -253,12 +259,12 @@
gap: 8px;
min-height: 44px;
padding: 0 16px;
border-radius: 999px;
background: var(--gw-green);
border-radius: var(--radius-pill);
background: var(--surface-brand);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.04),
0 10px 22px rgba(17, 20, 24, 0.06);
color: #fff;
var(--shadow-inset-inverse),
0 10px 22px rgba(var(--ink-rgb), 0.06);
color: var(--text-inverse);
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
@@ -310,8 +316,8 @@
align-items: flex-start;
text-align: left;
padding: 28px 24px;
border-radius: 24px;
border: 1px solid rgba(17, 20, 24, 0.06);
border-radius: var(--radius-xl);
border: 1px solid var(--border-soft-strong);
}
.hiw-step-meta {
+8 -23
View File
@@ -57,7 +57,7 @@
<!-- ── Hero ── -->
<section class="loc-hero">
<div class="page-inner">
<span class="loc-hero-eyebrow">Auckland Central Dog Walking</span>
<span class="eyebrow eyebrow--accent loc-hero-eyebrow">Auckland Central Dog Walking</span>
<h1>Dog walkers in {location.suburb}</h1>
<p class="loc-hero-desc">{location.intro}</p>
<div class="loc-hero-actions">
@@ -103,7 +103,7 @@
<section use:reveal={{ delay: 30 }} class="loc-parks reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">Where we walk</span>
<span class="eyebrow loc-eyebrow">Where we walk</span>
<h2>Parks &amp; walks in {location.suburb}</h2>
<p class="loc-section-intro">
These are the parks and routes we know well in {location.suburb}. Every walk is planned around your dog's pace, size, and temperament — not just the nearest green space.
@@ -128,7 +128,7 @@
<section use:reveal={{ delay: 30 }} class="loc-gallery reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">Local parks</span>
<span class="eyebrow loc-eyebrow">Local parks</span>
<h2>Park photos from {location.suburb}</h2>
<p class="loc-section-intro">
Real images from the parks we mention help each suburb page feel more specific and give search engines clearer local context.
@@ -161,7 +161,7 @@
<section use:reveal={{ delay: 30 }} class="loc-services reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">What we offer</span>
<span class="eyebrow loc-eyebrow">What we offer</span>
<h2>Goodwalk services in {location.suburb}</h2>
<p class="loc-section-intro">
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.
@@ -226,27 +226,12 @@
/* ── Eyebrow ── */
.loc-eyebrow,
.loc-hero-eyebrow {
/* Layout-only overrides; typography lives on the shared .eyebrow utility,
colour comes from .eyebrow / .eyebrow--accent. */
.loc-hero-eyebrow,
.loc-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loc-eyebrow {
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.loc-hero-eyebrow {
background: rgba(255, 209, 0, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 209, 0, 0.2);
color: var(--yellow);
}
/* ── Hero ── */
+2
View File
@@ -27,7 +27,9 @@
$: hidden =
pathname === '/contact-us' ||
pathname === '/booking' ||
$page.url.hostname === 'clients.goodwalk.co.nz' ||
$page.url.hostname === 'onboarding.goodwalk.co.nz' ||
$page.url.searchParams.get('preview') === 'clients' ||
$page.url.searchParams.get('preview') === 'onboarding';
let visible = false;
+11 -5
View File
@@ -6,11 +6,9 @@
$: 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, unknown>; draft: Record<string, unknown> } }>();
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, unknown>; draft: Record<string, unknown>; cpAdmin?: boolean; ownerEmail?: string } }>();
const ownerEmail = 'info@goodwalk.co.nz';
const ownerPhone = '(022) 642 1011';
@@ -58,6 +56,8 @@
try { window.localStorage.setItem('gw_onboarding_session', data.token); } catch { /* ignore */ }
let profile: Record<string, string> = {};
let draft: Record<string, unknown> = {};
let cpAdmin = false;
let verifiedOwnerEmail = '';
try {
const verifyRes = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${data.token}` },
@@ -66,9 +66,11 @@
const verifyData = await verifyRes.json();
profile = verifyData.profile ?? {};
draft = verifyData.draft ?? {};
cpAdmin = Boolean(verifyData.cpAdmin);
verifiedOwnerEmail = typeof verifyData.ownerEmail === 'string' ? verifyData.ownerEmail : '';
}
} catch { /* ignore */ }
dispatch('authenticated', { email: data.email, profile, draft });
dispatch('authenticated', { email: data.email, profile, draft, cpAdmin, ownerEmail: verifiedOwnerEmail });
} catch (e) {
error = e instanceof Error ? e.message : 'Something went wrong';
} finally {
@@ -99,7 +101,9 @@
{#if stage === 'email'}
<h2>Sign in to continue</h2>
{#if context !== 'owner'}
<p>{introText}</p>
{/if}
<div class="auth-field">
<label for="auth-email">Email address</label>
@@ -136,7 +140,7 @@
maxlength="6"
bind:value={codeValue}
on:keydown={handleCodeKey}
placeholder="123456"
placeholder="198604"
autocomplete="one-time-code"
disabled={loading}
class="auth-code-input"
@@ -156,12 +160,14 @@
</button>
{/if}
{#if context !== 'owner'}
<div class="auth-help">
<span>Need help?</span>
<a href="mailto:{ownerEmail}">{ownerEmail}</a>
<span>or</span>
<a href="tel:{ownerPhone.replace(/[^0-9+]/g, '')}">{ownerPhone}</a>
</div>
{/if}
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -10,6 +10,7 @@
mobileOrder: number;
};
export let variant: 'pricing' | 'service' = 'service';
export let hideCtaOnMobile = false;
$: featured = plan.isPopular;
const ctaLabel = 'Book a Meet & Greet';
@@ -48,7 +49,11 @@
{/each}
</ul>
<a class="btn btn-yellow plan-card__cta" href="#newlead">
<a
class="btn btn-yellow plan-card__cta"
class:plan-card__cta--mobile-hidden={hideCtaOnMobile}
href="#newlead"
>
{ctaLabel}
<Icon name="fas fa-arrow-right" />
</a>
@@ -380,5 +385,9 @@
width: 100%;
min-width: 0;
}
.plan-card__cta--mobile-hidden {
display: none;
}
}
</style>
+1
View File
@@ -311,4 +311,5 @@
justify-content: center;
}
}
</style>
@@ -1344,6 +1344,15 @@
color: var(--yellow);
}
/* "Best on solo" kicker sits on the dark-green fit cell. The default
pill is dark-green-on-faint-green, which renders as unreadable on this
background. Switch to yellow text on a yellow-tinted pill so it
matches the check icons and the brand's dark-surface eyebrow colour. */
.service-page.service-page-dog .service-decision-col-fit .service-decision-col-kicker-fit {
background: rgba(var(--accent-rgb), 0.16);
color: var(--yellow);
}
.service-page.service-page-dog .service-decision-col-not {
background:
linear-gradient(180deg, rgba(var(--white-rgb), 0.98) 0%, rgba(244, 247, 243, 0.96) 100%);
+2 -2
View File
@@ -4,9 +4,9 @@
import type { IconCard } from '$lib/types';
export let services: IconCard[];
export let heading = 'Choose the walk style that suits your dog best.';
export let heading = 'Find the walk that fits your dog.';
export let intro =
'Dogs are social creatures. The Tiny Gang gives them their own little friendship group: older dogs guide the younger ones, playful dogs burn energy together, and everyone comes home happy, tired, and fulfilled. All the fun of doggy daycare, without the huge groups or price tag.';
"The Tiny Gang is your dog's friendship group. Older dogs guide the youngsters; playful pairs burn energy together. The fun of doggy daycare, without the crowd or the price tag.";
const sharedPromises = [
'Familiar walkers',
+129 -34
View File
@@ -1,8 +1,9 @@
<script lang="ts">
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';
import { reveal } from '$lib/actions/reveal';
import ServiceHero from '$lib/components/ServiceHero.svelte';
import type { SiteSharedContent, TestimonialContent } from '$lib/types';
export let content: SiteSharedContent;
@@ -12,6 +13,13 @@
...testimonial,
enhanced: testimonial.imageUrl ? getEnhancedImage(testimonial.imageUrl) : undefined
}));
const heroImageUrl = '/images/our-client-testimonials.webp';
const heroImageAlt = 'Goodwalk client dogs featured in testimonials';
const heroChips = [
{ icon: 'fas fa-star', label: '30+ five-star Google reviews' },
{ icon: 'fas fa-paw', label: 'Tiny Gang regulars' },
{ icon: 'fas fa-camera', label: 'Real client dogs' }
];
function dogNameFromDetail(detail: string) {
const match = detail.match(/^([^']+)/);
@@ -44,23 +52,17 @@
</script>
<main class="testimonials-page">
<PageHeader
variant="green"
<ServiceHero
eyebrow="Goodwalk reviews"
title="Client Testimonials"
subtitle="Read why clients say their dogs love Aless, the Tiny Gang, and getting out with their mates."
>
<a
class="btn btn-yellow btn-hide-arrow-mobile testimonials-page-header-cta"
href="#newlead"
aria-label="Book a Meet and Greet"
>
<span>Book a Meet &amp; Greet</span>
<Icon name="fas fa-arrow-right" />
</a>
</PageHeader>
subtitle="Calmer dogs. Easier days. The kind of care owners stop worrying about once they have it."
imageUrl={heroImageUrl}
imageAlt={heroImageAlt}
chips={heroChips}
cta={{ label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' }}
/>
<section class="testimonials-page-grid-section">
<section use:reveal class="testimonials-page-grid-section reveal-block">
<div class="page-inner">
<div class="testimonials-page-grid">
{#each testimonialCards as testimonial}
@@ -155,16 +157,6 @@
background: var(--off-white);
}
.testimonials-page-header-cta {
display: inline-flex;
align-items: center;
gap: 12px;
margin-top: 22px;
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.testimonials-page-trust-logo {
flex: 0 0 auto;
}
@@ -195,6 +187,10 @@
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 16px 34px rgba(33, 48, 33, 0.08);
transition:
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.28s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.24s ease;
}
.testimonials-page-card-media {
@@ -210,6 +206,7 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.testimonials-page-card-fallback {
@@ -290,6 +287,7 @@
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(18, 26, 18, 0.1) 0%, rgba(18, 26, 18, 0.78) 100%);
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.testimonials-page-card-dog {
@@ -308,6 +306,7 @@
grid-template-rows: auto 1fr auto;
gap: 14px;
padding: 18px 8px 10px;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.testimonials-page-card-top {
@@ -384,6 +383,97 @@
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
background 0.22s ease;
}
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-card,
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
opacity: 0;
transform: translateY(28px);
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.56s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.56s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.28s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.24s ease;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(1) {
transition-delay: 60ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(2) {
transition-delay: 140ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(3) {
transition-delay: 220ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(4) {
transition-delay: 300ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(5) {
transition-delay: 380ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(6) {
transition-delay: 460ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.56s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.56s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: 240ms;
}
@media (hover: hover) {
.testimonials-page-card:hover {
transform: translateY(-6px);
border-color: rgba(33, 48, 33, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 24px 48px rgba(33, 48, 33, 0.12);
}
.testimonials-page-card:hover .testimonials-page-card-media img {
transform: scale(1.035);
}
.testimonials-page-card:hover .testimonials-page-card-copy {
transform: translateY(-2px);
}
.testimonials-page-card:hover .testimonials-page-card-meta {
transform: translateY(-2px);
}
.testimonials-page-google-cta:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.92);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 20px 34px rgba(67, 49, 21, 0.11);
}
}
.testimonials-page-card:focus-within {
transform: translateY(-4px);
border-color: rgba(33, 48, 33, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 22px 44px rgba(33, 48, 33, 0.12);
}
@media (max-width: 1024px) {
@@ -412,15 +502,6 @@
padding: 20px 0 56px;
}
.testimonials-page-header-cta {
gap: 10px;
font-size: 13px;
}
.testimonials-page-header-cta :global(.icon) {
display: none;
}
.testimonials-page-card-copy {
gap: 14px;
padding: 18px 6px 8px;
@@ -460,4 +541,18 @@
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.testimonials-page-card,
.testimonials-page-card-media img,
.testimonials-page-card-meta,
.testimonials-page-card-copy,
.testimonials-page-google-cta,
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-card,
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
transition: none;
transform: none;
opacity: 1;
}
}
</style>
+2 -11
View File
@@ -185,7 +185,7 @@
<div class="testimonials-inner">
<div class="testimonials-header">
<div class="testimonials-header-main">
<span class="testimonials-eyebrow">
<span class="eyebrow testimonials-eyebrow">
<Icon name="fas fa-star" className="testimonials-eyebrow-star" />
{eyebrow}
</span>
@@ -341,19 +341,10 @@
gap: 8px;
width: fit-content;
margin: 0 auto 14px;
padding: 7px 14px;
border-radius: 999px;
background: var(--yellow);
color: var(--gw-green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.18);
}
.testimonials-eyebrow :global(.testimonials-eyebrow-star) {
color: var(--gw-green);
color: var(--yellow);
font-size: 11px;
line-height: 1;
}
+15 -59
View File
@@ -7,28 +7,28 @@
export let values: IconCard[];
const stakes = [
{
label: 'Without the right routine',
title: 'By the end of the day, everything feels harder than it should.',
label: 'Without Goodwalk',
title: 'The evening pays for the day.',
body:
'Your dog still has energy to burn, you are carrying guilt through the workday, and home does not feel as calm as it could.',
"The dog is wired. You're tired. Home feels like the third shift.",
points: [
'A dog who is restless, wired, or harder to settle at home',
'A workday shaped by guilt, logistics, and wondering how they are doing',
'Too much uncertainty around who is picking up your dog and what the walk will be like'
"A dog who can't settle",
'A workday full of guilt',
"A walker you're never quite sure about"
],
footer: 'The walk is not really the point. The evening is.'
},
{
label: 'With Goodwalk',
title: 'A good walk changes you & your dogs whole evening.',
title: 'The evening pays you back.',
body:
'Your dog comes home happier, the routine feels lighter, and you are not spending the day second-guessing whether they are okay.',
"The dog is tired. The home is quiet. The workday isn't carrying guilt.",
points: [
'A walker your dog recognises and is happy to see at the door',
'Small-group or one-on-one care that genuinely suits your dog',
'Clear updates, calmer evenings, and one less thing sitting on your mind'
'A walker your dog knows',
'Small dogs walking with small dogs',
'A workday with one less thing on it'
],
footer: 'That is what people are really buying: peace of mind, routine, and a dog who feels cared for.'
footer: 'Peace of mind, in dog form.'
}
];
const clientPhotos = [
@@ -87,11 +87,7 @@
<section id="values" use:reveal={{ delay: 30 }} class="reveal-block">
<div class="values-inner">
<div class="section-header">
<span class="eyebrow values-eyebrow">Why people come to us</span>
<h2 class="section-heading">Calmer dogs. Better routines. The Tiny Gang effect.</h2>
<p class="section-intro values-intro">
Goodwalk was created for busy owners who want reliable, relationship-led care their dog genuinely looks forward to.
</p>
<h2 class="section-heading">Calmer dogs. Calmer evenings.</h2>
</div>
<div class="values-photo-grid" aria-label="Goodwalk client dogs">
@@ -154,11 +150,7 @@
</div>
<div class="values-points-header">
<span class="eyebrow values-eyebrow">What we stand for</span>
<h3 class="values-points-title">The values behind every walk</h3>
<p class="values-points-intro">
Kind handling, small groups, proper safety training, and honest communication — not extras, just how Goodwalk works.
</p>
<h3 class="values-points-title">Things we don't treat as extras</h3>
</div>
<div class="values-bento values-points">
@@ -194,18 +186,6 @@
text-align: center;
}
.values-eyebrow {
width: fit-content;
padding: 6px 12px;
border-radius: var(--radius-pill);
background: var(--surface-brand-soft);
box-shadow: var(--shadow-inset-strong);
}
.values-intro {
max-width: 720px;
}
/* ── Client photo gallery ── */
.values-photo-grid {
display: grid;
@@ -462,7 +442,7 @@
.values-points-title {
max-width: 19ch;
margin: 12px auto 0;
margin: 0 auto;
font-family: var(--font-head);
font-size: clamp(24px, 2.4vw, 32px);
font-weight: 700;
@@ -471,14 +451,6 @@
color: var(--text-heading);
}
.values-points-intro {
max-width: 560px;
margin: 14px auto 0;
color: var(--text-muted);
font-size: var(--body-copy-size);
line-height: 1.65;
}
/* ── Values points ── */
.values-points {
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -546,16 +518,6 @@
padding: 0 var(--space-container-x-mobile);
}
.values-intro {
max-width: 32ch;
}
.values-eyebrow {
padding: 6px 10px;
font-size: 12px;
letter-spacing: 0.06em;
}
.values-photo-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: auto;
@@ -641,12 +603,6 @@
font-size: clamp(22px, 6.4vw, 27px);
}
.values-points-intro {
max-width: 36ch;
font-size: var(--body-lead-size-mobile);
line-height: 1.55;
}
.values-points {
grid-template-columns: 1fr;
margin-top: 20px;
@@ -6,7 +6,6 @@
import type { Picture } from '@sveltejs/enhanced-img';
const desktop = logoDesktop as Picture;
const ownerEmail = 'info@goodwalk.co.nz';
const allClientsPageSize = 12;
const birthdaysPageSize = 12;
@@ -54,8 +53,23 @@
let isAuthenticated = false;
let userEmail = '';
let accessDenied = false;
let ownerEmail = 'info@goodwalk.co.nz';
let activeTab: 'home' | 'clients' | 'birthdays' | 'messaging' = 'home';
let activeTab: 'home' | 'clients' | 'birthdays' | 'messaging' | 'activity' = 'home';
type ActivityEvent = {
id: number;
createdAt: string | null;
requestId: string | null;
eventType: string;
actorEmail: string | null;
ip: string | null;
status: string | null;
detail: Record<string, unknown>;
};
let activityEvents: ActivityEvent[] = [];
let activityLoading = false;
let activityError = '';
let loadingHome = false;
let homeError = '';
@@ -256,7 +270,8 @@
const data = await res.json();
isAuthenticated = true;
userEmail = data.email;
accessDenied = data.email !== ownerEmail;
ownerEmail = typeof data.ownerEmail === 'string' && data.ownerEmail ? data.ownerEmail : ownerEmail;
accessDenied = !data.cpAdmin;
if (!accessDenied) {
await Promise.all([fetchHome(), fetchAllClients(1), fetchBirthdays(1)]);
}
@@ -270,10 +285,11 @@
authChecking = false;
}
function handleAuthenticated(event: CustomEvent<{ email: string }>) {
function handleAuthenticated(event: CustomEvent<{ email: string; cpAdmin?: boolean; ownerEmail?: string }>) {
isAuthenticated = true;
userEmail = event.detail.email;
accessDenied = event.detail.email !== ownerEmail;
ownerEmail = event.detail.ownerEmail || ownerEmail;
accessDenied = !event.detail.cpAdmin;
if (!accessDenied) {
void Promise.all([fetchHome(), fetchAllClients(1), fetchBirthdays(1)]);
}
@@ -487,13 +503,52 @@
}
}
function setTab(tab: 'home' | 'clients' | 'birthdays' | 'messaging') {
function setTab(tab: 'home' | 'clients' | 'birthdays' | 'messaging' | 'activity') {
activeTab = tab;
resetComposer();
birthdayActionError = '';
if (tab === 'messaging' && !messageTemplates.length) {
void fetchMessageTemplates();
}
if (tab === 'activity') {
void fetchActivity();
}
}
async function fetchActivity() {
const token = getToken();
if (!token) return;
activityLoading = true;
activityError = '';
try {
const res = await fetch('/api/owner/activity?limit=200', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Activity feed failed (${res.status}).`);
const json = await res.json();
activityEvents = Array.isArray(json.events) ? json.events : [];
} catch (error) {
activityError = error instanceof Error ? error.message : 'Could not load activity.';
} finally {
activityLoading = false;
}
}
function formatEventTime(iso: string | null): string {
if (!iso) return '—';
try {
const d = new Date(iso);
return d.toLocaleString('en-NZ', { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return iso;
}
}
function formatEventDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '';
return Object.entries(detail)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : String(v)}`)
.join(' · ');
}
type MessageTemplate = {
@@ -754,6 +809,10 @@
title: 'Messaging',
description: 'Send themed bulk or individual emails to clients via BCC.',
},
activity: {
title: 'Activity',
description: 'Audit log of contact-form submissions, logins, and owner actions.',
},
} as const;
$: header = tabHeaders[activeTab];
@@ -804,6 +863,10 @@
<Icon name="fas fa-envelope-open-text" />
<span>Messaging</span>
</button>
<button type="button" class:owner-topbar-tab-active={activeTab === 'activity'} class="owner-topbar-tab" on:click={() => setTab('activity')}>
<Icon name="fas fa-clock-rotate-left" />
<span>Activity</span>
</button>
</nav>
{/if}
@@ -829,7 +892,7 @@
{#if accessDenied}
<div class="owner-message-card">
<h2>Owner access only.</h2>
<p>This screen is restricted to <strong>{ownerEmail}</strong>.</p>
<p>This screen is restricted to configured CP admin emails.</p>
</div>
{:else}
{#if activeTab === 'home'}
@@ -1340,6 +1403,47 @@
</aside>
</div>
{/if}
{:else if activeTab === 'activity'}
<div class="activity-panel">
<div class="activity-head">
<h2>Recent activity</h2>
<button type="button" class="activity-refresh" on:click={() => void fetchActivity()} disabled={activityLoading}>
<Icon name="fas fa-arrows-rotate" />
{activityLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{#if activityError}
<p class="activity-error">{activityError}</p>
{/if}
{#if activityLoading && !activityEvents.length}
<p class="activity-status">Loading…</p>
{:else if !activityEvents.length}
<p class="activity-status">No activity recorded yet.</p>
{:else}
<table class="activity-table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Actor</th>
<th>Status</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{#each activityEvents as ev (ev.id)}
<tr>
<td class="activity-time">{formatEventTime(ev.createdAt)}</td>
<td class="activity-type">{ev.eventType}</td>
<td class="activity-actor">{ev.actorEmail || '—'}</td>
<td class="activity-status-cell" data-status={ev.status || ''}>{ev.status || '—'}</td>
<td class="activity-detail">{formatEventDetail(ev.detail)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
<nav class="owner-bottom-nav" aria-label="Owner sections">
@@ -1359,6 +1463,10 @@
<Icon name="fas fa-envelope-open-text" />
<span>Messaging</span>
</button>
<button type="button" class:owner-bottom-tab-active={activeTab === 'activity'} class="owner-bottom-tab" on:click={() => setTab('activity')}>
<Icon name="fas fa-clock-rotate-left" />
<span>Activity</span>
</button>
</nav>
{/if}
</div>
@@ -2773,4 +2881,89 @@
gap: 2px;
}
}
/* ── Activity tab ── */
.activity-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 1px 3px rgba(33, 48, 33, 0.06);
}
.activity-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.activity-head h2 {
margin: 0;
font-size: 18px;
color: var(--gw-green);
}
.activity-refresh {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(33, 48, 33, 0.14);
background: #fff;
color: var(--gw-green);
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.activity-refresh:disabled {
opacity: 0.6;
cursor: progress;
}
.activity-error {
background: #fde9e6;
color: #8a2b22;
padding: 10px 14px;
border-radius: 10px;
margin-bottom: 12px;
}
.activity-status {
color: #666;
font-size: 14px;
margin: 12px 0;
}
.activity-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.activity-table th,
.activity-table td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid #eeeee8;
vertical-align: top;
}
.activity-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #888;
font-weight: 700;
}
.activity-time { color: #555; white-space: nowrap; }
.activity-type { font-family: Menlo, Consolas, monospace; color: var(--gw-green); }
.activity-actor { color: #444; }
.activity-status-cell { font-weight: 600; }
.activity-status-cell[data-status="ok"] { color: #2d7a2d; }
.activity-status-cell[data-status="ignored"] { color: #b87800; }
.activity-status-cell[data-status="partial"] { color: #b87800; }
.activity-detail { color: #666; max-width: 480px; word-break: break-word; }
</style>
+14 -14
View File
@@ -104,25 +104,25 @@ export const homepageContent: HomePageContent = {
{
phase: 'Meet',
benefit: 'No pressure, just clarity',
title: 'A proper Meet & Greet at home',
title: 'We come to your home first.',
body:
'You show us your dog. We talk through routine and temperament. Everyone decides if the fit is right — before anything starts.',
"You show us your dog. We talk routine and temperament. Nobody books until everyone's sure.",
icon: 'fas fa-handshake'
},
{
phase: 'Settle',
benefit: 'A smoother start for nervous dogs',
benefit: 'Confidence before commitment',
title: 'Your dog settles in. No rushing.',
body:
'Two assessment walks to build confidence, find the right pace, and make sure your dog feels comfortable before they take a regular spot.',
'Two assessment walks. We find their pace before any regular spot.',
icon: 'fas fa-clipboard-check'
},
{
phase: 'Thrive',
benefit: 'The outcome you actually want',
title: 'Then the routine does the work',
benefit: 'Calmer evenings',
title: 'Then it just runs.',
body:
'Your dog comes home tired and happy. Evenings get quieter. The workday stops carrying the same low-grade guilt.',
'Your dog comes home tired and happy. Evenings get quieter. The workday stops carrying guilt.',
icon: 'fas fa-heart'
}
]
@@ -132,40 +132,40 @@ export const homepageContent: HomePageContent = {
icon: 'fas fa-heart',
title: 'Calm, kind handling',
body:
'Positive reinforcement. Patient routines. Dogs build confidence — not stress.'
'Patient routines. Confidence over stress.'
},
{
icon: 'fas fa-camera',
title: 'Updates you actually want',
body:
"See your dog out enjoying the day. Less wondering through the workday."
'See your dog out enjoying the day. Less wondering.'
},
{
icon: 'fas fa-users',
title: 'Small pack sizes',
title: 'Matched, not just grouped',
order: 2,
body:
'4 to 8 dogs. Always. Calm, structured walks with real attention for every dog.'
'4 to 8 dogs, matched on size, energy, and play style. Small and medium only.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety, by default',
order: 1,
body:
'Pet first aid certified. Careful screening. Proactive handling. Not extras — the baseline.'
'Pet first aid certified. Careful screening. Proactive handling. The baseline, not a premium.'
},
{
icon: 'fas fa-calendar-check',
title: 'Built for real schedules',
body:
"We specialise in regular walks. Life changes — give us notice and we'll work with it."
"Regular walks, week in week out. Life changes; we adjust."
},
{
icon: 'fas fa-clock',
title: 'Reliable. Clear. Easy.',
order: 3,
body:
"You should not have to chase your dog walker. Consistent pickup. Clear communication. Nothing to manage."
'You should never have to chase your walker.'
}
],
testimonials: [
+46 -22
View File
@@ -9,29 +9,43 @@ const oneToOneService = sharedServices.find((service) => service.title === 'Solo
const puppyVisitsService = sharedServices.find((service) => service.title === 'Puppy Visits');
export const ourPricingContent: PricingPageContent = {
title: 'Our Pricing',
subtitle: 'Choose the Goodwalk routine that fits your dog, your week, and the kind of support you need.',
eyebrow: 'Pricing',
title: 'The right walk changes the whole week.',
subtitle: 'Calmer dogs. Easier workdays. A routine you do not have to think about twice.',
heroImageUrl: '/images/happy-customer-anna.webp',
heroImageAlt: 'Happy Goodwalk customer Anna with her dog in Auckland',
heroChips: [
{ icon: 'fas fa-star', label: '30+ five-star Google reviews' },
{ icon: 'fas fa-handshake', label: 'Free Meet & Greet first' },
{ icon: 'fas fa-car', label: 'Pickup & drop-off included' }
],
heroNote: 'Start with the outcome. We will help with the fit.',
comparison: {
title: 'Which service is right for my dog?',
title: 'Which one fits?',
intro:
'Three services, designed for three different stages and temperaments. Here is how to think about which one fits your dog best.',
'Three options. Pick the one that sounds most like your dog.',
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.',
'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.'
'Tiny Gang is for sociable small and medium dogs who come home better when the group is right.',
'Solo Walks are for dogs who need more space, more steadiness, or more individual attention.',
'Puppy Visits are for young dogs who need support at home now, and a smoother next step later.',
'Still unsure? The free Meet & Greet makes the call simple. We will tell you what fits, and what does not.'
]
},
sections: [
{
title: 'Tiny Gang Pack Walks',
icon: packWalksService?.icon ?? 'fas fa-paw',
eyebrow: 'Good Walk signature',
eyebrow: 'Most chosen',
lede: 'For sociable small and medium dogs who do well in company.',
outcome: 'The dog who paces the house at 5pm comes home settled instead.',
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
imageAlt: 'Small Goodwalk dogs sitting together after a pack walk in an Auckland park',
fit: ['Small or medium-sized', 'Enjoys other dogs', 'Thrives on a weekly routine'],
assurance: 'This is where most regular weekday clients start.',
meta: [
{ label: 'Best for', value: 'Dogs who enjoy other dogs' },
{ label: 'Typical routine', value: '2 to 4 walks a week' },
{ label: 'Pricing note', value: 'Per-walk price drops as frequency goes up' }
{ label: 'Best for', value: 'Social dogs' },
{ label: 'Typical week', value: '2 to 4 walks' },
{ label: 'From', value: '$49.50 per walk' }
],
detailCta: {
label: 'View Tiny Gang Pack Walks',
@@ -43,12 +57,17 @@ export const ourPricingContent: PricingPageContent = {
{
title: 'Solo Walks',
icon: oneToOneService?.icon ?? 'fas fa-person-walking',
eyebrow: 'Tailored support',
eyebrow: 'More space',
lede: 'When a group setting is not the right fit.',
outcome: 'The walk slows down. The dog softens. The rest of the day gets easier.',
imageUrl: '/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp',
imageAlt: 'Large dog walking calmly on a one-on-one Goodwalk outing in Auckland',
fit: ['Reactive or anxious around dogs', 'Senior or recovering', 'Needs one familiar walker'],
assurance: 'We recommend solo when group pressure would make things worse.',
meta: [
{ label: 'Best for', value: 'Reactive, senior, recovering, anxious, or larger dogs' },
{ label: 'How it differs', value: 'Pace, route, and duration shaped around your dog' },
{ label: 'Pricing note', value: 'Higher per-walk than Tiny Gang because the walk is fully individual' }
{ label: 'Best for', value: 'Sensitive dogs' },
{ label: 'How it feels', value: 'Quieter, steadier, fully individual' },
{ label: 'From', value: '$45 per walk' }
],
detailCta: {
label: 'View Solo Walks',
@@ -60,12 +79,17 @@ export const ourPricingContent: PricingPageContent = {
{
title: 'Puppy Visits',
icon: puppyVisitsService?.icon ?? 'fas fa-dog',
eyebrow: 'Building blocks for the Tiny Gang',
eyebrow: 'Early support',
lede: 'For puppies under 12 to 18 months, before they are ready for full walks.',
outcome: 'You get through the workday. Your puppy gets through the day properly looked after.',
imageUrl: '/images/goodwalk-puppy-home-visit-auckland.webp',
imageAlt: 'Young puppy receiving a calm Goodwalk visit at home in Auckland',
fit: ['Too young for full walks', 'Needs midday support at home', 'Will likely move into walks later'],
assurance: 'Many Tiny Gang dogs started here first.',
meta: [
{ label: 'Includes', value: 'Toilet breaks, feeding, gentle play, calm company at home' },
{ label: 'Why visits, not walks', value: 'Vets advise against long pavement walks while growth plates develop' },
{ label: 'Sets up', value: 'A smoother move to Tiny Gang later' }
{ label: 'Includes', value: 'Toilet break, feeding, play, calm company' },
{ label: 'Why now', value: 'Low-impact support while they grow' },
{ label: 'From', value: '$39 per visit' }
],
detailCta: {
label: 'View Puppy Visits',
@@ -77,8 +101,8 @@ export const ourPricingContent: PricingPageContent = {
],
testimonialsHeading: 'What our clients say',
booking: {
title: 'Tell us about your dog',
subtitle: '',
title: 'Tell us about your dog. We will point you to the right fit.',
subtitle: 'A few details first. Then a free Meet & Greet.',
formAction: '/contact-us',
serviceOptions: [],
ownerStepLabel: 'Your details',
+1
View File
@@ -75,6 +75,7 @@ export const puppyVisitsContent: ServicePageContent = {
title: '45 Minute Visit',
price: '$49',
period: 'Per Visit',
popular: true,
features: ['Toilet break and feeding if needed', 'Play and enrichment time', 'Early routine-building support', 'Most popular visit length']
},
{
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveSurface } from './surface';
vi.mock('$app/environment', () => ({
dev: true
}));
function createCookies(initial?: string) {
let value = initial;
return {
get: vi.fn(() => value),
set: vi.fn((_name: string, nextValue: string) => {
value = nextValue;
}),
delete: vi.fn(() => {
value = undefined;
}),
};
}
describe('resolveSurface', () => {
it('maps canonical subdomains to their surfaces', () => {
const cookies = createCookies();
expect(resolveSurface(new URL('https://cp.goodwalk.co.nz/'), cookies as never)).toEqual({
surface: 'cp',
isPreview: false,
});
expect(resolveSurface(new URL('https://clients.goodwalk.co.nz/'), cookies as never)).toEqual({
surface: 'clients',
isPreview: false,
});
});
it('keeps legacy subdomains working during the redirect window', () => {
const cookies = createCookies();
expect(resolveSurface(new URL('https://admin.goodwalk.co.nz/'), cookies as never)).toEqual({
surface: 'cp',
isPreview: false,
});
expect(resolveSurface(new URL('https://onboarding.goodwalk.co.nz/'), cookies as never)).toEqual({
surface: 'clients',
isPreview: false,
});
});
it('accepts legacy preview aliases and stores the canonical surface cookie', () => {
const cookies = createCookies();
expect(resolveSurface(new URL('https://localhost:5173/?preview=admin'), cookies as never)).toEqual({
surface: 'cp',
isPreview: true,
});
expect(cookies.set).toHaveBeenCalledWith(
'gw_preview',
'cp',
expect.objectContaining({ path: '/', httpOnly: false, sameSite: 'lax' })
);
expect(resolveSurface(new URL('https://localhost:5173/?preview=onboarding'), cookies as never)).toEqual({
surface: 'clients',
isPreview: true,
});
expect(cookies.set).toHaveBeenLastCalledWith(
'gw_preview',
'clients',
expect.objectContaining({ path: '/', httpOnly: false, sameSite: 'lax' })
);
});
it('reuses canonical preview values from the cookie', () => {
const cookies = createCookies('clients');
expect(resolveSurface(new URL('https://localhost:5173/contract'), cookies as never)).toEqual({
surface: 'clients',
isPreview: true,
});
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Surface resolution single source of truth for "which app variant is this
* request hitting." Used by hooks and per-route server loaders so the rules
* live in one place.
*
* In production a request is routed purely by hostname:
* cp.goodwalk.co.nz 'cp' (owner admin dashboard)
* clients.goodwalk.co.nz 'clients' (onboarding + contract portal)
* everything else 'marketing' (public site)
*
* The previous subdomains admin.* and onboarding.* still resolve to the same
* surfaces so a 301 redirect at the nginx layer can take its time without
* the app breaking if a direct hit slips through.
*
* In development a ?preview=cp or ?preview=clients query parameter forces a
* surface and is persisted in a cookie so links inside the previewed surface
* keep working without re-typing the query. The legacy aliases
* ?preview=admin and ?preview=onboarding resolve to the same surfaces.
* ?preview=off clears it. The preview machinery is hard-gated on `dev`
* `vite build` strips the else-branch entirely, so a production build cannot
* honour ?preview no matter what query string a visitor sends.
*/
import { dev } from '$app/environment';
import type { Cookies } from '@sveltejs/kit';
export type Surface = 'marketing' | 'cp' | 'clients';
type PreviewAlias = Surface | 'admin' | 'onboarding';
const CP_HOSTS = new Set(['cp.goodwalk.co.nz', 'admin.goodwalk.co.nz']);
const CLIENT_HOSTS = new Set(['clients.goodwalk.co.nz', 'onboarding.goodwalk.co.nz']);
const PREVIEW_COOKIE = 'gw_preview';
const PREVIEW_COOKIE_MAX_AGE = 60 * 60 * 8; // 8h — fits a dev workday
const PREVIEW_ALIASES: Record<PreviewAlias, Surface> = {
marketing: 'marketing',
cp: 'cp',
clients: 'clients',
admin: 'cp',
onboarding: 'clients',
};
export interface ResolvedSurface {
surface: Surface;
/** True when this surface is being viewed via the dev-only ?preview mechanism. */
isPreview: boolean;
}
function normalizePreviewSurface(value: string | null): Surface | null {
if (!value) return null;
return PREVIEW_ALIASES[value as PreviewAlias] ?? null;
}
export function resolveSurface(url: URL, cookies: Cookies): ResolvedSurface {
const hostname = url.hostname.toLowerCase();
if (CP_HOSTS.has(hostname)) return { surface: 'cp', isPreview: false };
if (CLIENT_HOSTS.has(hostname)) return { surface: 'clients', isPreview: false };
// Preview only exists in dev. In production this whole block is dead code
// that Vite tree-shakes out of the server bundle.
if (!dev) return { surface: 'marketing', isPreview: false };
const queryPreview = url.searchParams.get('preview');
if (queryPreview === 'off') {
cookies.delete(PREVIEW_COOKIE, { path: '/' });
return { surface: 'marketing', isPreview: false };
}
const normalizedQueryPreview = normalizePreviewSurface(queryPreview);
if (normalizedQueryPreview && normalizedQueryPreview !== 'marketing') {
cookies.set(PREVIEW_COOKIE, normalizedQueryPreview, {
path: '/',
maxAge: PREVIEW_COOKIE_MAX_AGE,
httpOnly: false,
sameSite: 'lax',
});
return { surface: normalizedQueryPreview, isPreview: true };
}
const cookieValue = normalizePreviewSurface(cookies.get(PREVIEW_COOKIE) ?? null);
if (cookieValue && cookieValue !== 'marketing') {
return { surface: cookieValue, isPreview: true };
}
return { surface: 'marketing', isPreview: false };
}
+1 -9
View File
@@ -10,10 +10,6 @@
.booking-eyebrow {
display: inline-block;
margin-bottom: 12px;
padding: 6px 10px;
border-radius: 999px;
background: var(--surface-brand-muted);
box-shadow: var(--shadow-inset-soft);
}
/* Intentional exception: this is a conversion-focused hero title, not a
@@ -290,7 +286,7 @@
display: inline-flex;
align-items: center;
gap: 7px;
margin-bottom: 10px;
margin-bottom: 14px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
@@ -319,10 +315,6 @@
text-underline-offset: 0.18em;
}
.booking-field-stack .booking-service-label {
margin-bottom: 14px;
}
.booking-selected-service-row {
display: flex;
align-items: center;
-2
View File
@@ -497,8 +497,6 @@
.booking-eyebrow {
margin-bottom: 12px;
padding: 6px 10px;
font-size: 11px;
}
.booking-title {
+2 -4
View File
@@ -954,12 +954,10 @@ footer {
color: var(--text-inverse);
}
/* Yellow text on the dark-green hero variant. Pill chrome removed to
match the Tiny Gang Pack Walks eyebrow style. */
.page-header--green .eyebrow {
display: inline-block;
padding: 7px 12px;
border-radius: 999px;
background: var(--surface-accent-soft);
box-shadow: inset 0 0 0 1px var(--border-accent-soft);
color: var(--yellow);
}
+10 -5
View File
@@ -94,13 +94,18 @@ h3 {
}
/*
* Shared eyebrow utility for the small uppercase label that introduces a
* heading. Use this instead of bespoke per-section kickers.
* Shared eyebrow utility. Single source of truth for the small uppercase
* label that introduces a heading. The Tiny Gang Pack Walks hero
* (ServiceHero.sh-eyebrow) is the reference: Unbounded head, 13px, 800
* weight, 0.08em tracking, uppercase. Colour is contextual brand green
* on light surfaces (default), yellow on dark/green surfaces via the
* --accent modifier. Bespoke section-eyebrow classes should add this
* class and override layout only (margin / alignment), not typography.
*/
.eyebrow {
font-family: var(--font-body);
font-size: 12px;
font-weight: var(--weight-heading);
font-family: var(--font-head);
font-size: 13px;
font-weight: 800;
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--gw-green);
+10
View File
@@ -216,6 +216,11 @@ export interface PricingPageSection {
blurb?: string;
eyebrow?: string;
lede?: string;
outcome?: string;
imageUrl?: string;
imageAlt?: string;
fit?: string[];
assurance?: string;
meta?: PricingPageSectionMeta[];
detailCta?: CallToAction;
plans: ServicePricingPlan[];
@@ -229,7 +234,12 @@ export interface PricingPageComparison {
export interface PricingPageContent {
title: string;
eyebrow?: string;
subtitle?: string;
heroImageUrl: string;
heroImageAlt: string;
heroChips?: HeroChip[];
heroNote?: string;
comparison?: PricingPageComparison;
sections: PricingPageSection[];
testimonialsHeading: string;
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { decoratePlans } from './pricing';
describe('decoratePlans', () => {
it('prefers an explicit popular plan over the cheapest plan', () => {
const plans = decoratePlans([
{ title: 'Cheapest', price: '$39', period: 'Per Walk' },
{ title: 'Recommended', price: '$49', period: 'Per Walk', popular: true },
{ title: 'Longest', price: '$55', period: 'Per Walk' }
]);
expect(plans.map((plan) => plan.isPopular)).toEqual([false, true, false]);
});
it('falls back to the cheapest plan when none is marked popular', () => {
const plans = decoratePlans([
{ title: 'Weekly', price: '$58', period: 'Per Walk' },
{ title: 'Regular', price: '$55', period: 'Per Walk' },
{ title: 'Frequent', price: '$49.50', period: 'Per Walk' }
]);
expect(plans.map((plan) => plan.isPopular)).toEqual([false, false, true]);
});
});
+5 -2
View File
@@ -11,12 +11,15 @@ export function decoratePlans<T extends { price: string; period: string }>(plans
}));
const sorted = [...enriched].sort((a, b) => a.value - b.value || a.index - b.index);
const cheapestIndex = sorted[0]?.index ?? -1;
const explicitPopularIndex = plans.findIndex(
(plan) => 'popular' in plan && Boolean((plan as T & { popular?: boolean }).popular)
);
const featuredIndex = explicitPopularIndex >= 0 ? explicitPopularIndex : (sorted[0]?.index ?? -1);
const mobileOrder = new Map(sorted.map((entry, order) => [entry.index, order]));
return plans.map((plan, index) => ({
...plan,
isPopular: index === cheapestIndex,
isPopular: index === featuredIndex,
mobileOrder: mobileOrder.get(index) ?? index
}));
}
+6 -10
View File
@@ -1,23 +1,19 @@
import { getHomepageContent } from '$lib/server/content';
import { resolveSurface } from '$lib/server/surface';
const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']);
export async function load({ url }) {
const hostname = url.hostname.toLowerCase();
const siteVariant =
onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'onboarding'
? 'onboarding'
: 'marketing';
export async function load({ url, cookies }) {
const { surface, isPreview } = resolveSurface(url, cookies);
const siteVariant = surface === 'clients' ? 'onboarding' : 'marketing';
if (siteVariant === 'onboarding') {
return {
siteVariant,
isPreview: url.searchParams.get('preview') === 'onboarding'
isPreview,
};
}
return {
siteVariant,
content: await getHomepageContent()
content: await getHomepageContent(),
};
}
+1 -1
View File
@@ -14,7 +14,7 @@ describe('static slug route page', () => {
['pack-walks', packWalksContent.hero.title],
['dog-walking', dogWalkingContent.hero.title],
['puppy-visits', puppyVisitsContent.hero.title],
['our-pricing', ourPricingContent.subtitle],
['our-pricing', ourPricingContent.subtitle ?? ourPricingContent.title],
['about', aboutPageContent.sections[0].title],
['testimonials', 'Client Testimonials'],
['contact-us', "Let's meet!"],
+5 -10
View File
@@ -1,17 +1,12 @@
import { error } from '@sveltejs/kit';
import { resolveSurface } from '$lib/server/surface';
const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']);
export const load = async ({ url, cookies }) => {
const { surface, isPreview } = resolveSurface(url, cookies);
export const load = async ({ url }) => {
const hostname = url.hostname.toLowerCase();
const isOnboardingHost =
onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'contract';
if (!isOnboardingHost) {
if (surface !== 'clients') {
throw error(404, 'Not found');
}
return {
isPreview: url.searchParams.get('preview') === 'contract'
};
return { isPreview };
};
+19 -3
View File
@@ -12,9 +12,16 @@ vi.mock('$lib/server/content', () => ({
import { load } from './+page.server';
function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
// The loader now also receives `cookies`; resolveSurface only touches it
// when ?preview is involved, which these tests don't exercise.
return {
url: new URL(url)
} as Parameters<typeof load>[0];
url: new URL(url),
cookies: {
get: () => undefined,
set: () => {},
delete: () => {}
}
} as unknown as Parameters<typeof load>[0];
}
describe('home page server load', () => {
@@ -27,7 +34,16 @@ describe('home page server load', () => {
});
});
it('returns the onboarding variant on the onboarding host', async () => {
it('returns the onboarding variant on the clients host', async () => {
getHomepageContent.mockResolvedValue(homepageContent);
await expect(load(createLoadEvent('https://clients.goodwalk.co.nz/'))).resolves.toEqual({
siteVariant: 'onboarding',
isPreview: false
});
});
it('still returns the onboarding variant on the legacy onboarding host', async () => {
getHomepageContent.mockResolvedValue(homepageContent);
await expect(load(createLoadEvent('https://onboarding.goodwalk.co.nz/'))).resolves.toEqual({
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB