From 541ae2eeec392cff4e54da88a324c7f91c565a66 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 18 May 2026 22:25:43 +1200 Subject: [PATCH] v4.1 - Admin/onboarding --- .claude/settings.local.json | 5 +- DEPLOYMENT.md | 57 +- Dockerfile | 5 +- deploy.env.template | 4 +- deploy.ps1 | 21 +- docker-compose.prod.yml | 19 +- docker-compose.yml | 13 +- docker/postgres/init/002-admin-kv.sql | 12 + logs/mail-api.log | 36 + mail-api/Dockerfile | 2 +- mail-api/__pycache__/db.cpython-314.pyc | Bin 0 -> 8668 bytes mail-api/__pycache__/main.cpython-314.pyc | Bin 126428 -> 211108 bytes mail-api/data/allowed_emails.json | 6 + mail-api/data/client_profiles.json | 21 +- mail-api/data/drafts.json | 57 + mail-api/db.py | 138 + mail-api/logs/mail-api.log | 1062 +++++++ mail-api/main.py | 1545 ++++++++- mail-api/requirements.txt | 1 + marketing-voice-v2.md | 422 +++ marketing-voice.md | 197 ++ nginx/goodwalk.co.nz.svelte.conf.example | 85 + package-lock.json | 8 +- package.json | 4 +- scripts/deploy-remote.sh | 12 + scripts/optimize-images.mjs | 6 + src/hooks.server.ts | 33 + src/lib/components/AboutPage.svelte | 4 +- src/lib/components/BookingPage.svelte | 11 +- src/lib/components/BookingSection.svelte | 6 +- src/lib/components/BookingWizard.svelte | 1110 +++++++ src/lib/components/ContractPage.svelte | 24 +- src/lib/components/CtaCard.svelte | 27 +- src/lib/components/FounderStorySection.svelte | 9 +- src/lib/components/InfoSection.svelte | 4 +- src/lib/components/InstagramSection.svelte | 2 +- src/lib/components/LocationPage.svelte | 8 +- src/lib/components/OnboardingAuth.svelte | 13 +- src/lib/components/OnboardingFooter.svelte | 27 +- src/lib/components/OnboardingPage.svelte | 1861 ++++++++--- src/lib/components/OnboardingPage.test.ts | 89 + src/lib/components/PricingPage.svelte | 4 +- src/lib/components/PricingPlanCard.svelte | 48 +- src/lib/components/ServiceAreaMap.svelte | 929 ++++++ src/lib/components/ServiceHero.svelte | 13 + src/lib/components/ServiceLandingPage.svelte | 962 +++++- src/lib/components/ServicesSection.svelte | 2 +- src/lib/components/TestimonialsPage.svelte | 4 +- src/lib/components/ValuesSection.svelte | 4 +- .../admin-dashboard/AdminDashboard.svelte | 2776 +++++++++++++++++ .../VariantApplicationFields.svelte | 2 +- src/lib/content/about.ts | 28 +- src/lib/content/dog-walking.ts | 132 +- src/lib/content/homepage.ts | 86 +- src/lib/content/locations.ts | 152 +- src/lib/content/our-pricing.ts | 8 +- src/lib/content/pack-walks.ts | 114 +- src/lib/content/puppy-visits.ts | 101 +- src/lib/content/services.ts | 8 +- src/lib/content/static-pages.ts | 8 +- src/lib/enhanced-images.ts | 11 +- src/lib/seo.ts | 2 +- src/lib/styles/buttons.css | 2 +- src/lib/styles/forms.css | 12 +- src/lib/styles/layout.css | 10 +- src/lib/styles/responsive.css | 17 + src/lib/styles/variables.css | 8 +- src/lib/types.ts | 15 + src/routes/+page.svelte | 49 +- src/routes/[slug]/+page.svelte | 28 +- src/routes/locations/+page.svelte | 4 +- src/routes/locations/[suburb]/+page.server.ts | 10 +- src/routes/meet-greet-v2/+page.svelte | 2 +- src/routes/owner/welcome/+page.svelte | 5 + src/routes/robots.txt/+server.ts | 2 + src/routes/variants-contact-form/+page.svelte | 3 +- static/images/goodwalk-sky-tower.webp | Bin 0 -> 39390 bytes static/llms.txt | 20 +- vite.config.ts | 4 + 79 files changed, 11544 insertions(+), 1007 deletions(-) create mode 100644 docker/postgres/init/002-admin-kv.sql create mode 100644 logs/mail-api.log create mode 100644 mail-api/__pycache__/db.cpython-314.pyc create mode 100644 mail-api/data/allowed_emails.json create mode 100644 mail-api/data/drafts.json create mode 100644 mail-api/db.py create mode 100644 marketing-voice-v2.md create mode 100644 marketing-voice.md create mode 100644 src/hooks.server.ts create mode 100644 src/lib/components/BookingWizard.svelte create mode 100644 src/lib/components/OnboardingPage.test.ts create mode 100644 src/lib/components/ServiceAreaMap.svelte create mode 100644 src/lib/components/admin-dashboard/AdminDashboard.svelte create mode 100644 src/routes/owner/welcome/+page.svelte create mode 100644 static/images/goodwalk-sky-tower.webp diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d382b84..168dde5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,10 @@ "Bash(kill %1)", "Bash(pkill -f \"vite dev\")", "Bash(npm run *)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(node \"C:\\\\Users\\\\mattc\\\\.claude\\\\plugins\\\\cache\\\\impeccable\\\\impeccable\\\\3.1.1\\\\skills\\\\impeccable\\\\scripts\\\\load-context.mjs\")", + "Bash(cat \"C:\\\\Users\\\\mattc\\\\.claude\\\\plugins\\\\cache\\\\impeccable\\\\impeccable\\\\3.1.1\\\\skills\\\\impeccable\\\\reference\\\\distill.md\" 2>&1 | head -100)", + "Read(//c/Users/mattc/.claude/plugins/cache/impeccable/impeccable/3.1.1/skills/impeccable/reference/**)" ] } } diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4b316da..79f8402 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,5 +1,45 @@ # Deployment +## Hosts served by this stack + +The Goodwalk Svelte stack serves three subdomains from the same SvelteKit app +container, routed by Host header at nginx: + +| Hostname | Purpose | +|-----------------------------|--------------------------------------------| +| `goodwalk.co.nz` / `www.…` | Public marketing site | +| `onboarding.goodwalk.co.nz` | New-client onboarding flow | +| `admin.goodwalk.co.nz` | Owner admin dashboard (Aless only) | + +The admin host needs its own TLS certificate at +`/etc/letsencrypt/live/admin.goodwalk.co.nz/`. Issue it once before the first +nginx reload, e.g.: + +```bash +docker compose -p nginx -f /docker/nginx/docker-compose.yml exec nginx \ + certbot certonly --webroot -w /var/www/certbot -d admin.goodwalk.co.nz +``` + +The dashboard's data (`client_profiles`, `allowed_emails`, `drafts`) lives in +the shared postgres database alongside the marketing site content, in a single +`admin_kv` table created by `docker/postgres/init/002-admin-kv.sql`. The +mail-api connects with the same `DATABASE_URL` the SvelteKit app uses. + +### Seeding admin_kv from the old JSON files + +Existing installs have admin data in `client_profiles.json`, +`allowed_emails.json`, and `drafts.json` on the mail-api Docker volume. To copy +that data into postgres on the next deploy, run: + +```powershell +./deploy.ps1 -SeedAdminData +``` + +That sets `ADMIN_DATA_SEED_FROM_JSON=force` for the mail-api container, which +overwrites `admin_kv` from the JSON files on the next boot. Subsequent deploys +default back to `auto` (seed only when `admin_kv` is empty), so they are no-ops +for the seed. Use `-SeedAdminData` again if you ever need to force a re-seed. + ## Server layout confirmed The production server currently runs multiple separate Docker Compose projects: @@ -73,9 +113,10 @@ mkdir -p /docker/goodwalk-svelte It is created from [deploy.env.template](deploy.env.template). Current template contents: ```env -APP_VERSION=4.2.3 +APP_VERSION=4.0.0 ENABLE_GENERAL_ENQUIRIES=false PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false +PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false TZ=Pacific/Auckland POSTGRES_DB=goodwalk @@ -87,6 +128,7 @@ RESEND_API_KEY=replace-me OWNER_EMAIL=replace-me FROM_EMAIL=GoodWalk REPLY_TO=aless@goodwalk.co.nz +MAIL_API_DATA_DIR=/app/data FORM_MIN_SECONDS=4 FORM_MAX_SECONDS=7200 @@ -105,6 +147,7 @@ Frontend flags: - `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false` keeps the sticky mobile booking CTA hidden. - Set `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=true` to show it again. +- `PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false` skips eager `@sveltejs/enhanced-img` processing for content images during production builds. Turn it on only if you intentionally want non-WebP images from `src/lib/images` to go through the imagetools pipeline. 4. Confirm the shared Docker network already exists: @@ -218,6 +261,18 @@ The deployment flow now handles that automatically: This means future deploys will carry your latest file-based homepage/navigation/ shared content changes into production PostgreSQL automatically. +## Mail auth persistence + +The mail API stores auth state in `DATA_DIR`, including: + +- `allowed_emails.json` +- `client_profiles.json` +- `drafts.json` + +Both compose files now mount a named Docker volume at `MAIL_API_DATA_DIR` +(default `/app/data`) so previously registered client emails and saved drafts +survive container rebuilds and redeploys. + ## Cutover nginx After the new Svelte stack is up and healthy, update the shared nginx config on diff --git a/Dockerfile b/Dockerfile index 67a757b..444a149 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG APP_VERSION=4.2.3 +ARG APP_VERSION=4.0.0 FROM node:22-alpine AS builder ARG APP_VERSION @@ -6,7 +6,8 @@ ARG APP_VERSION WORKDIR /app COPY package.json ./ -RUN npm install +COPY package-lock.json ./ +RUN npm ci COPY . . RUN node --experimental-strip-types --import="file:///app/scripts/sveltekit-resolver.mjs" scripts/export-homepage-content.mjs diff --git a/deploy.env.template b/deploy.env.template index cf27aa7..261b091 100644 --- a/deploy.env.template +++ b/deploy.env.template @@ -1,4 +1,4 @@ -APP_VERSION=4.2.3 +APP_VERSION=4.0.0 TZ=Pacific/Auckland POSTGRES_DB=goodwalk @@ -12,8 +12,10 @@ OWNER_BCC=mattcohen0@gmail.com CLIENT_BCC=mattcohen0@gmail.com FROM_EMAIL=GoodWalk REPLY_TO=info@goodwalk.co.nz +MAIL_API_DATA_DIR=/app/data ENABLE_GENERAL_ENQUIRIES=false PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false +PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false FORM_MIN_SECONDS=4 FORM_MAX_SECONDS=7200 diff --git a/deploy.ps1 b/deploy.ps1 index c1ead3a..8f09b4f 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -2,7 +2,13 @@ param( [switch]$Force, [switch]$SkipSiteCheck, - [string]$Service + [string]$Service, + # When set, the next mail-api boot copies admin state (client_profiles, + # allowed_emails, drafts) from the on-disk JSON files into the shared + # postgres database, overwriting anything currently in admin_kv. After the + # deploy completes the flag is automatically reset to 'auto' for subsequent + # boots so we don't keep overwriting live data. + [switch]$SeedAdminData ) # --------------------------------------------------------------------------- @@ -242,6 +248,9 @@ Write-Host "[deploy] SSH config: $SshConfigPath" if (-not [string]::IsNullOrWhiteSpace($Service)) { Write-Host "[deploy] Target service: $Service" } +if ($SeedAdminData) { + Write-Host '[deploy] Admin data: seeding postgres from JSON on next mail-api boot' +} if ([string]::IsNullOrWhiteSpace($SshKeyPath)) { Write-Host '[deploy] SSH auth: interactive password prompt' } else { @@ -254,6 +263,13 @@ Write-Host ' - Legacy WordPress/onboarding compose files are not used.' Write-Host ' - Remote .env files are preserved because they are not uploaded.' Write-Host ' - No global Docker prune/stop/delete commands are used.' Write-Host ' - Shared nginx will be updated and reloaded with the Docker-DNS-based config.' +Write-Host ' - Subdomains served by this stack:' +Write-Host ' goodwalk.co.nz / www.goodwalk.co.nz (marketing)' +Write-Host ' onboarding.goodwalk.co.nz (client onboarding)' +Write-Host ' admin.goodwalk.co.nz (owner admin dashboard)' +if ($SeedAdminData) { + Write-Host ' - Admin data seed: mail-api will OVERWRITE postgres admin_kv from the JSON volume.' +} if (-not $Force) { $confirmation = Read-Host "Type DEPLOY to continue with the remote path '$RemoteDeploymentPath'" @@ -310,7 +326,8 @@ try { $MaintenanceHostDir, '--maintenance-flag', $MaintenanceFlagPath - ) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() })) + ) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }) ` + + $(if ($SeedAdminData) { @('--seed-admin-data') } else { @() })) Write-Host '' Write-Host '[deploy] Cleaning remote temporary files' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1541720..d98c476 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,15 +3,16 @@ services: build: context: . args: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} container_name: goodwalk_svelte_app environment: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} NODE_ENV: production PORT: 3000 ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false} + PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES: ${PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES:-false} TZ: ${TZ:-Pacific/Auckland} depends_on: - db @@ -26,10 +27,12 @@ services: build: context: ./mail-api args: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} container_name: goodwalk_svelte_mail_api + depends_on: + - db environment: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} RESEND_API_KEY: ${RESEND_API_KEY} OWNER_EMAIL: ${OWNER_EMAIL} OWNER_BCC: ${OWNER_BCC:-} @@ -37,6 +40,11 @@ services: FROM_EMAIL: ${FROM_EMAIL:-GoodWalk } REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz} ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} + DATA_DIR: ${MAIL_API_DATA_DIR:-/app/data} + # Shared postgres for admin state (client_profiles, allowed_emails, drafts). + # When unset the mail-api falls back to JSON files under DATA_DIR. + DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} + ADMIN_DATA_SEED_FROM_JSON: ${ADMIN_DATA_SEED_FROM_JSON:-auto} FORM_MIN_SECONDS: ${FORM_MIN_SECONDS:-4} FORM_MAX_SECONDS: ${FORM_MAX_SECONDS:-7200} RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-900} @@ -47,6 +55,8 @@ services: TZ: ${TZ:-Pacific/Auckland} expose: - '8000' + volumes: + - mail_api_data:${MAIL_API_DATA_DIR:-/app/data} restart: unless-stopped networks: - default @@ -68,6 +78,7 @@ services: volumes: postgres_data: + mail_api_data: networks: webnet: diff --git a/docker-compose.yml b/docker-compose.yml index f0714b9..6d84ad0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,15 @@ services: build: context: . args: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} environment: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk} NODE_ENV: production PORT: ${APP_PORT:-3000} ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false} + PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES: ${PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES:-false} TZ: ${TZ:-Pacific/Auckland} depends_on: - db @@ -20,9 +21,9 @@ services: build: context: ./mail-api args: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} environment: - APP_VERSION: ${APP_VERSION:-4.2.3} + APP_VERSION: ${APP_VERSION:-4.0.0} RESEND_API_KEY: ${RESEND_API_KEY} OWNER_EMAIL: ${OWNER_EMAIL} OWNER_BCC: ${OWNER_BCC:-} @@ -30,7 +31,10 @@ services: FROM_EMAIL: ${FROM_EMAIL:-GoodWalk } REPLY_TO: ${REPLY_TO:-info@goodwalk.co.nz} ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false} + DATA_DIR: ${MAIL_API_DATA_DIR:-/app/data} TZ: ${TZ:-Pacific/Auckland} + volumes: + - mail_api_data:${MAIL_API_DATA_DIR:-/app/data} restart: unless-stopped db: @@ -60,3 +64,4 @@ services: volumes: postgres_data: + mail_api_data: diff --git a/docker/postgres/init/002-admin-kv.sql b/docker/postgres/init/002-admin-kv.sql new file mode 100644 index 0000000..68c8bfa --- /dev/null +++ b/docker/postgres/init/002-admin-kv.sql @@ -0,0 +1,12 @@ +-- Key/value table used by mail-api for admin state. +-- Three keys are written from main.py: +-- client_profiles -> dict[email, profile] +-- allowed_emails -> {"emails": [..]} +-- drafts -> dict[email, drafts] +-- Stored as JSONB blobs to match the existing dict-shaped storage in the +-- application and to avoid coupling the DB schema to mail-api shape changes. +create table if not exists admin_kv ( + key text primary key, + value jsonb not null, + updated_at timestamptz not null default now() +); diff --git a/logs/mail-api.log b/logs/mail-api.log new file mode 100644 index 0000000..ef8ce9b --- /dev/null +++ b/logs/mail-api.log @@ -0,0 +1,36 @@ +18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s) +18/05/2026 19:34:38 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:34:41 New Zealand Standard Time INFO mail-api: [ab4f9aaf] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 19:34:41 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 625809 +18/05/2026 19:34:41 New Zealand Standard Time INFO mail-api: [ab4f9aaf] POST /auth/request-code → 200 (3ms) +18/05/2026 19:34:47 New Zealand Standard Time INFO mail-api: [a5564b23] auth: session created for email=info@goodwalk.co.nz +18/05/2026 19:34:47 New Zealand Standard Time INFO mail-api: [a5564b23] POST /auth/verify-code → 200 (1ms) +18/05/2026 19:34:47 New Zealand Standard Time INFO mail-api: [f0a2b1fe] GET /auth/verify → 200 (0ms) +18/05/2026 19:35:42 New Zealand Standard Time INFO mail-api: [c3a5a829] GET /auth/verify → 200 (0ms) +18/05/2026 19:35:42 New Zealand Standard Time INFO mail-api: [0bd54996] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 19:35:43 New Zealand Standard Time INFO mail-api: [ce90270c] GET /auth/verify → 200 (1ms) +18/05/2026 19:35:43 New Zealand Standard Time INFO mail-api: [d3d6a292] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:35:52 New Zealand Standard Time INFO mail-api: [758551a5] POST /auth/logout → 200 (0ms) +18/05/2026 19:35:55 New Zealand Standard Time INFO mail-api: [901ce77f] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 19:35:55 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 166556 +18/05/2026 19:35:55 New Zealand Standard Time INFO mail-api: [901ce77f] POST /auth/request-code → 200 (1ms) +18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [0f092606] auth: session created for email=info@goodwalk.co.nz +18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [0f092606] POST /auth/verify-code → 200 (1ms) +18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [2c504fc8] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:04 New Zealand Standard Time INFO mail-api: [5f742030] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:05 New Zealand Standard Time INFO mail-api: [01546195] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:05 New Zealand Standard Time INFO mail-api: [6c10de94] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:20 New Zealand Standard Time INFO mail-api: [8740c583] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:20 New Zealand Standard Time INFO mail-api: [d8025471] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:21 New Zealand Standard Time INFO mail-api: [c45f3685] GET /auth/verify → 200 (1ms) +18/05/2026 19:36:21 New Zealand Standard Time INFO mail-api: [2f8d224d] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:33 New Zealand Standard Time INFO mail-api: [48041b64] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:33 New Zealand Standard Time INFO mail-api: [d3946240] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:34 New Zealand Standard Time INFO mail-api: [41be2531] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:34 New Zealand Standard Time INFO mail-api: [02357a33] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [b579012f] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [14ac39b2] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [01acd4c8] GET /auth/verify → 200 (0ms) +18/05/2026 19:36:50 New Zealand Standard Time INFO mail-api: [9a3b3304] GET /owner/pending-onboarding → 200 (0ms) diff --git a/mail-api/Dockerfile b/mail-api/Dockerfile index 97c803c..6f225d5 100644 --- a/mail-api/Dockerfile +++ b/mail-api/Dockerfile @@ -1,4 +1,4 @@ -ARG APP_VERSION=4.2.3 +ARG APP_VERSION=4.0.0 FROM python:3.12-slim ARG APP_VERSION diff --git a/mail-api/__pycache__/db.cpython-314.pyc b/mail-api/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d702c20f7734d9b6b1f98a3b70d82dc02b9313ad GIT binary patch literal 8668 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 literal 0 HcmV?d00001 diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc index 6daaf46ddd21d047a1308194ed12f1a5d3deae8e..acbfe4d808145dbf199f2a8dded449bed61fe2b8 100644 GIT binary patch literal 211108 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

F>#ydGVnzShI*C3|K!`mf`<3 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 diff --git a/mail-api/data/allowed_emails.json b/mail-api/data/allowed_emails.json new file mode 100644 index 0000000..560a0e2 --- /dev/null +++ b/mail-api/data/allowed_emails.json @@ -0,0 +1,6 @@ +{ + "emails": [ + "mattcohen0@gmail.com", + "test@test.com" + ] +} \ No newline at end of file diff --git a/mail-api/data/client_profiles.json b/mail-api/data/client_profiles.json index 641f5fa..978b6f6 100644 --- a/mail-api/data/client_profiles.json +++ b/mail-api/data/client_profiles.json @@ -2,11 +2,28 @@ "mattcohen0@gmail.com": { "fullName": "Matt Test", "phone": "02124347477", - "dogName": "Geoffrey" + "dogName": "Geoffrey", + "welcomePackSentAt": "2026-05-18T20:37:14", + "welcomePackOffer": { + "serviceType": "test", + "priceDetails": "45", + "startDate": "2026-05-18", + "sentAt": "2026-05-18T20:37:14" + } }, "natalie@desseinparke.com": { - "fullName":"Natalie Parke", + "fullName": "Natalie Parke", "phone": "021616200", "dogName": "Ziggy" + }, + "test@test.com": { + "fullName": "test test", + "phone": "test@test.com", + "address": "test@test.com", + "dogName": "X", + "dogBreed": "H", + "dogAge": "2026-05-18", + "onboardingCompleted": true, + "birthdayAutoSend": false } } \ No newline at end of file diff --git a/mail-api/data/drafts.json b/mail-api/data/drafts.json new file mode 100644 index 0000000..35d3d1f --- /dev/null +++ b/mail-api/data/drafts.json @@ -0,0 +1,57 @@ +{ + "mattcohen0@gmail.com": { + "onboarding": { + "currentStep": 5, + "ownerFirstName": "test", + "ownerLastName": "test", + "email": "test@test.com", + "phone": "test@test.com", + "address": "test@test.com", + "dogName": "test@test.com", + "dogLastName": "test@test.com", + "dogBreed": "test@test.com", + "dogDateOfBirth": "2026-05-18", + "servicesNeeded": [ + "Tiny Gang Pack Walks" + ], + "temperament": "test", + "accessInstructions": "01", + "vetName": "test", + "vetAddress": "test", + "vetPhone": "test", + "emergencyContactName": "test", + "emergencyContactPhone": "test", + "isVaccinated": "no", + "hasFoodAllergies": "no", + "foodAllergiesDetail": "", + "hasEnvAllergies": "no", + "envAllergiesDetail": "", + "onSpecialDiet": "no", + "specialDietDetail": "", + "onMedication": "no", + "medicationDetail": "", + "wellSocialised": "", + "dogsInteractedWeekly": "", + "visitsBeach": "yes", + "visitsDogParks": "no", + "dogParksFrequency": "", + "biteHistory": "no", + "reactiveToDogs": "no", + "reactiveToAnimals": "no", + "reactiveToChildren": "no", + "reactiveToPeople": "no", + "isDesexed": "no", + "isRegistered": "yes", + "leashTrained": "yes", + "recallRating": 5, + "ranAwayBefore": "yes", + "carBehaviour": "test", + "knownCommands": "test", + "additionalNotes": "", + "socialMediaAccount": "", + "howDidYouHear": "", + "emergencyVetConsent": false, + "termsAccepted": false + } + } +} \ No newline at end of file diff --git a/mail-api/db.py b/mail-api/db.py new file mode 100644 index 0000000..ada2a44 --- /dev/null +++ b/mail-api/db.py @@ -0,0 +1,138 @@ +"""Postgres-backed key/value persistence for mail-api admin state. + +The mail-api historically stored client_profiles / allowed_emails / drafts +as JSON files on a Docker volume. This module lets the same data live in +the shared Goodwalk postgres database so the admin dashboard at +admin.goodwalk.co.nz reads from a real database instead of a per-container +JSON file. JSON files remain as a development/local fallback and as the +seed source for the initial postgres migration. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any + +try: + import asyncpg +except Exception: # pragma: no cover - asyncpg is optional in dev + asyncpg = None # type: ignore[assignment] + + +logger = logging.getLogger("mail-api.db") + +_pool: Any = None +_pool_lock = asyncio.Lock() +_schema_lock = asyncio.Lock() +_schema_ensured = False + + +def database_url() -> str: + return (os.environ.get("DATABASE_URL", "") or "").strip() + + +def is_enabled() -> bool: + return bool(database_url()) and asyncpg is not None + + +async def get_pool() -> Any: + """Return a lazily-initialised asyncpg pool, or None when DB is disabled.""" + global _pool + if not is_enabled(): + return None + if _pool is not None: + return _pool + async with _pool_lock: + if _pool is None: + try: + _pool = await asyncpg.create_pool( + dsn=database_url(), + min_size=1, + max_size=4, + command_timeout=10, + ) + logger.info("Postgres pool ready for admin_kv persistence") + except Exception as exc: + logger.warning("Postgres pool init failed (%s); falling back to JSON only", exc) + return None + return _pool + + +async def _ensure_schema() -> None: + global _schema_ensured + if _schema_ensured: + return + pool = await get_pool() + if pool is None: + return + async with _schema_lock: + if _schema_ensured: + return + async with pool.acquire() as conn: + await conn.execute( + """ + create table if not exists admin_kv ( + key text primary key, + value jsonb not null, + updated_at timestamptz not null default now() + ); + """ + ) + _schema_ensured = True + + +async def get_kv(key: str) -> Any | None: + pool = await get_pool() + if pool is None: + return None + await _ensure_schema() + async with pool.acquire() as conn: + row = await conn.fetchrow("select value from admin_kv where key = $1", key) + if not row: + return None + raw = row["value"] + # asyncpg returns jsonb as a Python str; parse to native value. + if isinstance(raw, (dict, list)): + return raw + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + try: + return json.loads(raw) + except Exception: + return None + + +async def set_kv(key: str, value: Any) -> bool: + pool = await get_pool() + if pool is None: + return False + await _ensure_schema() + payload = json.dumps(value) + async with pool.acquire() as conn: + await conn.execute( + """ + insert into admin_kv (key, value, updated_at) + values ($1, $2::jsonb, now()) + on conflict (key) do update + set value = excluded.value, + updated_at = excluded.updated_at + """, + key, + payload, + ) + return True + + +async def has_any_value() -> bool: + """Return True if admin_kv already has any rows. Used to decide whether + to seed from JSON files on first boot.""" + pool = await get_pool() + if pool is None: + return False + await _ensure_schema() + async with pool.acquire() as conn: + row = await conn.fetchrow("select 1 from admin_kv limit 1") + return row is not None diff --git a/mail-api/logs/mail-api.log b/mail-api/logs/mail-api.log index a94fe1c..3ee8fe6 100644 --- a/mail-api/logs/mail-api.log +++ b/mail-api/logs/mail-api.log @@ -311,3 +311,1065 @@ resend.exceptions.ResendError: API key is invalid 11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) 11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address 11/05/2026 19:05:47 New Zealand Standard Time INFO mail-api: [0c1cdd9c] GET /auth/verify → 401 (2ms) +18/05/2026 18:10:16 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 18:10:16 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' 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 18:10:16 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +18/05/2026 18:10:17 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 18:10:23 New Zealand Standard Time INFO mail-api: [a722f69c] auth: unknown email=youremail@example.com ip=127.0.0.1 +18/05/2026 18:10:23 New Zealand Standard Time WARNING mail-api: [a722f69c] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=1 +18/05/2026 18:10:23 New Zealand Standard Time INFO mail-api: [a722f69c] POST /auth/request-code → 403 (3ms) +18/05/2026 18:11:30 New Zealand Standard Time INFO mail-api: [eceec1d6] auth: unknown email=mattcohen0@gmail.com ip=127.0.0.1 +18/05/2026 18:11:30 New Zealand Standard Time WARNING mail-api: [eceec1d6] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=2 +18/05/2026 18:11:30 New Zealand Standard Time INFO mail-api: [eceec1d6] POST /auth/request-code → 403 (1ms) +18/05/2026 18:11:33 New Zealand Standard Time INFO mail-api: [25a59a91] auth: unknown email=mattcohen0@gmail.com ip=127.0.0.1 +18/05/2026 18:11:33 New Zealand Standard Time WARNING mail-api: [25a59a91] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=3 +18/05/2026 18:11:33 New Zealand Standard Time INFO mail-api: [25a59a91] POST /auth/request-code → 403 (1ms) +18/05/2026 18:11:46 New Zealand Standard Time INFO mail-api: [5a2fd3f5] auth: unknown email=mattcohen0@gmail.com ip=127.0.0.1 +18/05/2026 18:11:46 New Zealand Standard Time WARNING mail-api: [5a2fd3f5] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=4 +18/05/2026 18:11:46 New Zealand Standard Time INFO mail-api: [5a2fd3f5] POST /auth/request-code → 403 (1ms) +18/05/2026 18:12:34 New Zealand Standard Time INFO mail-api: [9fc29467] auth: unknown email=mattcohen0@gmail.com ip=127.0.0.1 +18/05/2026 18:12:34 New Zealand Standard Time WARNING mail-api: [9fc29467] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=5 +18/05/2026 18:12:34 New Zealand Standard Time INFO mail-api: [9fc29467] POST /auth/request-code → 403 (1ms) +18/05/2026 18:12:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 18:12:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' 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 18:12:50 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +18/05/2026 18:12:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 18:12:55 New Zealand Standard Time INFO mail-api: [bf6a5611] auth: code issued for email=mattcohen0@gmail.com +18/05/2026 18:12:55 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 944970 +18/05/2026 18:12:55 New Zealand Standard Time INFO mail-api: [bf6a5611] POST /auth/request-code → 200 (2ms) +18/05/2026 18:13:06 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 18:13:06 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' 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 18:13:06 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +18/05/2026 18:13:06 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 18:13:10 New Zealand Standard Time INFO mail-api: [d5cbd77b] auth: code issued for email=mattcohen0@gmail.com +18/05/2026 18:13:10 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 522462 +18/05/2026 18:13:10 New Zealand Standard Time INFO mail-api: [d5cbd77b] POST /auth/request-code → 200 (3ms) +18/05/2026 18:13:16 New Zealand Standard Time INFO mail-api: [5fb29fef] auth: session created for email=mattcohen0@gmail.com +18/05/2026 18:13:16 New Zealand Standard Time INFO mail-api: [5fb29fef] POST /auth/verify-code → 200 (1ms) +18/05/2026 18:13:16 New Zealand Standard Time INFO mail-api: [39077aca] GET /auth/verify → 200 (1ms) +18/05/2026 18:13:35 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:13:35 New Zealand Standard Time INFO mail-api: [8b22ffe6] POST /auth/save-draft → 200 (19ms) +18/05/2026 18:13:49 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:13:49 New Zealand Standard Time INFO mail-api: [5f0be82f] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:16:38 New Zealand Standard Time INFO mail-api: [09917fdc] GET /auth/verify → 200 (0ms) +18/05/2026 18:16:56 New Zealand Standard Time INFO mail-api: [063fcd25] GET /auth/verify → 200 (0ms) +18/05/2026 18:17:03 New Zealand Standard Time INFO mail-api: [4f96fba8] GET /auth/verify → 200 (0ms) +18/05/2026 18:17:27 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:17:27 New Zealand Standard Time INFO mail-api: [4eb7a80e] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:17:49 New Zealand Standard Time INFO mail-api: [6addb8a3] GET /auth/verify → 200 (0ms) +18/05/2026 18:18:17 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:18:17 New Zealand Standard Time INFO mail-api: [8462021e] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:18:24 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:18:24 New Zealand Standard Time INFO mail-api: [89186333] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:18:28 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:18:28 New Zealand Standard Time INFO mail-api: [81ca8bbb] POST /auth/save-draft → 200 (3ms) +18/05/2026 18:18:46 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:18:46 New Zealand Standard Time INFO mail-api: [90709817] POST /auth/save-draft → 200 (3ms) +18/05/2026 18:18:46 New Zealand Standard Time INFO mail-api: [3723e050] GET /auth/verify → 200 (1ms) +18/05/2026 18:18:50 New Zealand Standard Time INFO mail-api: [fc02a942] GET /auth/verify → 200 (0ms) +18/05/2026 18:18:55 New Zealand Standard Time INFO mail-api: [03f4a037] GET /auth/verify → 200 (0ms) +18/05/2026 18:18:56 New Zealand Standard Time INFO mail-api: [aa72ec66] GET /auth/verify → 200 (1ms) +18/05/2026 18:18:57 New Zealand Standard Time INFO mail-api: [4477b6e8] GET /auth/verify → 200 (0ms) +18/05/2026 18:18:58 New Zealand Standard Time INFO mail-api: [be26a002] GET /auth/verify → 200 (0ms) +18/05/2026 18:19:08 New Zealand Standard Time INFO mail-api: [86edee75] GET /auth/verify → 200 (0ms) +18/05/2026 18:19:16 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:19:16 New Zealand Standard Time INFO mail-api: [e171eb1f] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:19:23 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:19:23 New Zealand Standard Time INFO mail-api: [6d4b7d1e] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:19:25 New Zealand Standard Time INFO mail-api: [c5f9a0f0] GET /auth/verify → 200 (0ms) +18/05/2026 18:19:33 New Zealand Standard Time INFO mail-api: [b7110aeb] GET /auth/verify → 200 (1ms) +18/05/2026 18:19:39 New Zealand Standard Time INFO mail-api: [5fb6906b] GET /auth/verify → 200 (0ms) +18/05/2026 18:19:56 New Zealand Standard Time INFO mail-api: [27455aa6] GET /auth/verify → 200 (0ms) +18/05/2026 18:20:08 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:20:08 New Zealand Standard Time INFO mail-api: [391e8933] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:20:24 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:20:24 New Zealand Standard Time INFO mail-api: [873f3490] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:20:28 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:20:28 New Zealand Standard Time INFO mail-api: [e531455f] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:21:08 New Zealand Standard Time INFO mail-api: [b65d6485] GET /auth/verify → 200 (0ms) +18/05/2026 18:21:30 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:21:30 New Zealand Standard Time INFO mail-api: [bcaffc5c] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:21:33 New Zealand Standard Time INFO mail-api: [97367cc8] GET /auth/verify → 200 (0ms) +18/05/2026 18:21:50 New Zealand Standard Time INFO mail-api: [d6c5b625] GET /auth/verify → 200 (0ms) +18/05/2026 18:22:03 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:22:03 New Zealand Standard Time INFO mail-api: [a19b7bb8] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:23:05 New Zealand Standard Time INFO mail-api: [7a0dfd17] GET /auth/verify → 200 (1ms) +18/05/2026 18:23:21 New Zealand Standard Time INFO mail-api: [801d82eb] GET /auth/verify → 200 (0ms) +18/05/2026 18:24:41 New Zealand Standard Time INFO mail-api: [fdd7805a] GET /auth/verify → 200 (0ms) +18/05/2026 18:25:05 New Zealand Standard Time INFO mail-api: [8133f0cc] GET /auth/verify → 200 (1ms) +18/05/2026 18:25:53 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:25:53 New Zealand Standard Time INFO mail-api: [5dc41bc6] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:26:21 New Zealand Standard Time INFO mail-api: [7e812209] GET /auth/verify → 200 (0ms) +18/05/2026 18:26:34 New Zealand Standard Time INFO mail-api: [507a506d] GET /auth/verify → 200 (0ms) +18/05/2026 18:27:28 New Zealand Standard Time INFO mail-api: [a9ba6af5] GET /auth/verify → 200 (0ms) +18/05/2026 18:27:38 New Zealand Standard Time INFO mail-api: [b1be3fc7] GET /auth/verify → 200 (0ms) +18/05/2026 18:28:16 New Zealand Standard Time INFO mail-api: [e71fb20b] GET /auth/verify → 200 (0ms) +18/05/2026 18:28:30 New Zealand Standard Time INFO mail-api: [a3313030] GET /auth/verify → 200 (0ms) +18/05/2026 18:28:40 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:28:40 New Zealand Standard Time INFO mail-api: [30040ce7] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:29:23 New Zealand Standard Time INFO mail-api: [b60a4a96] GET /auth/verify → 200 (1ms) +18/05/2026 18:29:36 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:29:36 New Zealand Standard Time INFO mail-api: [ef2e3ce3] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:30:43 New Zealand Standard Time INFO mail-api: [3a1ed9dd] auth: code issued for email=mattcohen0@gmail.com +18/05/2026 18:30:43 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 212717 +18/05/2026 18:30:43 New Zealand Standard Time INFO mail-api: [3a1ed9dd] POST /auth/request-code → 200 (2ms) +18/05/2026 18:30:51 New Zealand Standard Time INFO mail-api: [6b9feb38] auth: session created for email=mattcohen0@gmail.com +18/05/2026 18:30:51 New Zealand Standard Time INFO mail-api: [6b9feb38] POST /auth/verify-code → 200 (1ms) +18/05/2026 18:30:51 New Zealand Standard Time INFO mail-api: [39e14ba5] GET /auth/verify → 200 (0ms) +18/05/2026 18:31:57 New Zealand Standard Time INFO mail-api: [8e0dcbe9] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:16 New Zealand Standard Time INFO mail-api: [ce74cce7] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:17 New Zealand Standard Time INFO mail-api: [9b77954b] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:18 New Zealand Standard Time INFO mail-api: [b793d5c2] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:25 New Zealand Standard Time INFO mail-api: [4c97ca8a] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:38 New Zealand Standard Time INFO mail-api: [dd70ec86] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:38 New Zealand Standard Time INFO mail-api: [77e9205f] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:40 New Zealand Standard Time INFO mail-api: [aff59453] GET /auth/verify → 200 (0ms) +18/05/2026 18:32:50 New Zealand Standard Time INFO mail-api: [63fccd68] GET /auth/verify → 200 (0ms) +18/05/2026 18:33:21 New Zealand Standard Time INFO mail-api: [63849a78] GET /auth/verify → 200 (0ms) +18/05/2026 18:34:33 New Zealand Standard Time INFO mail-api: [a1a198fe] GET /auth/verify → 200 (0ms) +18/05/2026 18:34:33 New Zealand Standard Time INFO mail-api: [e61da2ba] GET /auth/verify → 200 (1ms) +18/05/2026 18:35:02 New Zealand Standard Time INFO mail-api: [bd60fe64] GET /auth/verify → 200 (0ms) +18/05/2026 18:35:07 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:35:07 New Zealand Standard Time INFO mail-api: [216bbe4e] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:35:13 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:35:13 New Zealand Standard Time INFO mail-api: [0443ee14] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:36:01 New Zealand Standard Time INFO mail-api: [130f43cd] GET /auth/verify → 200 (0ms) +18/05/2026 18:36:02 New Zealand Standard Time INFO mail-api: [8c9abc2d] GET /auth/verify → 200 (0ms) +18/05/2026 18:36:08 New Zealand Standard Time INFO mail-api: [afcc7067] GET /auth/verify → 200 (0ms) +18/05/2026 18:36:21 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:36:21 New Zealand Standard Time INFO mail-api: [bf68961c] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:36:28 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:36:28 New Zealand Standard Time INFO mail-api: [b8d26deb] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:36:43 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:36:43 New Zealand Standard Time INFO mail-api: [d26bd5cc] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:37:36 New Zealand Standard Time INFO mail-api: [11527b70] GET /auth/verify → 200 (0ms) +18/05/2026 18:37:43 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:37:43 New Zealand Standard Time INFO mail-api: [4da1bba6] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:37:50 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:37:50 New Zealand Standard Time INFO mail-api: [30180895] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:37:56 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:37:56 New Zealand Standard Time INFO mail-api: [59c4d115] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:38:50 New Zealand Standard Time INFO mail-api: [94545ad6] GET /auth/verify → 200 (0ms) +18/05/2026 18:39:03 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:39:03 New Zealand Standard Time INFO mail-api: [32ab66e0] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:39:10 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:39:10 New Zealand Standard Time INFO mail-api: [9f31e354] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:39:17 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:39:17 New Zealand Standard Time INFO mail-api: [d2bc68c5] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:39:38 New Zealand Standard Time INFO mail-api: [76f45fa2] GET /auth/verify → 200 (0ms) +18/05/2026 18:39:49 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:39:49 New Zealand Standard Time INFO mail-api: [e2711cb2] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:39:55 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:39:55 New Zealand Standard Time INFO mail-api: [52329194] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:40:02 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:40:02 New Zealand Standard Time INFO mail-api: [eade2878] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:41:08 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:41:08 New Zealand Standard Time INFO mail-api: [5924d01f] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:41:44 New Zealand Standard Time INFO mail-api: [630dd2a2] GET /auth/verify → 200 (0ms) +18/05/2026 18:41:47 New Zealand Standard Time INFO mail-api: [9753a6f9] GET /auth/verify → 200 (0ms) +18/05/2026 18:41:48 New Zealand Standard Time INFO mail-api: [5354e738] GET /auth/verify → 200 (0ms) +18/05/2026 18:41:59 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:41:59 New Zealand Standard Time INFO mail-api: [13edbe5c] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:42:05 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:42:05 New Zealand Standard Time INFO mail-api: [0d74ed96] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:42:12 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:42:12 New Zealand Standard Time INFO mail-api: [a9f9e4ed] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:42:22 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:42:22 New Zealand Standard Time INFO mail-api: [f6be84cc] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:42:33 New Zealand Standard Time INFO mail-api: [4c3abc08] POST /onboarding-submit → 400 (10ms) +18/05/2026 18:42:38 New Zealand Standard Time INFO mail-api: [f14da50b] POST /onboarding-submit → 400 (1ms) +18/05/2026 18:42:45 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:42:45 New Zealand Standard Time INFO mail-api: [6f7c73d7] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:42:48 New Zealand Standard Time INFO mail-api: [80f37f96] POST /onboarding-submit → 400 (1ms) +18/05/2026 18:43:06 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:43:06 New Zealand Standard Time INFO mail-api: [0eb5152a] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:43:08 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:43:08 New Zealand Standard Time INFO mail-api: [e6519efa] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:43:10 New Zealand Standard Time INFO mail-api: [b9989ba8] /onboarding-submit: email=test@test.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=test services=['Tiny Gang Pack Walks'] page='http://10.0.0.124:5173/?preview=onboarding' +18/05/2026 18:43:10 New Zealand Standard Time DEBUG mail-api: [b9989ba8] onboarding payload: {'fullName': 'test test', 'email': 'test@test.com', 'phone': 'test', 'website': '', 'formStartedAt': 1779086505228, 'visitStartedAt': 1779084471544, 'pageEnteredAt': 1779086505228, 'firstInteractionAt': 1779086512339, 'sendClickedAt': 1779086588246, 'referrer': '', 'page': 'http://10.0.0.124:5173/?preview=onboarding', 'address': 'test', 'dogName': 'test', 'dogBreed': 'est', 'dogAge': '2026-06-03', 'servicesNeeded': ['Tiny Gang Pack Walks'], 'temperament': 'test', 'medicalNotes': 'test\ntest', 'accessInstructions': 'ts', 'vetName': 'test', 'vetPhone': 'test', 'emergencyContactName': 't', 'emergencyContactPhone': 'st', 'councilRegistrationConfirmed': True, 'vaccinationsConfirmed': True, 'emergencyVetConsent': True, 'termsAccepted': True, 'signatureDataUrl': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABCwAAADhCAYAAAADKyTpAAAgAElEQVR4Xu3dDZTdZX0n8P//zoQkvAhYEI1J5t4UeWmSmYEoCFrL9k2otj2LiOtS1HbpYXvoiqDVU7Gu51Cwu0WoVLd2S9e2uKuLSO3RI2zf1G3lRQQyk0RA2cydSRoLRl6EEMLM3P/+JmbGO0MCd8jMvc+995NzciAz//s8v+fzPEecb57n+eeZXwQIECBAgAABAgQIECBAgACBxATyxOpRDgECBAgQIECAAAECBAgQIEAgE1hYBAQIECBAgAABAgQIECBAgEByAgKL5KZEQQQIECBAgAABAgQIECBAgIDAwhogQIAAAQIECBAgQIAAAQIEkhMQWCQ3JQoiQIAAAQIECBAgQIAAAQIEBBbWAAECBAgQIECAAAECBAgQIJCcgMAiuSlREAECBAgQIECAAAECBAgQICCwsAYIECBAgAABAgQIECBAgACB5AQEFslNiYIIECBAgAABAgQIECBAgAABgYU1QIAAAQIECBAgQIAAAQIECCQnILBIbkoURIAAAQIECBAgQIAAAQIECAgsrAECBAgQIECAAAECBAgQIEAgOQGBRXJToiACBAgQIECAAAECBAgQIEBAYGENECBAgAABAgQIECBAgAABAskJCCySmxIFESBAgAABAgQIECBAgAABAgILa4AAAQIECBAgQIAAAQIECBBITkBgkdyUKIgAAQIECBAgQIAAAQIECBAQWFgDBAgQIECAAAECBAgQIECAQHICAovkpkRBBAgQIECAAAECBAgQIECAgMDCGiBAgAABAgQIECBAgAABAgSSExBYJDclCiJAgAABAgQIECBAgAABAgQEFtYAAQIECBAgQIAAAQIECBAgkJyAwCK5KVEQAQIECBAgQIAAAQIECBAgILCwBggQIECAAAECBAgQIECAAIHkBAQWyU2JgggQIECAAAECBAgQIECAAAGBhTVAgAABAgQIECBAgAABAgQIJCcgsEhuShREgAABAgQIECBAgAABAgQICCysAQIECBAgQIAAAQIECBAgQCA5AYFFclOiIAIECBAgQIAAAQIECBAgQEBgYQ0QIECAAAECBAgQIECAAAECyQkILJKbEgURIECAAAECBAgQIECAAAECAgtrgAABAgQIECBAgAABAgQIEEhOQGCR3JQoiAABAgQIECBAgAABAgQIEBBYWAMECBAgQIAAAQIECBAgQIBAcgICi+SmREEECBAgQIAAAQIECBAgQICAwMIaIECAAAECBAgQIECAAAECBJITEFgkNyUKIkCAAAECBAgQIECAAAECBAQW1gABAgQIECBAgAABAgQIECCQnIDAIrkpURABAgQIECBAgAABAgQIECAgsLAGCBAgQIAAAQIECBAgQIAAgeQEBBbJTYmCCBAgQIAAAQIECBAgQIAAAYGFNUCAAAECBAgQIECAAAECBAgkJyCwSG5KFESAAAECBAgQIECAAAECBAgILKwBAgQIECBAgAABAgQIECBAIDkBgUVyU6IgAgQIECBAgAABAgQIECBAQGBhDRAgQIAAAQIECBAgQIAAAQLJCQgskpsSBREgQIAAAQIECBAgQIAAAQICC2uAAAECBAgQIECAAAECBAgQSE5AYJHclCiIAAECBAgQIECAAAECBAgQEFhYAwQIECBAgAABAgQIECBAgEByAgKL5KZEQQQIECBAgAABAgQIECBAgIDAwhogQIAAAQIECBAgQIAAAQIEkhMQWCQ3JQoiQIAAAQIECBAgQIAAAQIEBBbWAAECBAgQIECAAAECBAgQIJCcgMAiuSlREAECBAgQIECAAAECBAgQICCwsAYIECBAgAABAgQIECBAgACB5AQEFslNiYIIECBAgAABAgQIECBAgAABgYU1QIAAAQIECBAgQIAAAQIECCQnILBIbkoURIAAAQIECBAgQIAAAQIECAgsrAECBAgQIECAAAECBAgQIEAgOQGBRXJToiACBAgQIECAAAECBAgQIEBAYGENECBAgAABAgQIECBAgAABAskJCCySmxIFESBAgAABAgQIECBAgAABAgILa4AAAQIECBAgQIAAAQIECBBITkBgkdyUKIgAAQIECBAgQIAAAQIECBAQWFgDBAgQIECAAAECBAgQIECAQHICAovkpkRBBAgQIECAAAECBAgQIECAgMDCGiBAgAABAgQIECBAgAABAgSSExBYJDclCiJAgAABAgQIECBAgAABAgQEFtYAAQIECBAgQIAAAQIECBAgkJyAwCK5KVEQAQIECBAgQIAAAQIECBAgILCwBggQIECAAAECBAgQIECAAIHkBAQWyU2JgggQIECAAAECBAgQIECAAAGBhTVAgAABAgQIECBAgAABAgQIJCcgsEhuShREgAABAgQIECBAgAABAgQICCysAQIECBAgQIAAAQIECBAgQCA5AYFFclOiIAIECBAgQIAAAQIECBAgQEBgYQ0QIECAAAECBAgQIECAAAECyQkILJKbEgURIECAAAECBAgQIECAAAECAgtrgAABAgQIECBAgAABAgQIEEhOQGCR3JQoiAABAgQIECBAgAABAgQIEBBYWAMECBAgQIAAAQIECBAgQIBAcgICi+SmREEECBAgQIAAAQIECBAgQICAwMIaIECAAAECBAgQIECAAAECBJITEFgkNyUKIkCAAAECBAgQIECAAAECBAQW1gABAgQIECBAgAABAgQIECCQnIDAIrkpURABAgQIECBAgAABAgQIECAgsLAGCBAgQIAAAQIECBAgQIAAgeQEBBbJTYmCCBAgQIAAAQIECBAgQIAAAYGFNUCAAAECBAgQIECAAAECBAgkJyCwSG5KFESAAAECBAgQIECAAAECBAgILKwBAgQIECBAgAABAgQIECBAIDkBgUVyU6IgAgQIECBAgAABAgQIECBAQGBhDRAgQIAAAQIECBAgQIAAAQLJCQgskpsSBREgQIAAAQIECBAgQIAAAQICC2uAAAECBAgQIECAAAECBAgQSE5AYJHclCiIAAECBAgQIECAAAECBAgQEFhYAwQIECBAgAABAgQIECBAgEByAgKL5KZEQQQIECBAgAABAgQIECBAgIDAwhogQIAAAQIECBAgQIAAAQIEkhMQWCQ3JQoiQIAAAQIECBAgQIAAAQIEBBbWAAECBAgQIECAAAECBAgQIJCcgMAiuSlREAECBAgQIECAAAECBAgQICCwsAYIECBAgAABAgQIECBAgACB5AQEFslNiYIIECDQWoG+9X1n5KX82jzPX/tiKimyYmNRFDeO7x7/qx3f2bHzxbThMwQIECBAgAABAgQEFtYAAQIECEwLlPoG+363lJU+El/oXQCWiQgv/jarZTdmP8y+WK1Wn1mANjVBgAABAgQIECDQJQICiy6ZaMMkQIDA8wn0ndz3inxp/r/yLD9rMaRix8WuaPem+OdnRodH/3Ex+mhmm5XByi9kRXbtVJ+TtcnLxjaN/X0z+9cXAQIECBAgQKAbBAQW3TDLxkiAAIHnEVg9sPrNpbx0Y4QVR9U/FrsjdsSfvzsfvGjj6Hi+//k+E+1+L77/F8VEcePo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'} +18/05/2026 18:43:10 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_onboarding_email to=['dev@localhost'] subject='New GoodWalk onboarding — test test (test)' +18/05/2026 18:43:10 New Zealand Standard Time INFO mail-api: Auth: registered new allowed email: test@test.com +18/05/2026 18:43:10 New Zealand Standard Time INFO mail-api: [b9989ba8] POST /onboarding-submit → 200 (13ms) +18/05/2026 18:43:26 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 18:43:26 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' 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 18:43:26 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s) +18/05/2026 18:43:26 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 18:43:30 New Zealand Standard Time INFO mail-api: [765c8d4d] GET /auth/verify → 401 (2ms) +18/05/2026 18:45:26 New Zealand Standard Time INFO mail-api: [2013b8b5] POST /auth/logout → 200 (1ms) +18/05/2026 18:48:07 New Zealand Standard Time INFO mail-api: [07a0c504] auth: code issued for email=mattcohen0@gmail.com +18/05/2026 18:48:07 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 910056 +18/05/2026 18:48:07 New Zealand Standard Time INFO mail-api: [07a0c504] POST /auth/request-code → 200 (2ms) +18/05/2026 18:48:14 New Zealand Standard Time INFO mail-api: [3fbebbbc] auth: session created for email=mattcohen0@gmail.com +18/05/2026 18:48:14 New Zealand Standard Time INFO mail-api: [3fbebbbc] POST /auth/verify-code → 200 (1ms) +18/05/2026 18:48:14 New Zealand Standard Time INFO mail-api: [4d0d9631] GET /auth/verify → 200 (0ms) +18/05/2026 18:49:38 New Zealand Standard Time INFO mail-api: [f0dc5e00] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:07 New Zealand Standard Time INFO mail-api: [6b628d2a] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:08 New Zealand Standard Time INFO mail-api: [74427c1d] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:09 New Zealand Standard Time INFO mail-api: [eed93abd] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:10 New Zealand Standard Time INFO mail-api: [3f3ecbb7] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:10 New Zealand Standard Time INFO mail-api: [36cf9cdb] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:14 New Zealand Standard Time INFO mail-api: [e9f9f525] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:15 New Zealand Standard Time INFO mail-api: [976be70c] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:20 New Zealand Standard Time INFO mail-api: [d796f744] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:46 New Zealand Standard Time INFO mail-api: [2615245b] GET /auth/verify → 200 (0ms) +18/05/2026 18:50:51 New Zealand Standard Time INFO mail-api: [17f958c0] GET /auth/verify → 200 (0ms) +18/05/2026 18:51:19 New Zealand Standard Time INFO mail-api: [d49cbb2f] GET /auth/verify → 200 (0ms) +18/05/2026 18:51:24 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:51:24 New Zealand Standard Time INFO mail-api: [b8ebd395] POST /auth/save-draft → 200 (15ms) +18/05/2026 18:52:06 New Zealand Standard Time INFO mail-api: [ddbcbbb4] GET /auth/verify → 200 (0ms) +18/05/2026 18:52:16 New Zealand Standard Time INFO mail-api: [381996f7] GET /auth/verify → 200 (1ms) +18/05/2026 18:52:54 New Zealand Standard Time INFO mail-api: [90ca02be] GET /auth/verify → 200 (0ms) +18/05/2026 18:53:16 New Zealand Standard Time INFO mail-api: [68b0bba9] GET /auth/verify → 200 (0ms) +18/05/2026 18:53:19 New Zealand Standard Time INFO mail-api: [fa59ecc1] GET /auth/verify → 200 (0ms) +18/05/2026 18:53:20 New Zealand Standard Time INFO mail-api: [53e2da48] GET /auth/verify → 200 (0ms) +18/05/2026 18:53:25 New Zealand Standard Time INFO mail-api: [7fc3d2e2] GET /auth/verify → 200 (0ms) +18/05/2026 18:53:26 New Zealand Standard Time INFO mail-api: [b7203739] GET /auth/verify → 200 (0ms) +18/05/2026 18:53:59 New Zealand Standard Time INFO mail-api: [0d559001] GET /auth/verify → 200 (1ms) +18/05/2026 18:54:04 New Zealand Standard Time INFO mail-api: [d26d2d47] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:05 New Zealand Standard Time INFO mail-api: [2155fcd9] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:14 New Zealand Standard Time INFO mail-api: [e8503c51] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:35 New Zealand Standard Time INFO mail-api: [6a60a513] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:43 New Zealand Standard Time INFO mail-api: [7159838c] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:46 New Zealand Standard Time INFO mail-api: [e6f0f0e3] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:49 New Zealand Standard Time INFO mail-api: [ff9a000a] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:50 New Zealand Standard Time INFO mail-api: [ff2ad028] GET /auth/verify → 200 (1ms) +18/05/2026 18:54:52 New Zealand Standard Time INFO mail-api: [fe518a74] GET /auth/verify → 200 (0ms) +18/05/2026 18:54:59 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 18:54:59 New Zealand Standard Time INFO mail-api: [bad7ac23] POST /auth/save-draft → 200 (2ms) +18/05/2026 18:59:13 New Zealand Standard Time INFO mail-api: [28d0f628] auth: code issued for email=mattcohen0@gmail.com +18/05/2026 18:59:13 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 102671 +18/05/2026 18:59:13 New Zealand Standard Time INFO mail-api: [28d0f628] POST /auth/request-code → 200 (1ms) +18/05/2026 18:59:36 New Zealand Standard Time INFO mail-api: [7bc72e6a] auth: session created for email=mattcohen0@gmail.com +18/05/2026 18:59:36 New Zealand Standard Time INFO mail-api: [7bc72e6a] POST /auth/verify-code → 200 (1ms) +18/05/2026 18:59:36 New Zealand Standard Time INFO mail-api: [1e0e1a53] GET /auth/verify → 200 (0ms) +18/05/2026 19:00:22 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:00:22 New Zealand Standard Time INFO mail-api: [896553c7] POST /auth/save-draft → 200 (2ms) +18/05/2026 19:01:15 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:01:15 New Zealand Standard Time INFO mail-api: [01719ac6] POST /auth/save-draft → 200 (2ms) +18/05/2026 19:01:48 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:01:48 New Zealand Standard Time INFO mail-api: [3bfff9d6] POST /auth/save-draft → 200 (2ms) +18/05/2026 19:02:21 New Zealand Standard Time INFO mail-api: [86b80feb] /onboarding-submit: email=test@test.com ip=127.0.0.1 browser='Safari on macOS' dog=X services=['Unsure yet'] page='http://10.0.0.124:5173/?preview=onboarding' +18/05/2026 19:02:21 New Zealand Standard Time DEBUG mail-api: [86b80feb] onboarding payload: {'fullName': 'test test', 'email': 'test@test.com', 'phone': 'test@test.com', 'website': '', 'formStartedAt': 1779087576243, 'visitStartedAt': 1779087576243, 'pageEnteredAt': 1779087576243, 'firstInteractionAt': 1779087589781, 'sendClickedAt': 1779087741631, 'referrer': '', 'page': 'http://10.0.0.124:5173/?preview=onboarding', 'address': 'test@test.com', 'dogName': 'X', 'dogBreed': 'H', 'dogAge': '2026-05-18', 'servicesNeeded': ['Unsure yet'], 'temperament': 'Y', 'medicalNotes': 'Y\nFood allergies: U\nEnvironmental allergies: Y\nSpecial diet: Y\nMedication: Y', 'accessInstructions': 'U', 'vetName': 'H', 'vetPhone': '3', 'emergencyContactName': 'O', 'emergencyContactPhone': '5', 'councilRegistrationConfirmed': True, 'vaccinationsConfirmed': True, 'emergencyVetConsent': True, 'termsAccepted': True, 'signatureDataUrl': 'data:image/png;base64,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zZjnt7y5GqHhc8p50cQTIFB5AQVM5YcAAAIECBAoQyCkocjeMJaRlYEvBwEClRKwhKxS3e1hCRAgQOD/Eoh7wzxrLjUPx/xf57jG8onWie92d3Zzv0OT4xpCCRAgsFACZmAWqjs9DAECBAgcpEBnM//eMP3QNwtzkJ3m2gQIzJ2AAmbuuswNEyBAgMAMC6RJkrxfShay3Gd8+f9qljgxBAgQIDAUUMAYCQQIECBAoESBzkbnbkx3K/4lGdOezhGbMaUwAgQILK6AAmZx+9aTESBAgMABCWxvbl+Ol/4j4+XDanvV18gyYgkjQICAAsYYIECAAAEC5QuEtJ4Oipi9CakHS8wGf0nc1PLMhDinCBAgQGBEQAEzguEnAQIECBAoS+Dp+tNHoRYujcn3/t2YwRKz4TKzpPZ2TJxDBAgQIDBGQAEzBsUhAgQIECBQhkB3s3svzq58859cw6Jl5GDcQ+b3kX/9JECAAIEJAvaBmYDjFAECBAgQmFYg7g/zy/Gl4ysxT3tcrvgVsh+2H2x/O+6cYwQIECCwX0ABs9/EEQIECBAgUKpAb6d3r9lqtmLSL0cTx08ufx/3jrkyesxvAgQIEJgssG8ae3K4swQIECBAgEBRgbUv1j7rp/2vBu0P1Q/d3/pz60nRXNoRIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAwCIL/AP3k7fgjgVvegAAAABJRU5ErkJggg=='} +18/05/2026 19:02:21 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_onboarding_email to=['dev@localhost'] subject='New GoodWalk onboarding — test test (X)' +18/05/2026 19:02:21 New Zealand Standard Time INFO mail-api: [86b80feb] POST /onboarding-submit → 200 (13ms) +18/05/2026 19:04:29 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:04:29 New Zealand Standard Time INFO mail-api: [4d664935] POST /auth/save-draft → 200 (2ms) +18/05/2026 19:05:16 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:05:16 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:05:16 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s) +18/05/2026 19:05:16 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:05:21 New Zealand Standard Time INFO mail-api: [3e940608] POST /auth/save-draft → 401 (2ms) +18/05/2026 19:05:30 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:05:30 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:05:30 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s) +18/05/2026 19:05:30 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:05:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:05:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:05:36 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s) +18/05/2026 19:05:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:05:40 New Zealand Standard Time INFO mail-api: [577d9cc3] POST /auth/save-draft → 401 (2ms) +18/05/2026 19:06:31 New Zealand Standard Time INFO mail-api: [f92ab807] GET /auth/verify → 401 (1ms) +18/05/2026 19:14:56 New Zealand Standard Time INFO mail-api: [a88505e9] auth: code issued for email=mattcohen0@gmail.com +18/05/2026 19:14:56 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 808813 +18/05/2026 19:14:56 New Zealand Standard Time INFO mail-api: [a88505e9] POST /auth/request-code → 200 (2ms) +18/05/2026 19:15:06 New Zealand Standard Time INFO mail-api: [e6fe039e] auth: session created for email=mattcohen0@gmail.com +18/05/2026 19:15:06 New Zealand Standard Time INFO mail-api: [e6fe039e] POST /auth/verify-code → 200 (1ms) +18/05/2026 19:15:06 New Zealand Standard Time INFO mail-api: [c84465df] GET /auth/verify → 200 (0ms) +18/05/2026 19:15:53 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:15:53 New Zealand Standard Time INFO mail-api: [01674156] POST /auth/save-draft → 200 (3ms) +18/05/2026 19:17:30 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:17:30 New Zealand Standard Time INFO mail-api: [e9a0a0ce] POST /auth/save-draft → 200 (2ms) +18/05/2026 19:17:45 New Zealand Standard Time INFO mail-api: Draft saved: email=mattcohen0@gmail.com form=onboarding +18/05/2026 19:17:45 New Zealand Standard Time INFO mail-api: [151d037e] POST /auth/save-draft → 200 (2ms) +18/05/2026 19:18:41 New Zealand Standard Time INFO mail-api: [4befc845] GET /auth/verify → 200 (0ms) +18/05/2026 19:19:24 New Zealand Standard Time INFO mail-api: [097dddb7] GET /auth/verify → 200 (0ms) +18/05/2026 19:22:57 New Zealand Standard Time INFO mail-api: [986524c9] GET /auth/verify → 200 (1ms) +18/05/2026 19:22:59 New Zealand Standard Time INFO mail-api: [ab0e5771] POST /auth/logout → 200 (0ms) +18/05/2026 19:23:19 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:23:19 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:23:19 New Zealand Standard Time INFO mail-api: Auth: loaded 2 allowed email(s) +18/05/2026 19:23:19 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:23:30 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:23:30 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:23:30 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:23:30 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:23:40 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:23:40 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:23:40 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:23:41 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:24:06 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:24:06 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:24:06 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:24:06 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:24:12 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:24:12 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:24:12 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:24:12 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:24:33 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:24:33 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:24:33 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:24:33 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:24:43 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:24:43 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:24:43 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:24:43 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:24:49 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:24:49 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:24:49 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:24:49 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:24:56 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:24:56 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:24:56 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:24:57 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:32:24 New Zealand Standard Time INFO mail-api: [d2802773] auth: unknown email=info@goodwalk.co.nz ip=127.0.0.1 +18/05/2026 19:32:24 New Zealand Standard Time WARNING mail-api: [d2802773] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=1 +18/05/2026 19:32:24 New Zealand Standard Time INFO mail-api: [d2802773] POST /auth/request-code → 403 (3ms) +18/05/2026 19:32:45 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:32:45 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:32:45 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:32:45 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:32:49 New Zealand Standard Time INFO mail-api: [752497fb] auth: unknown email=info@goodwalk.co.nz ip=127.0.0.1 +18/05/2026 19:32:49 New Zealand Standard Time WARNING mail-api: [752497fb] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=1 +18/05/2026 19:32:49 New Zealand Standard Time INFO mail-api: [752497fb] POST /auth/request-code → 403 (3ms) +18/05/2026 19:37:07 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:37:07 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:37:07 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:37:07 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:37:09 New Zealand Standard Time INFO mail-api: [49cfee69] GET /auth/verify → 401 (1ms) +18/05/2026 19:37:12 New Zealand Standard Time INFO mail-api: [2baec3fb] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 19:37:12 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 645023 +18/05/2026 19:37:12 New Zealand Standard Time INFO mail-api: [2baec3fb] POST /auth/request-code → 200 (2ms) +18/05/2026 19:37:20 New Zealand Standard Time INFO mail-api: [1dddf255] auth: session created for email=info@goodwalk.co.nz +18/05/2026 19:37:20 New Zealand Standard Time INFO mail-api: [1dddf255] POST /auth/verify-code → 200 (1ms) +18/05/2026 19:37:20 New Zealand Standard Time INFO mail-api: [5cd55ec8] GET /auth/verify → 200 (0ms) +18/05/2026 19:37:20 New Zealand Standard Time INFO mail-api: [97b632c4] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 19:39:44 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:39:44 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:39:44 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:39:44 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:39:51 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:39:51 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:39:51 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:39:51 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 19:42:55 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 19:42:55 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +18/05/2026 19:42:55 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 19:42:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:36:40 New Zealand Standard Time INFO mail-api: [3f283086] GET /auth/verify → 401 (1ms) +18/05/2026 20:36:51 New Zealand Standard Time INFO mail-api: [b368db7e] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 20:36:51 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 273804 +18/05/2026 20:36:51 New Zealand Standard Time INFO mail-api: [b368db7e] POST /auth/request-code → 200 (2ms) +18/05/2026 20:37:00 New Zealand Standard Time INFO mail-api: [e417a82c] auth: session created for email=info@goodwalk.co.nz +18/05/2026 20:37:00 New Zealand Standard Time INFO mail-api: [e417a82c] POST /auth/verify-code → 200 (1ms) +18/05/2026 20:37:00 New Zealand Standard Time INFO mail-api: [315e6b4d] GET /auth/verify → 200 (0ms) +18/05/2026 20:37:00 New Zealand Standard Time INFO mail-api: [46c191a6] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:37:05 New Zealand Standard Time INFO mail-api: [bb17551c] GET /auth/verify → 200 (0ms) +18/05/2026 20:37:05 New Zealand Standard Time INFO mail-api: [99047ed5] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:37:14 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' +18/05/2026 20:37:14 New Zealand Standard Time INFO mail-api: [b0c8ebe5] welcome pack sent: email=mattcohen0@gmail.com service=test start=2026-05-18 +18/05/2026 20:37:14 New Zealand Standard Time INFO mail-api: [b0c8ebe5] POST /owner/send-welcome-pack → 200 (8ms) +18/05/2026 20:37:17 New Zealand Standard Time INFO mail-api: [a8a4b2db] GET /auth/verify → 200 (0ms) +18/05/2026 20:37:17 New Zealand Standard Time INFO mail-api: [07b80337] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:48:42 New Zealand Standard Time INFO mail-api: [41b27cbd] GET /auth/verify → 200 (0ms) +18/05/2026 20:48:42 New Zealand Standard Time INFO mail-api: [ecf68f55] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:49:00 New Zealand Standard Time INFO mail-api: [758c7342] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:00 New Zealand Standard Time INFO mail-api: [226ad835] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:05 New Zealand Standard Time INFO mail-api: [d023e315] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:05 New Zealand Standard Time INFO mail-api: [363b904a] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:23 New Zealand Standard Time INFO mail-api: [898ef469] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:23 New Zealand Standard Time INFO mail-api: [add0beab] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:32 New Zealand Standard Time INFO mail-api: [ad1b7e9b] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:32 New Zealand Standard Time INFO mail-api: [f960f6be] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:38 New Zealand Standard Time INFO mail-api: [1c44dc2e] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:38 New Zealand Standard Time INFO mail-api: [21a76e10] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:49 New Zealand Standard Time INFO mail-api: [fda41ecc] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:49 New Zealand Standard Time INFO mail-api: [6bafc11b] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:53 New Zealand Standard Time INFO mail-api: [01671e54] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:53 New Zealand Standard Time INFO mail-api: [b167f067] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:49:57 New Zealand Standard Time INFO mail-api: [6ec1a097] GET /auth/verify → 200 (0ms) +18/05/2026 20:49:57 New Zealand Standard Time INFO mail-api: [365e530d] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:50:00 New Zealand Standard Time INFO mail-api: [7145f664] GET /auth/verify → 200 (1ms) +18/05/2026 20:50:00 New Zealand Standard Time INFO mail-api: [4c7f6ea7] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:50:06 New Zealand Standard Time INFO mail-api: [569d97d3] GET /auth/verify → 200 (0ms) +18/05/2026 20:50:06 New Zealand Standard Time INFO mail-api: [c2d63f91] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:50:06 New Zealand Standard Time INFO mail-api: [806fccdb] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:50:09 New Zealand Standard Time INFO mail-api: [d96da4e6] GET /auth/verify → 200 (0ms) +18/05/2026 20:50:09 New Zealand Standard Time INFO mail-api: [f9225ccd] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:50:09 New Zealand Standard Time INFO mail-api: [b8d22fc7] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:50:32 New Zealand Standard Time INFO mail-api: [431a0057] GET /auth/verify → 200 (0ms) +18/05/2026 20:50:32 New Zealand Standard Time INFO mail-api: [6f72eb73] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:50:32 New Zealand Standard Time INFO mail-api: [f156121e] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:51:02 New Zealand Standard Time INFO mail-api: [41b25f40] GET /auth/verify → 200 (0ms) +18/05/2026 20:51:02 New Zealand Standard Time INFO mail-api: [074c5790] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:51:02 New Zealand Standard Time INFO mail-api: [42413508] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:51:10 New Zealand Standard Time INFO mail-api: [ff4b04ba] GET /auth/verify → 200 (0ms) +18/05/2026 20:51:10 New Zealand Standard Time INFO mail-api: [52528c10] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:51:10 New Zealand Standard Time INFO mail-api: [48137c12] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:52:30 New Zealand Standard Time INFO mail-api: [2abac2d8] GET /auth/verify → 200 (0ms) +18/05/2026 20:52:30 New Zealand Standard Time INFO mail-api: [ba9be7f6] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 20:52:30 New Zealand Standard Time INFO mail-api: [c02fd6c0] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:52:44 New Zealand Standard Time INFO mail-api: [1ed6452c] GET /auth/verify → 200 (0ms) +18/05/2026 20:52:44 New Zealand Standard Time INFO mail-api: [0b16c2db] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:52:44 New Zealand Standard Time INFO mail-api: [39be3b9d] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:52:46 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:52:46 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:52:46 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:52:46 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:52:51 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:52:51 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:52:51 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:52:51 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:52:53 New Zealand Standard Time INFO mail-api: [415dcfda] GET /auth/verify → 401 (1ms) +18/05/2026 20:52:57 New Zealand Standard Time INFO mail-api: [7d59882a] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 20:52:57 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 587178 +18/05/2026 20:52:57 New Zealand Standard Time INFO mail-api: [7d59882a] POST /auth/request-code → 200 (2ms) +18/05/2026 20:53:06 New Zealand Standard Time INFO mail-api: [ba148f10] auth: session created for email=info@goodwalk.co.nz +18/05/2026 20:53:06 New Zealand Standard Time INFO mail-api: [ba148f10] POST /auth/verify-code → 200 (2ms) +18/05/2026 20:53:06 New Zealand Standard Time INFO mail-api: [4d2e0d81] GET /auth/verify → 200 (0ms) +18/05/2026 20:53:06 New Zealand Standard Time INFO mail-api: [e3e880d5] GET /owner/completed-onboarding → 200 (2ms) +18/05/2026 20:53:06 New Zealand Standard Time INFO mail-api: [66c77107] GET /owner/pending-onboarding → 200 (2ms) +18/05/2026 20:53:19 New Zealand Standard Time INFO mail-api: [29e3f7d7] GET /auth/verify → 200 (0ms) +18/05/2026 20:53:19 New Zealand Standard Time INFO mail-api: [1554fc60] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:53:19 New Zealand Standard Time INFO mail-api: [caa57c2b] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:53:22 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:53:22 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:53:22 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:53:22 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:53:26 New Zealand Standard Time INFO mail-api: [e03104a8] GET /auth/verify → 401 (1ms) +18/05/2026 20:53:30 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:53:30 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:53:30 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:53:30 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:53:50 New Zealand Standard Time INFO mail-api: [329720e2] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 20:53:50 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 292473 +18/05/2026 20:53:50 New Zealand Standard Time INFO mail-api: [329720e2] POST /auth/request-code → 200 (3ms) +18/05/2026 20:53:54 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:53:54 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:53:54 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:53:54 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:54:01 New Zealand Standard Time INFO mail-api: [8402d588] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 20:54:01 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 926028 +18/05/2026 20:54:01 New Zealand Standard Time INFO mail-api: [8402d588] POST /auth/request-code → 200 (3ms) +18/05/2026 20:54:08 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:54:08 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:54:08 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:54:08 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:54:08 New Zealand Standard Time WARNING mail-api: [158bfcb9] auth: failure ip=127.0.0.1 reason='no_pending_code' total_in_window=1 +18/05/2026 20:54:08 New Zealand Standard Time INFO mail-api: [158bfcb9] POST /auth/verify-code → 400 (3ms) +18/05/2026 20:54:17 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:54:17 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:54:17 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:54:17 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:54:21 New Zealand Standard Time INFO mail-api: [b96e1a15] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 20:54:21 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 171778 +18/05/2026 20:54:21 New Zealand Standard Time INFO mail-api: [b96e1a15] POST /auth/request-code → 200 (3ms) +18/05/2026 20:54:28 New Zealand Standard Time INFO mail-api: [5d344a66] auth: session created for email=info@goodwalk.co.nz +18/05/2026 20:54:28 New Zealand Standard Time INFO mail-api: [5d344a66] POST /auth/verify-code → 200 (2ms) +18/05/2026 20:54:29 New Zealand Standard Time INFO mail-api: [a42dcc2e] GET /auth/verify → 200 (0ms) +18/05/2026 20:54:29 New Zealand Standard Time INFO mail-api: [d7b1503e] GET /owner/pending-onboarding → 200 (2ms) +18/05/2026 20:54:29 New Zealand Standard Time INFO mail-api: [333f54a9] GET /owner/completed-onboarding → 200 (2ms) +18/05/2026 20:55:18 New Zealand Standard Time INFO mail-api: [146fad21] GET /auth/verify → 200 (0ms) +18/05/2026 20:55:18 New Zealand Standard Time INFO mail-api: [3b0c10cf] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 20:55:18 New Zealand Standard Time INFO mail-api: [dd191338] GET /owner/completed-onboarding → 200 (1ms) +18/05/2026 20:55:23 New Zealand Standard Time INFO mail-api: [bab51812] GET /auth/verify → 200 (0ms) +18/05/2026 20:55:24 New Zealand Standard Time INFO mail-api: [ad8c1219] GET /auth/verify → 200 (0ms) +18/05/2026 20:55:29 New Zealand Standard Time INFO mail-api: [9b536203] GET /auth/verify → 200 (0ms) +18/05/2026 20:55:35 New Zealand Standard Time INFO mail-api: [b5154137] GET /auth/verify → 200 (0ms) +18/05/2026 20:55:40 New Zealand Standard Time INFO mail-api: [f4bc3375] GET /auth/verify → 200 (1ms) +18/05/2026 20:55:43 New Zealand Standard Time INFO mail-api: [0b7c0994] GET /auth/verify → 200 (0ms) +18/05/2026 20:57:09 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 20:57:09 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 20:57:09 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 20:57:09 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 20:57:57 New Zealand Standard Time INFO mail-api: [e4d0f1eb] GET /auth/verify → 401 (1ms) +18/05/2026 20:58:09 New Zealand Standard Time INFO mail-api: [82661eb4] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 20:58:09 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 611853 +18/05/2026 20:58:09 New Zealand Standard Time INFO mail-api: [82661eb4] POST /auth/request-code → 200 (2ms) +18/05/2026 20:58:16 New Zealand Standard Time INFO mail-api: [c8a64ef0] auth: session created for email=info@goodwalk.co.nz +18/05/2026 20:58:16 New Zealand Standard Time INFO mail-api: [c8a64ef0] POST /auth/verify-code → 200 (1ms) +18/05/2026 20:58:16 New Zealand Standard Time INFO mail-api: [b8d16127] GET /auth/verify → 200 (1ms) +18/05/2026 20:58:16 New Zealand Standard Time INFO mail-api: [966d900e] GET /owner/pending-onboarding → 200 (2ms) +18/05/2026 20:58:16 New Zealand Standard Time INFO mail-api: [09cbe17c] GET /owner/completed-onboarding → 200 (2ms) +18/05/2026 20:58:26 New Zealand Standard Time INFO mail-api: [6d43f130] GET /owner/birthday-ics → 200 (47ms) +18/05/2026 21:01:40 New Zealand Standard Time INFO mail-api: [10fe76cb] GET /auth/verify → 200 (0ms) +18/05/2026 21:01:40 New Zealand Standard Time INFO mail-api: [0974c005] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:01:40 New Zealand Standard Time INFO mail-api: [24266b49] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:01:40 New Zealand Standard Time INFO mail-api: [aec89563] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:01:54 New Zealand Standard Time INFO mail-api: [ac91ab1a] GET /auth/verify → 200 (0ms) +18/05/2026 21:01:54 New Zealand Standard Time INFO mail-api: [392012b2] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:01:54 New Zealand Standard Time INFO mail-api: [19bb9b32] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:01:54 New Zealand Standard Time INFO mail-api: [f49619c8] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:02:00 New Zealand Standard Time INFO mail-api: [919dc475] GET /auth/verify → 200 (0ms) +18/05/2026 21:02:00 New Zealand Standard Time INFO mail-api: [69fff7db] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:02:00 New Zealand Standard Time INFO mail-api: [c83661f8] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:02:00 New Zealand Standard Time INFO mail-api: [6f5fac0c] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:03:08 New Zealand Standard Time INFO mail-api: [154efc4c] GET /auth/verify → 200 (0ms) +18/05/2026 21:03:08 New Zealand Standard Time INFO mail-api: [61b8e36d] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:03:08 New Zealand Standard Time INFO mail-api: [746f579f] GET /owner/all-clients → 200 (0ms) +18/05/2026 21:03:08 New Zealand Standard Time INFO mail-api: [4c03794b] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:04:37 New Zealand Standard Time INFO mail-api: [66334fa7] GET /auth/verify → 200 (0ms) +18/05/2026 21:04:37 New Zealand Standard Time INFO mail-api: [7ae679b9] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:04:37 New Zealand Standard Time INFO mail-api: [e9bb44e0] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:04:37 New Zealand Standard Time INFO mail-api: [dd496d79] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:04:48 New Zealand Standard Time INFO mail-api: [3391cb3e] GET /auth/verify → 200 (0ms) +18/05/2026 21:04:48 New Zealand Standard Time INFO mail-api: [3e4f5d46] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:04:48 New Zealand Standard Time INFO mail-api: [943c3c32] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:04:48 New Zealand Standard Time INFO mail-api: [0fe2a305] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:04:56 New Zealand Standard Time INFO mail-api: [630e810d] POST /owner/birthday-auto-send → 200 (11ms) +18/05/2026 21:04:57 New Zealand Standard Time INFO mail-api: [257fd79f] POST /owner/birthday-auto-send → 200 (2ms) +18/05/2026 21:05:46 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:05:46 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:05:46 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:05:46 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:05:54 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:05:54 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:05:54 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:05:54 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:06:14 New Zealand Standard Time INFO mail-api: [1d4ec9f0] GET /auth/verify → 401 (1ms) +18/05/2026 21:06:22 New Zealand Standard Time INFO mail-api: [cd75de27] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 21:06:22 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 352716 +18/05/2026 21:06:22 New Zealand Standard Time INFO mail-api: [cd75de27] POST /auth/request-code → 200 (2ms) +18/05/2026 21:06:33 New Zealand Standard Time INFO mail-api: [39fcb039] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 21:06:33 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 673532 +18/05/2026 21:06:33 New Zealand Standard Time INFO mail-api: [39fcb039] POST /auth/request-code → 200 (1ms) +18/05/2026 21:06:41 New Zealand Standard Time INFO mail-api: [b569b0a7] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 21:06:41 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 422102 +18/05/2026 21:06:41 New Zealand Standard Time INFO mail-api: [b569b0a7] POST /auth/request-code → 200 (1ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [f7d31b25] auth: session created for email=info@goodwalk.co.nz +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [f7d31b25] POST /auth/verify-code → 200 (1ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [3bfb4696] GET /auth/verify → 200 (0ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [ad0e89cf] GET /owner/pending-onboarding → 200 (2ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [4e0de9fd] GET /owner/all-clients → 200 (5ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [60fd31ea] GET /owner/birthdays → 200 (6ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [d16754bc] GET /auth/verify → 200 (0ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [4af5c695] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [5f073850] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:06:47 New Zealand Standard Time INFO mail-api: [74c6b7ba] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:06:50 New Zealand Standard Time INFO mail-api: [5f5e5ce9] GET /owner/client-enquiry → 200 (8ms) +18/05/2026 21:06:51 New Zealand Standard Time INFO mail-api: [e1417817] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:06:52 New Zealand Standard Time INFO mail-api: [df34527b] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:06:55 New Zealand Standard Time INFO mail-api: [937f331a] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:06:56 New Zealand Standard Time INFO mail-api: [d2da1b46] GET /auth/verify → 200 (0ms) +18/05/2026 21:06:56 New Zealand Standard Time INFO mail-api: [71230eee] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:06:56 New Zealand Standard Time INFO mail-api: [6f92ccfc] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:06:56 New Zealand Standard Time INFO mail-api: [be70a30e] GET /owner/birthdays → 200 (38ms) +18/05/2026 21:06:56 New Zealand Standard Time INFO mail-api: [dd3c2966] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:06:57 New Zealand Standard Time INFO mail-api: [9d07d186] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:07:06 New Zealand Standard Time INFO mail-api: [30adb043] GET /auth/verify → 200 (0ms) +18/05/2026 21:07:06 New Zealand Standard Time INFO mail-api: [e4210b7e] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:07:06 New Zealand Standard Time INFO mail-api: [aae658b3] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:07:06 New Zealand Standard Time INFO mail-api: [93cf8b33] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:07:16 New Zealand Standard Time INFO mail-api: [48eaf99a] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:07:21 New Zealand Standard Time INFO mail-api: [40707298] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:07:26 New Zealand Standard Time INFO mail-api: [4dc85cd2] GET /auth/verify → 200 (0ms) +18/05/2026 21:07:26 New Zealand Standard Time INFO mail-api: [15e9f336] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:07:26 New Zealand Standard Time INFO mail-api: [02bdfb2f] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:07:26 New Zealand Standard Time INFO mail-api: [33d645fd] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:07:27 New Zealand Standard Time INFO mail-api: [f5d3c66c] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:07:29 New Zealand Standard Time INFO mail-api: [808493ea] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:07:31 New Zealand Standard Time INFO mail-api: [5abd3b07] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:08:54 New Zealand Standard Time INFO mail-api: [df00ae10] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:09:13 New Zealand Standard Time INFO mail-api: [577cac77] GET /auth/verify → 200 (0ms) +18/05/2026 21:09:13 New Zealand Standard Time INFO mail-api: [4a8b9e56] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:09:13 New Zealand Standard Time INFO mail-api: [eddfea37] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:09:13 New Zealand Standard Time INFO mail-api: [3dacc7b7] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:09:24 New Zealand Standard Time INFO mail-api: [49418f49] GET /auth/verify → 200 (0ms) +18/05/2026 21:09:24 New Zealand Standard Time INFO mail-api: [95000436] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:09:24 New Zealand Standard Time INFO mail-api: [366ef2d7] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:09:24 New Zealand Standard Time INFO mail-api: [98e6711c] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:09:30 New Zealand Standard Time INFO mail-api: [f29e3ea1] GET /auth/verify → 200 (0ms) +18/05/2026 21:09:30 New Zealand Standard Time INFO mail-api: [ce9b4e92] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:09:30 New Zealand Standard Time INFO mail-api: [94eb3c49] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:09:30 New Zealand Standard Time INFO mail-api: [7cfaa0e0] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:09:51 New Zealand Standard Time INFO mail-api: [1540a1fe] GET /auth/verify → 200 (0ms) +18/05/2026 21:09:51 New Zealand Standard Time INFO mail-api: [cef54851] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:09:51 New Zealand Standard Time INFO mail-api: [d8b05598] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:09:51 New Zealand Standard Time INFO mail-api: [5582ac68] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:09:55 New Zealand Standard Time INFO mail-api: [3bc7eac9] GET /auth/verify → 200 (0ms) +18/05/2026 21:09:55 New Zealand Standard Time INFO mail-api: [dbd5ad2f] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:09:55 New Zealand Standard Time INFO mail-api: [ce08939a] GET /owner/all-clients → 200 (0ms) +18/05/2026 21:09:55 New Zealand Standard Time INFO mail-api: [a0c6e352] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:09:56 New Zealand Standard Time INFO mail-api: [628a7bb1] GET /auth/verify → 200 (0ms) +18/05/2026 21:09:56 New Zealand Standard Time INFO mail-api: [3f0c4dde] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:09:56 New Zealand Standard Time INFO mail-api: [edcf1014] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:09:56 New Zealand Standard Time INFO mail-api: [cdd1d4e6] GET /owner/birthdays → 200 (2ms) +18/05/2026 21:10:01 New Zealand Standard Time INFO mail-api: [cd064044] GET /auth/verify → 200 (0ms) +18/05/2026 21:10:01 New Zealand Standard Time INFO mail-api: [2fcfe82b] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:10:01 New Zealand Standard Time INFO mail-api: [f0a36d94] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:10:01 New Zealand Standard Time INFO mail-api: [e82203da] GET /owner/birthdays → 200 (2ms) +18/05/2026 21:10:14 New Zealand Standard Time INFO mail-api: [9fa6aea1] GET /auth/verify → 200 (0ms) +18/05/2026 21:10:14 New Zealand Standard Time INFO mail-api: [1daa6f9d] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:10:14 New Zealand Standard Time INFO mail-api: [5e1ce2b0] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:10:14 New Zealand Standard Time INFO mail-api: [e24c3f93] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:10:25 New Zealand Standard Time INFO mail-api: [c8045ecf] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:10:40 New Zealand Standard Time INFO mail-api: [66544d87] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:10:42 New Zealand Standard Time INFO mail-api: [e1d560f3] GET /owner/client-enquiry → 200 (0ms) +18/05/2026 21:10:52 New Zealand Standard Time INFO mail-api: [5154665b] GET /auth/verify → 200 (0ms) +18/05/2026 21:10:52 New Zealand Standard Time INFO mail-api: [e8c6529f] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:10:52 New Zealand Standard Time INFO mail-api: [bff9782c] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:10:52 New Zealand Standard Time INFO mail-api: [3bfe2f94] GET /owner/birthdays → 200 (2ms) +18/05/2026 21:10:57 New Zealand Standard Time INFO mail-api: [1fab6d86] GET /auth/verify → 200 (0ms) +18/05/2026 21:10:57 New Zealand Standard Time INFO mail-api: [5c77b1a5] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:10:57 New Zealand Standard Time INFO mail-api: [999f1cbd] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:10:57 New Zealand Standard Time INFO mail-api: [196d7f81] GET /owner/birthdays → 200 (2ms) +18/05/2026 21:11:30 New Zealand Standard Time INFO mail-api: [d4867904] GET /auth/verify → 200 (0ms) +18/05/2026 21:11:30 New Zealand Standard Time INFO mail-api: [1ae08f2c] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:11:30 New Zealand Standard Time INFO mail-api: [17a10187] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:11:30 New Zealand Standard Time INFO mail-api: [7cc1014d] GET /owner/birthdays → 200 (2ms) +18/05/2026 21:12:30 New Zealand Standard Time INFO mail-api: [c0ad4e73] GET /auth/verify → 200 (0ms) +18/05/2026 21:12:30 New Zealand Standard Time INFO mail-api: [c696f323] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:12:30 New Zealand Standard Time INFO mail-api: [fd2b382b] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:12:30 New Zealand Standard Time INFO mail-api: [bfbf712c] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:12:33 New Zealand Standard Time INFO mail-api: [d9181470] GET /auth/verify → 200 (0ms) +18/05/2026 21:12:33 New Zealand Standard Time INFO mail-api: [432506c5] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:12:33 New Zealand Standard Time INFO mail-api: [36f2eb7d] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:12:33 New Zealand Standard Time INFO mail-api: [4572655e] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:13:20 New Zealand Standard Time INFO mail-api: [1d738698] GET /auth/verify → 200 (0ms) +18/05/2026 21:13:20 New Zealand Standard Time INFO mail-api: [9a6b2a2a] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:13:20 New Zealand Standard Time INFO mail-api: [d33bc3cf] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:13:20 New Zealand Standard Time INFO mail-api: [82cd8646] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:13:25 New Zealand Standard Time INFO mail-api: [cd64ed80] GET /auth/verify → 200 (0ms) +18/05/2026 21:13:25 New Zealand Standard Time INFO mail-api: [fd6aab4e] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:13:25 New Zealand Standard Time INFO mail-api: [1620a2b7] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:13:25 New Zealand Standard Time INFO mail-api: [4fbfa033] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:14:34 New Zealand Standard Time INFO mail-api: [47c17e37] GET /auth/verify → 200 (0ms) +18/05/2026 21:14:34 New Zealand Standard Time INFO mail-api: [c9a6b0e8] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:14:34 New Zealand Standard Time INFO mail-api: [04a15a35] GET /owner/all-clients → 200 (0ms) +18/05/2026 21:14:34 New Zealand Standard Time INFO mail-api: [df331a1d] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:16:26 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:16:26 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:16:26 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:16:26 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:16:41 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:16:41 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:16:41 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:16:42 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:16:53 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:16:53 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:16:53 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:16:53 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:16:58 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:16:58 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:16:58 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:16:58 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:17:03 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:17:03 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:17:03 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:17:03 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:17:09 New Zealand Standard Time INFO mail-api: [4adefcdc] GET /auth/verify → 401 (2ms) +18/05/2026 21:18:51 New Zealand Standard Time INFO mail-api: [daa42fc9] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 21:18:51 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 196387 +18/05/2026 21:18:51 New Zealand Standard Time INFO mail-api: [daa42fc9] POST /auth/request-code → 200 (2ms) +18/05/2026 21:19:02 New Zealand Standard Time INFO mail-api: [813144ca] auth: session created for email=info@goodwalk.co.nz +18/05/2026 21:19:02 New Zealand Standard Time INFO mail-api: [813144ca] POST /auth/verify-code → 200 (2ms) +18/05/2026 21:19:02 New Zealand Standard Time INFO mail-api: [4a640f08] GET /auth/verify → 200 (0ms) +18/05/2026 21:19:02 New Zealand Standard Time INFO mail-api: [4c90dbf6] GET /owner/pending-onboarding → 200 (2ms) +18/05/2026 21:19:02 New Zealand Standard Time INFO mail-api: [1f509c67] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:19:02 New Zealand Standard Time INFO mail-api: [51382343] GET /owner/birthdays → 200 (7ms) +18/05/2026 21:19:10 New Zealand Standard Time INFO mail-api: [2e82354f] GET /owner/client-enquiry → 200 (1ms) +18/05/2026 21:20:40 New Zealand Standard Time INFO mail-api: [37a981ff] GET /auth/verify → 200 (0ms) +18/05/2026 21:20:40 New Zealand Standard Time INFO mail-api: [dfc92fce] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:20:40 New Zealand Standard Time INFO mail-api: [0feb7af7] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:20:40 New Zealand Standard Time INFO mail-api: [685a1be4] GET /owner/all-clients → 200 (2ms) +18/05/2026 21:20:47 New Zealand Standard Time INFO mail-api: [f52d193a] GET /auth/verify → 200 (0ms) +18/05/2026 21:20:47 New Zealand Standard Time INFO mail-api: [5da059ab] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:20:47 New Zealand Standard Time INFO mail-api: [a08a20a1] GET /owner/all-clients → 200 (0ms) +18/05/2026 21:20:47 New Zealand Standard Time INFO mail-api: [caf78fb9] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:20:52 New Zealand Standard Time INFO mail-api: [b980a727] GET /auth/verify → 200 (0ms) +18/05/2026 21:20:52 New Zealand Standard Time INFO mail-api: [6261daf9] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:20:52 New Zealand Standard Time INFO mail-api: [28597ae7] GET /owner/all-clients → 200 (7ms) +18/05/2026 21:20:52 New Zealand Standard Time INFO mail-api: [a4b8ff1a] GET /owner/birthdays → 200 (7ms) +18/05/2026 21:21:13 New Zealand Standard Time INFO mail-api: [3ce67fce] GET /auth/verify → 200 (0ms) +18/05/2026 21:21:13 New Zealand Standard Time INFO mail-api: [5638cdf4] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:21:13 New Zealand Standard Time INFO mail-api: [2b317150] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:21:13 New Zealand Standard Time INFO mail-api: [d901f1e9] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:21:18 New Zealand Standard Time INFO mail-api: [2b80392f] GET /auth/verify → 200 (0ms) +18/05/2026 21:21:18 New Zealand Standard Time INFO mail-api: [80a30bb3] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:21:18 New Zealand Standard Time INFO mail-api: [25400300] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:21:18 New Zealand Standard Time INFO mail-api: [d2439dff] GET /owner/birthdays → 200 (2ms) +18/05/2026 21:21:19 New Zealand Standard Time INFO mail-api: [fa179f96] GET /auth/verify → 200 (0ms) +18/05/2026 21:21:19 New Zealand Standard Time INFO mail-api: [28702419] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:21:19 New Zealand Standard Time INFO mail-api: [457a87dd] GET /owner/all-clients → 200 (0ms) +18/05/2026 21:21:19 New Zealand Standard Time INFO mail-api: [736e3b1d] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:21:29 New Zealand Standard Time INFO mail-api: [2e256a2d] GET /auth/verify → 200 (0ms) +18/05/2026 21:21:29 New Zealand Standard Time INFO mail-api: [e6c66558] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:21:29 New Zealand Standard Time INFO mail-api: [9585bade] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:21:29 New Zealand Standard Time INFO mail-api: [88fbfe22] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:21:35 New Zealand Standard Time INFO mail-api: [6ce9acb5] GET /auth/verify → 200 (0ms) +18/05/2026 21:21:35 New Zealand Standard Time INFO mail-api: [95b1ef6f] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:21:35 New Zealand Standard Time INFO mail-api: [cf8aa164] GET /owner/all-clients → 200 (1ms) +18/05/2026 21:21:35 New Zealand Standard Time INFO mail-api: [6fb3e3b2] GET /owner/birthdays → 200 (1ms) +18/05/2026 21:21:40 New Zealand Standard Time INFO mail-api: [0d99af34] GET /auth/verify → 200 (0ms) +18/05/2026 21:21:40 New Zealand Standard Time INFO mail-api: [6b65cc45] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 21:21:40 New Zealand Standard Time INFO mail-api: [e2bd16c3] GET /owner/all-clients → 200 (0ms) +18/05/2026 21:21:40 New Zealand Standard Time INFO mail-api: [91bd32df] GET /owner/birthdays → 200 (0ms) +18/05/2026 21:24:56 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:24:56 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:24:56 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:24:57 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:25:23 New Zealand Standard Time INFO mail-api: [0f034546] GET /auth/verify → 401 (2ms) +18/05/2026 21:40:40 New Zealand Standard Time INFO mail-api: [b7bb39f7] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 21:40:40 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 385438 +18/05/2026 21:40:40 New Zealand Standard Time INFO mail-api: [b7bb39f7] POST /auth/request-code → 200 (2ms) +18/05/2026 21:40:49 New Zealand Standard Time INFO mail-api: [fe40986f] auth: session created for email=info@goodwalk.co.nz +18/05/2026 21:40:49 New Zealand Standard Time INFO mail-api: [fe40986f] POST /auth/verify-code → 200 (2ms) +18/05/2026 21:40:49 New Zealand Standard Time INFO mail-api: [c205fac4] GET /auth/verify → 200 (0ms) +18/05/2026 21:40:49 New Zealand Standard Time INFO mail-api: [b86a53a9] GET /owner/pending-onboarding → 200 (7ms) +18/05/2026 21:40:49 New Zealand Standard Time INFO mail-api: [08603fea] GET /owner/all-clients → 200 (41ms) +18/05/2026 21:40:49 New Zealand Standard Time INFO mail-api: [4326158b] GET /owner/birthdays → 200 (42ms) +18/05/2026 21:40:51 New Zealand Standard Time INFO mail-api: [af8cfae2] GET /owner/message-templates → 200 (1ms) +18/05/2026 21:40:51 New Zealand Standard Time INFO mail-api: [5bf21ce9] POST /owner/render-message → 200 (2ms) +18/05/2026 21:41:01 New Zealand Standard Time INFO mail-api: [19e5ccc9] POST /owner/render-message → 200 (1ms) +18/05/2026 21:41:04 New Zealand Standard Time INFO mail-api: [d3a678ba] POST /owner/render-message → 200 (1ms) +18/05/2026 21:41:07 New Zealand Standard Time INFO mail-api: [50836f50] POST /owner/render-message → 200 (1ms) +18/05/2026 21:41:19 New Zealand Standard Time INFO mail-api: [c74652ab] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:22 New Zealand Standard Time INFO mail-api: [7ba09ffe] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:42 New Zealand Standard Time INFO mail-api: [c9990429] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:45 New Zealand Standard Time INFO mail-api: [4f6da927] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:50 New Zealand Standard Time INFO mail-api: [f9326ca8] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:51 New Zealand Standard Time INFO mail-api: [f4511515] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:54 New Zealand Standard Time INFO mail-api: [0babd26a] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:55 New Zealand Standard Time INFO mail-api: [a5a6bf91] POST /owner/render-message → 200 (1ms) +18/05/2026 21:42:57 New Zealand Standard Time INFO mail-api: [cf902b5b] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:00 New Zealand Standard Time INFO mail-api: [504539ca] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:01 New Zealand Standard Time INFO mail-api: [5b0ab892] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:02 New Zealand Standard Time INFO mail-api: [4bee8880] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:03 New Zealand Standard Time INFO mail-api: [5dc98dff] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:03 New Zealand Standard Time INFO mail-api: [d7eacbb9] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:04 New Zealand Standard Time INFO mail-api: [00536acf] POST /owner/render-message → 200 (1ms) +18/05/2026 21:43:05 New Zealand Standard Time INFO mail-api: [9e6b3996] POST /owner/render-message → 200 (1ms) +18/05/2026 21:45:01 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:45:01 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:45:01 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:45:01 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:45:10 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:45:10 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:45:10 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:45:10 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:45:16 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:45:16 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:45:16 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:45:17 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:45:22 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:45:22 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:45:22 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:45:22 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:45:28 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:45:28 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:45:28 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:45:28 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:45:44 New Zealand Standard Time INFO mail-api: [243808e5] GET /auth/verify → 401 (2ms) +18/05/2026 21:49:29 New Zealand Standard Time INFO mail-api: [f56403cb] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 21:49:29 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 883753 +18/05/2026 21:49:29 New Zealand Standard Time INFO mail-api: [f56403cb] POST /auth/request-code → 200 (2ms) +18/05/2026 21:49:39 New Zealand Standard Time INFO mail-api: [ce0f4af4] auth: session created for email=info@goodwalk.co.nz +18/05/2026 21:49:39 New Zealand Standard Time INFO mail-api: [ce0f4af4] POST /auth/verify-code → 200 (1ms) +18/05/2026 21:49:39 New Zealand Standard Time INFO mail-api: [dad1ace5] GET /auth/verify → 200 (0ms) +18/05/2026 21:49:39 New Zealand Standard Time INFO mail-api: [042f262b] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 21:49:39 New Zealand Standard Time INFO mail-api: [baa63647] GET /owner/birthdays → 200 (4ms) +18/05/2026 21:49:39 New Zealand Standard Time INFO mail-api: [0900a839] GET /owner/all-clients → 200 (4ms) +18/05/2026 21:49:41 New Zealand Standard Time INFO mail-api: [34995134] GET /owner/message-templates → 200 (1ms) +18/05/2026 21:49:41 New Zealand Standard Time INFO mail-api: [e7ef632c] POST /owner/render-message → 200 (1ms) +18/05/2026 21:49:55 New Zealand Standard Time INFO mail-api: [5d8bea9e] POST /owner/render-message → 200 (1ms) +18/05/2026 21:49:58 New Zealand Standard Time INFO mail-api: [ccb95237] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:02 New Zealand Standard Time INFO mail-api: [c8bc8cbb] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:04 New Zealand Standard Time INFO mail-api: [2b846861] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:05 New Zealand Standard Time INFO mail-api: [ff28918c] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:06 New Zealand Standard Time INFO mail-api: [68a9d932] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:07 New Zealand Standard Time INFO mail-api: [490db64c] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:07 New Zealand Standard Time INFO mail-api: [260394e8] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:08 New Zealand Standard Time INFO mail-api: [d5bc9166] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:09 New Zealand Standard Time INFO mail-api: [1617ac95] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:10 New Zealand Standard Time INFO mail-api: [c109644f] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:10 New Zealand Standard Time INFO mail-api: [bcaaa153] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:11 New Zealand Standard Time INFO mail-api: [f09575d9] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:11 New Zealand Standard Time INFO mail-api: [3f98696c] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:12 New Zealand Standard Time INFO mail-api: [3f22efd6] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:12 New Zealand Standard Time INFO mail-api: [15105bdd] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:13 New Zealand Standard Time INFO mail-api: [791269c7] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:15 New Zealand Standard Time INFO mail-api: [6e559c4f] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:16 New Zealand Standard Time INFO mail-api: [4ae095e2] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:16 New Zealand Standard Time INFO mail-api: [ed175075] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:21 New Zealand Standard Time INFO mail-api: [bc939b9b] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:26 New Zealand Standard Time INFO mail-api: [e57853ee] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:28 New Zealand Standard Time INFO mail-api: [73ae8c36] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:29 New Zealand Standard Time INFO mail-api: [6fb85ae6] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:30 New Zealand Standard Time INFO mail-api: [772b2fc0] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:31 New Zealand Standard Time INFO mail-api: [d1400560] POST /owner/render-message → 200 (1ms) +18/05/2026 21:50:31 New Zealand Standard Time INFO mail-api: [2e3e329f] POST /owner/render-message → 200 (1ms) +18/05/2026 21:51:08 New Zealand Standard Time INFO mail-api: [22e673fa] POST /owner/render-message → 200 (1ms) +18/05/2026 21:53:17 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:53:17 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:53:17 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:53:17 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:53:23 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:53:23 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:53:23 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:53:23 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:53:29 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:53:29 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:53:29 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:53:29 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:53:40 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:53:40 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:53:40 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:53:40 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:53:45 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:53:45 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:53:45 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:53:45 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:53:53 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:53:53 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:53:53 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:53:53 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:05 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:05 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:05 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:08 New Zealand Standard Time INFO mail-api: [f2801825] POST /owner/render-message → 401 (2ms) +18/05/2026 21:54:09 New Zealand Standard Time INFO mail-api: [716fc567] POST /owner/render-message → 401 (1ms) +18/05/2026 21:54:11 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:11 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:11 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:11 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:19 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:19 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:19 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:19 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:24 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:24 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:24 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:24 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:31 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:31 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:31 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:31 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:50 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:54:55 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:54:55 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:54:55 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:54:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:55:05 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:55:05 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:55:05 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:55:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:55:11 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:55:11 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:55:11 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:55:11 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:55:17 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:55:17 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:55:17 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:55:17 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:55:22 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:55:22 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:55:22 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:55:22 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 21:55:37 New Zealand Standard Time INFO mail-api: [fd23f58f] GET /auth/verify → 401 (2ms) +18/05/2026 21:57:57 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 21:57:57 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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 21:57:57 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 21:57:57 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:02:17 New Zealand Standard Time INFO mail-api: [d3987921] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 22:02:17 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 446406 +18/05/2026 22:02:17 New Zealand Standard Time INFO mail-api: [d3987921] POST /auth/request-code → 200 (3ms) +18/05/2026 22:02:26 New Zealand Standard Time INFO mail-api: [42ed4fcc] auth: session created for email=info@goodwalk.co.nz +18/05/2026 22:02:26 New Zealand Standard Time INFO mail-api: [42ed4fcc] POST /auth/verify-code → 200 (1ms) +18/05/2026 22:02:26 New Zealand Standard Time INFO mail-api: [3a64dfe9] GET /auth/verify → 200 (1ms) +18/05/2026 22:02:26 New Zealand Standard Time INFO mail-api: [9619f6a5] GET /owner/pending-onboarding → 200 (2ms) +18/05/2026 22:02:26 New Zealand Standard Time INFO mail-api: [f0a7d3ea] GET /owner/all-clients → 200 (2ms) +18/05/2026 22:02:26 New Zealand Standard Time INFO mail-api: [c60b4978] GET /owner/birthdays → 200 (3ms) +18/05/2026 22:02:27 New Zealand Standard Time INFO mail-api: [e84fe138] GET /owner/message-templates → 200 (1ms) +18/05/2026 22:02:28 New Zealand Standard Time INFO mail-api: [3e1936de] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:32 New Zealand Standard Time INFO mail-api: [ffc1231f] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:35 New Zealand Standard Time INFO mail-api: [bfd4c262] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:36 New Zealand Standard Time INFO mail-api: [51893bc5] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:37 New Zealand Standard Time INFO mail-api: [422a720d] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:39 New Zealand Standard Time INFO mail-api: [4b4c9a56] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:40 New Zealand Standard Time INFO mail-api: [09b93c27] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:41 New Zealand Standard Time INFO mail-api: [631145b2] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:42 New Zealand Standard Time INFO mail-api: [092cee24] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:43 New Zealand Standard Time INFO mail-api: [e7849f4a] POST /owner/render-message → 200 (1ms) +18/05/2026 22:02:44 New Zealand Standard Time INFO mail-api: [abc31513] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:45 New Zealand Standard Time INFO mail-api: [2501eac6] GET /auth/verify → 200 (0ms) +18/05/2026 22:03:45 New Zealand Standard Time INFO mail-api: [9cc74cb0] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:03:45 New Zealand Standard Time INFO mail-api: [dc9f5af3] GET /owner/all-clients → 200 (2ms) +18/05/2026 22:03:45 New Zealand Standard Time INFO mail-api: [eb897c58] GET /owner/birthdays → 200 (2ms) +18/05/2026 22:03:47 New Zealand Standard Time INFO mail-api: [c32725ec] GET /owner/message-templates → 200 (0ms) +18/05/2026 22:03:47 New Zealand Standard Time INFO mail-api: [5d73c228] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:51 New Zealand Standard Time INFO mail-api: [4510ab41] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:52 New Zealand Standard Time INFO mail-api: [2674cfba] GET /auth/verify → 200 (0ms) +18/05/2026 22:03:52 New Zealand Standard Time INFO mail-api: [7eccf12f] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:03:52 New Zealand Standard Time INFO mail-api: [03d61041] GET /owner/all-clients → 200 (2ms) +18/05/2026 22:03:52 New Zealand Standard Time INFO mail-api: [02e60b2b] GET /owner/birthdays → 200 (2ms) +18/05/2026 22:03:52 New Zealand Standard Time INFO mail-api: [b4d043a7] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:53 New Zealand Standard Time INFO mail-api: [53d554e5] GET /owner/message-templates → 200 (0ms) +18/05/2026 22:03:53 New Zealand Standard Time INFO mail-api: [3e72ae62] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:54 New Zealand Standard Time INFO mail-api: [174c3dc6] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:55 New Zealand Standard Time INFO mail-api: [64e3da9a] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:56 New Zealand Standard Time INFO mail-api: [caef5141] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:56 New Zealand Standard Time INFO mail-api: [feadca97] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:57 New Zealand Standard Time INFO mail-api: [e40cdd03] POST /owner/render-message → 200 (1ms) +18/05/2026 22:03:58 New Zealand Standard Time INFO mail-api: [96dc0e55] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:00 New Zealand Standard Time INFO mail-api: [adaa22dc] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:05 New Zealand Standard Time INFO mail-api: [19c8aa44] GET /auth/verify → 200 (0ms) +18/05/2026 22:04:05 New Zealand Standard Time INFO mail-api: [cc42c1c3] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:04:05 New Zealand Standard Time INFO mail-api: [c48db649] GET /owner/birthdays → 200 (1ms) +18/05/2026 22:04:05 New Zealand Standard Time INFO mail-api: [e7bccf6d] GET /owner/all-clients → 200 (2ms) +18/05/2026 22:04:06 New Zealand Standard Time INFO mail-api: [c2fd602d] GET /owner/message-templates → 200 (1ms) +18/05/2026 22:04:06 New Zealand Standard Time INFO mail-api: [cc8d6010] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:13 New Zealand Standard Time INFO mail-api: [f02417df] GET /auth/verify → 200 (0ms) +18/05/2026 22:04:13 New Zealand Standard Time INFO mail-api: [ed577242] GET /owner/all-clients → 200 (1ms) +18/05/2026 22:04:13 New Zealand Standard Time INFO mail-api: [460c1b7e] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:04:13 New Zealand Standard Time INFO mail-api: [84ab3426] GET /owner/birthdays → 200 (2ms) +18/05/2026 22:04:14 New Zealand Standard Time INFO mail-api: [4dd7449e] GET /owner/message-templates → 200 (0ms) +18/05/2026 22:04:14 New Zealand Standard Time INFO mail-api: [4dd726f6] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:20 New Zealand Standard Time INFO mail-api: [21ad2f87] GET /auth/verify → 200 (0ms) +18/05/2026 22:04:20 New Zealand Standard Time INFO mail-api: [09556113] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:04:20 New Zealand Standard Time INFO mail-api: [242520ca] GET /owner/all-clients → 200 (2ms) +18/05/2026 22:04:20 New Zealand Standard Time INFO mail-api: [59a09481] GET /owner/birthdays → 200 (2ms) +18/05/2026 22:04:22 New Zealand Standard Time INFO mail-api: [232a5c2f] GET /owner/message-templates → 200 (1ms) +18/05/2026 22:04:22 New Zealand Standard Time INFO mail-api: [d779f7eb] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:26 New Zealand Standard Time INFO mail-api: [425b6f33] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:27 New Zealand Standard Time INFO mail-api: [c96fd9c0] GET /auth/verify → 200 (0ms) +18/05/2026 22:04:27 New Zealand Standard Time INFO mail-api: [c464d744] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:04:27 New Zealand Standard Time INFO mail-api: [3415a88d] GET /owner/all-clients → 200 (1ms) +18/05/2026 22:04:27 New Zealand Standard Time INFO mail-api: [ae48672f] GET /owner/birthdays → 200 (1ms) +18/05/2026 22:04:28 New Zealand Standard Time INFO mail-api: [2f489281] GET /owner/message-templates → 200 (0ms) +18/05/2026 22:04:28 New Zealand Standard Time INFO mail-api: [7ac11738] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:32 New Zealand Standard Time INFO mail-api: [b8595e12] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:36 New Zealand Standard Time INFO mail-api: [043fe31d] POST /owner/render-message → 200 (1ms) +18/05/2026 22:04:42 New Zealand Standard Time INFO mail-api: [6224e819] POST /owner/render-message → 200 (1ms) +18/05/2026 22:05:19 New Zealand Standard Time INFO mail-api: [165e8ff0] GET /auth/verify → 200 (0ms) +18/05/2026 22:05:19 New Zealand Standard Time INFO mail-api: [32f4302b] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:05:19 New Zealand Standard Time INFO mail-api: [7c3c5093] GET /owner/birthdays → 200 (1ms) +18/05/2026 22:05:19 New Zealand Standard Time INFO mail-api: [bfcb3aea] GET /owner/all-clients → 200 (1ms) +18/05/2026 22:05:27 New Zealand Standard Time INFO mail-api: [151a6837] GET /owner/message-templates → 200 (1ms) +18/05/2026 22:05:27 New Zealand Standard Time INFO mail-api: [5b4d9be1] POST /owner/render-message → 200 (1ms) +18/05/2026 22:05:29 New Zealand Standard Time INFO mail-api: [3bd9870a] POST /owner/render-message → 200 (1ms) +18/05/2026 22:05:42 New Zealand Standard Time INFO mail-api: [c4334bc7] POST /owner/render-message → 200 (1ms) +18/05/2026 22:06:01 New Zealand Standard Time INFO mail-api: [7d518671] POST /owner/render-message → 200 (1ms) +18/05/2026 22:06:23 New Zealand Standard Time INFO mail-api: [c54e8db7] GET /auth/verify → 200 (0ms) +18/05/2026 22:06:23 New Zealand Standard Time INFO mail-api: [8d7ff877] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 22:06:23 New Zealand Standard Time INFO mail-api: [a82a763b] GET /owner/all-clients → 200 (0ms) +18/05/2026 22:06:23 New Zealand Standard Time INFO mail-api: [cdb173ef] GET /owner/birthdays → 200 (1ms) +18/05/2026 22:06:39 New Zealand Standard Time INFO mail-api: [3ba9048f] GET /owner/message-templates → 200 (1ms) +18/05/2026 22:06:39 New Zealand Standard Time INFO mail-api: [899d7c2b] POST /owner/render-message → 200 (1ms) +18/05/2026 22:06:49 New Zealand Standard Time INFO mail-api: [e62f4b3d] GET /auth/verify → 200 (0ms) +18/05/2026 22:06:49 New Zealand Standard Time INFO mail-api: [490d999e] GET /owner/pending-onboarding → 200 (1ms) +18/05/2026 22:06:49 New Zealand Standard Time INFO mail-api: [16e7b9e7] GET /owner/all-clients → 200 (1ms) +18/05/2026 22:06:49 New Zealand Standard Time INFO mail-api: [f694cf58] GET /owner/birthdays → 200 (0ms) +18/05/2026 22:06:50 New Zealand Standard Time INFO mail-api: [c25e9d75] GET /owner/message-templates → 200 (0ms) +18/05/2026 22:06:50 New Zealand Standard Time INFO mail-api: [4e044a63] POST /owner/render-message → 200 (1ms) +18/05/2026 22:06:57 New Zealand Standard Time INFO mail-api: [fea631bf] POST /owner/render-message → 200 (1ms) +18/05/2026 22:06:58 New Zealand Standard Time INFO mail-api: [8d4ef1de] POST /owner/render-message → 200 (1ms) +18/05/2026 22:06:59 New Zealand Standard Time INFO mail-api: [beb0b74b] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:03 New Zealand Standard Time INFO mail-api: [1b6f4150] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:04 New Zealand Standard Time INFO mail-api: [39e306bb] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:04 New Zealand Standard Time INFO mail-api: [22dbb3e5] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:06 New Zealand Standard Time INFO mail-api: [53b57eef] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:42 New Zealand Standard Time INFO mail-api: [0c6ef29d] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:49 New Zealand Standard Time INFO mail-api: [8ac289bc] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:50 New Zealand Standard Time INFO mail-api: [f0d698bf] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:52 New Zealand Standard Time INFO mail-api: [7f9dfb4c] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:53 New Zealand Standard Time INFO mail-api: [697dc993] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:55 New Zealand Standard Time INFO mail-api: [bad945ae] POST /owner/render-message → 200 (1ms) +18/05/2026 22:07:56 New Zealand Standard Time INFO mail-api: [ede60297] POST /owner/render-message → 200 (1ms) +18/05/2026 22:08:04 New Zealand Standard Time INFO mail-api: [c8fa28e1] GET /auth/verify → 200 (0ms) +18/05/2026 22:08:04 New Zealand Standard Time INFO mail-api: [5fa0555e] GET /owner/pending-onboarding → 200 (0ms) +18/05/2026 22:08:04 New Zealand Standard Time INFO mail-api: [8ca5f4e3] GET /owner/all-clients → 200 (0ms) +18/05/2026 22:08:04 New Zealand Standard Time INFO mail-api: [c7e01ddb] GET /owner/birthdays → 200 (1ms) +18/05/2026 22:08:05 New Zealand Standard Time INFO mail-api: [f2d91d1a] GET /owner/message-templates → 200 (0ms) +18/05/2026 22:08:05 New Zealand Standard Time INFO mail-api: [e058c20c] POST /owner/render-message → 200 (1ms) +18/05/2026 22:08:46 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:08:46 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:08:46 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:08:46 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:08:51 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:08:51 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:08:51 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:08:51 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:08 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:08 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:08 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:08 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:12 New Zealand Standard Time INFO mail-api: [451d7d58] POST /owner/render-message → 401 (2ms) +18/05/2026 22:09:15 New Zealand Standard Time INFO mail-api: [44385eaa] POST /owner/render-message → 401 (1ms) +18/05/2026 22:09:15 New Zealand Standard Time INFO mail-api: [42185223] POST /owner/render-message → 401 (1ms) +18/05/2026 22:09:16 New Zealand Standard Time INFO mail-api: [5e5d1aeb] POST /owner/render-message → 401 (1ms) +18/05/2026 22:09:17 New Zealand Standard Time INFO mail-api: [2d033632] POST /owner/render-message → 401 (1ms) +18/05/2026 22:09:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:18 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:18 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:18 New Zealand Standard Time INFO mail-api: [1bcf5c1c] POST /owner/render-message → 401 (3ms) +18/05/2026 22:09:18 New Zealand Standard Time INFO mail-api: [0767d344] POST /owner/render-message → 401 (4ms) +18/05/2026 22:09:23 New Zealand Standard Time INFO mail-api: [46cff29d] GET /auth/verify → 401 (0ms) +18/05/2026 22:09:26 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:26 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:26 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:26 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:31 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:31 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:31 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:31 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:36 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:43 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:43 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:43 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:43 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:48 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:48 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:48 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:48 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:09:57 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:09:57 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:09:57 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:09:57 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:10:03 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:10:03 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:10:03 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:10:03 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:10:08 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:10:08 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:10:08 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:10:08 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:10:48 New Zealand Standard Time INFO mail-api: [5a9f3438] auth: code issued for email=info@goodwalk.co.nz +18/05/2026 22:10:48 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 790020 +18/05/2026 22:10:48 New Zealand Standard Time INFO mail-api: [5a9f3438] POST /auth/request-code → 200 (3ms) +18/05/2026 22:19:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:19:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:19:50 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:19:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:20:01 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:20:01 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:20:01 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:20:02 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:20:31 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:20:31 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:20:40 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:20:40 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:20:40 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:20:40 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:20:48 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:20:48 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:20:48 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:20:48 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:21:03 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:21:03 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:21:03 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:21:03 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:21:07 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:21:07 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:21:07 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:21:07 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +18/05/2026 22:21:28 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +18/05/2026 22:21:28 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' 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:21:28 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s) +18/05/2026 22:21:28 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address diff --git a/mail-api/main.py b/mail-api/main.py index 3589b92..e0f791f 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -1,4 +1,5 @@ import asyncio +import base64 from collections import deque import json import logging @@ -10,14 +11,18 @@ import secrets import sys import time import uuid -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path +from typing import Any import resend from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import Response from pydantic import BaseModel, EmailStr +import db as admin_db + # ── Logging ────────────────────────────────────────────────────────────────── @@ -133,6 +138,7 @@ 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)))) _ALLOWED_EMAILS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "allowed_emails.json" _CLIENT_PROFILES_FILE = Path(os.environ.get("DATA_DIR", "data")) / "client_profiles.json" _DRAFTS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "drafts.json" @@ -177,7 +183,7 @@ STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "exampl # ── Auth state ─────────────────────────────────────────────────────────────── -def _load_allowed_emails() -> set[str]: +def _load_allowed_emails_from_file() -> set[str]: seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()} try: if _ALLOWED_EMAILS_FILE.exists(): @@ -187,17 +193,18 @@ def _load_allowed_emails() -> set[str]: logger.warning("Could not load allowed_emails file: %s", exc) return seed -def _save_allowed_emails_sync(emails: set[str]) -> None: + +def _save_allowed_emails_file(emails: set[str]) -> None: try: _ALLOWED_EMAILS_FILE.parent.mkdir(parents=True, exist_ok=True) _ALLOWED_EMAILS_FILE.write_text( json.dumps({"emails": sorted(emails)}, indent=2), encoding="utf-8" ) except Exception as exc: - logger.warning("Could not save allowed_emails: %s", exc) + logger.warning("Could not save allowed_emails file: %s", exc) -def _load_client_profiles() -> dict[str, dict]: +def _load_client_profiles_from_file() -> dict[str, dict]: try: if _CLIENT_PROFILES_FILE.exists(): return json.loads(_CLIENT_PROFILES_FILE.read_text(encoding="utf-8")) @@ -206,15 +213,15 @@ def _load_client_profiles() -> dict[str, dict]: return {} -def _save_client_profiles_sync(profiles: dict) -> None: +def _save_client_profiles_file(profiles: dict) -> None: try: _CLIENT_PROFILES_FILE.parent.mkdir(parents=True, exist_ok=True) _CLIENT_PROFILES_FILE.write_text(json.dumps(profiles, indent=2), encoding="utf-8") except Exception as exc: - logger.warning("Could not save client_profiles: %s", exc) + logger.warning("Could not save client_profiles file: %s", exc) -def _load_drafts() -> dict: +def _load_drafts_from_file() -> dict: try: if _DRAFTS_FILE.exists(): return json.loads(_DRAFTS_FILE.read_text(encoding="utf-8")) @@ -223,27 +230,142 @@ def _load_drafts() -> dict: return {} -def _save_drafts_sync(drafts: dict) -> None: +def _save_drafts_file(drafts: dict) -> None: try: _DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True) _DRAFTS_FILE.write_text(json.dumps(drafts, indent=2), encoding="utf-8") except Exception as exc: - logger.warning("Could not save drafts: %s", exc) + logger.warning("Could not save drafts file: %s", exc) -_allowed_emails: set[str] = _load_allowed_emails() +# Backwards-compatible synchronous wrappers used by asyncio.to_thread call sites +# elsewhere in the module. They write to the JSON file. Postgres persistence is +# done asynchronously alongside the file write — see _persist_admin_state. +def _save_allowed_emails_sync(emails: set[str]) -> None: + _save_allowed_emails_file(emails) + + +def _save_client_profiles_sync(profiles: dict) -> None: + _save_client_profiles_file(profiles) + + +def _save_drafts_sync(drafts: dict) -> None: + _save_drafts_file(drafts) + + +async def _persist_admin_state(key: str, value: Any) -> None: + """Write a single admin_kv blob to postgres when the database is available.""" + try: + await admin_db.set_kv(key, value) + except Exception as exc: + logger.warning("Postgres persist (%s) failed; JSON copy is still authoritative: %s", key, exc) + + +async def _seed_admin_state_from_json_if_needed() -> None: + """Seed admin_kv from the JSON files on disk. + + Controlled by ADMIN_DATA_SEED_FROM_JSON: + - "never": do nothing + - "auto": seed only when admin_kv has no rows yet (default, safe on every boot) + - "force": overwrite postgres with whatever the JSON files currently hold + + The deployer exposes -SeedAdminData which sets this to "force" for one boot. + """ + mode = (os.environ.get("ADMIN_DATA_SEED_FROM_JSON", "auto") or "auto").strip().lower() + if mode == "never": + return + if not admin_db.is_enabled(): + return + try: + if mode == "auto" and await admin_db.has_any_value(): + return + seed_clients = _load_client_profiles_from_file() + seed_emails = sorted(_load_allowed_emails_from_file()) + seed_drafts = _load_drafts_from_file() + if not seed_clients and not seed_emails and not seed_drafts: + return + if seed_clients: + await admin_db.set_kv("client_profiles", seed_clients) + if seed_emails: + await admin_db.set_kv("allowed_emails", {"emails": seed_emails}) + if seed_drafts: + await admin_db.set_kv("drafts", seed_drafts) + logger.info( + "Seeded admin_kv from JSON (mode=%s): clients=%d emails=%d drafts=%d", + mode, len(seed_clients), len(seed_emails), len(seed_drafts), + ) + except Exception as exc: + logger.warning("Admin seed from JSON failed: %s", exc) + + +async def _load_allowed_emails_async() -> set[str]: + if admin_db.is_enabled(): + data = await admin_db.get_kv("allowed_emails") + if isinstance(data, dict): + emails = data.get("emails", []) + if isinstance(emails, list): + seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()} + seed.update(e.lower() for e in emails if isinstance(e, str)) + return seed + return _load_allowed_emails_from_file() + + +async def _load_client_profiles_async() -> dict[str, dict]: + if admin_db.is_enabled(): + data = await admin_db.get_kv("client_profiles") + if isinstance(data, dict): + return data + return _load_client_profiles_from_file() + + +async def _load_drafts_async() -> dict: + if admin_db.is_enabled(): + data = await admin_db.get_kv("drafts") + if isinstance(data, dict): + return data + return _load_drafts_from_file() + + +_allowed_emails: set[str] = _load_allowed_emails_from_file() +if OWNER_EMAIL: + _allowed_emails.add(OWNER_EMAIL.strip().lower()) _pending_codes: dict[str, dict] = {} # email -> {code, expires_at, attempts} _active_sessions: dict[str, dict] = {} # token -> {email, expires_at} _code_requests: dict[str, deque] = {} # email -> deque of monotonic timestamps -_client_profiles: dict[str, dict] = _load_client_profiles() -_drafts: dict[str, dict] = _load_drafts() # email -> {onboarding: {...}, contract: {...}} +_client_profiles: dict[str, dict] = _load_client_profiles_from_file() +_drafts: dict[str, dict] = _load_drafts_from_file() # email -> {onboarding: {...}, contract: {...}} _auth_failures_by_ip: dict[str, deque] = {} # ip -> deque of failure timestamps _blocked_ips: dict[str, float] = {} # ip -> unblock_at (monotonic) _auth_lock = asyncio.Lock() +_birthday_auto_task: asyncio.Task | None = None logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails)) +async def _require_session_email(request: Request) -> str: + auth_header = request.headers.get("Authorization", "") + token = auth_header.removeprefix("Bearer ").strip() or _trimmed(request.query_params.get("token", "")) + + if not token: + raise HTTPException(status_code=401, detail="No token provided.") + + async with _auth_lock: + session = _active_sessions.get(token) + if not session: + raise HTTPException(status_code=401, detail="Invalid session.") + if time.time() > session["expires_at"]: + _active_sessions.pop(token, None) + raise HTTPException(status_code=401, detail="Session expired. Please sign in again.") + return session["email"] + + +async def _require_owner_email(request: Request) -> str: + email = await _require_session_email(request) + if email != OWNER_EMAIL.strip().lower(): + raise HTTPException(status_code=403, detail="Owner access required.") + return email + + async def _register_email(email: str) -> None: normalized = email.strip().lower() if not normalized: @@ -251,7 +373,9 @@ async def _register_email(email: str) -> None: async with _auth_lock: if normalized not in _allowed_emails: _allowed_emails.add(normalized) + snapshot = sorted(_allowed_emails) await asyncio.to_thread(_save_allowed_emails_sync, set(_allowed_emails)) + await _persist_admin_state("allowed_emails", {"emails": snapshot}) logger.info("Auth: registered new allowed email: %s", normalized) @@ -261,10 +385,16 @@ async def _store_client_profile(email: str, profile: dict) -> None: return async with _auth_lock: existing = _client_profiles.get(normalized, {}) - merged = {k: v for k, v in {**existing, **profile}.items() if v} + merged = { + k: v + for k, v in {**existing, **profile}.items() + if v is not None and not (isinstance(v, str) and v == "") + } if merged != existing: _client_profiles[normalized] = merged - await asyncio.to_thread(_save_client_profiles_sync, dict(_client_profiles)) + snapshot = dict(_client_profiles) + await asyncio.to_thread(_save_client_profiles_sync, snapshot) + await _persist_admin_state("client_profiles", snapshot) def _check_ip_blocked(ip: str, request_id: str) -> None: now = time.monotonic() @@ -370,6 +500,25 @@ class OnboardingSubmission(BaseSubmission): 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): @@ -1374,6 +1523,50 @@ def owner_onboarding_email(data: OnboardingSubmission, ip: str, browser: str) -> """ +def _birthday_ics_attachment(dog_name: str, dog_birth_date: str, owner_name: str, request_id: str) -> dict | None: + dog_name_clean = _trimmed(dog_name) + birth_date_clean = _trimmed(dog_birth_date) + owner_name_clean = _trimmed(owner_name) + + if not dog_name_clean or not birth_date_clean: + return None + + try: + starts_on = datetime.strptime(birth_date_clean, "%Y-%m-%d").date() + except ValueError: + logger.warning("[%s] onboarding birthday calendar skipped: invalid dogAge=%r", request_id, dog_birth_date) + return None + + ends_on = starts_on + timedelta(days=1) + safe_name = re.sub(r"[^a-z0-9]+", "-", dog_name_clean.lower()).strip("-") or "dog" + summary = f"{dog_name_clean}'s Birthday" + description = f"GoodWalk reminder: {dog_name_clean}'s birthday." + calendar_name = summary if not owner_name_clean else f"{summary} for {owner_name_clean}" + ics_body = ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//GoodWalk//Dog Birthday Reminder//EN\r\n" + "CALSCALE:GREGORIAN\r\n" + "METHOD:PUBLISH\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uuid.uuid4()}@goodwalk.co.nz\r\n" + f"DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}\r\n" + f"DTSTART;VALUE=DATE:{starts_on.strftime('%Y%m%d')}\r\n" + f"DTEND;VALUE=DATE:{ends_on.strftime('%Y%m%d')}\r\n" + "RRULE:FREQ=YEARLY\r\n" + f"SUMMARY:{summary}\r\n" + f"DESCRIPTION:{description}\r\n" + f"X-WR-CALNAME:{calendar_name}\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" + ) + + return { + "filename": f"goodwalk-{safe_name}-birthday.ics", + "content": base64.b64encode(ics_body.encode("utf-8")).decode("ascii"), + } + + # ── Sending with retries ───────────────────────────────────────────────────── async def _send_email(payload: dict, label: str, request_id: str) -> dict: @@ -1478,10 +1671,49 @@ async def _send_startup_test_email() -> None: @app.on_event("startup") async def _startup_mail_check() -> None: + global _birthday_auto_task, _allowed_emails, _client_profiles, _drafts + + # 1. Seed postgres from JSON if admin_kv is empty (one-time migration). + await _seed_admin_state_from_json_if_needed() + + # 2. Refresh the in-memory caches from postgres so the app reads the + # canonical dataset even after restarts. + if admin_db.is_enabled(): + try: + db_clients = await _load_client_profiles_async() + if isinstance(db_clients, dict): + _client_profiles = db_clients + db_emails = await _load_allowed_emails_async() + if isinstance(db_emails, set): + _allowed_emails = db_emails + if OWNER_EMAIL: + _allowed_emails.add(OWNER_EMAIL.strip().lower()) + db_drafts = await _load_drafts_async() + if isinstance(db_drafts, dict): + _drafts = db_drafts + logger.info("Admin state refreshed from postgres: clients=%d emails=%d drafts=%d", + len(_client_profiles), len(_allowed_emails), len(_drafts)) + except Exception: + logger.exception("Admin state refresh from postgres failed; using JSON snapshot") + try: await _send_startup_test_email() except Exception: logger.exception("Startup test email failed") + if _birthday_auto_task is None or _birthday_auto_task.done(): + _birthday_auto_task = asyncio.create_task(_birthday_auto_sender_loop()) + + +@app.on_event("shutdown") +async def _shutdown_background_tasks() -> None: + global _birthday_auto_task + if _birthday_auto_task is not None: + _birthday_auto_task.cancel() + try: + await _birthday_auto_task + except asyncio.CancelledError: + pass + _birthday_auto_task = None @app.get("/health") async def health() -> dict: @@ -1532,6 +1764,218 @@ def _auth_code_email(email: str, code: str) -> str: """ +def _format_date_label(value: str) -> str: + raw = _trimmed(value) + if not raw: + return "To be confirmed" + try: + parsed = datetime.fromisoformat(raw) + return f"{parsed.day} {parsed.strftime('%b %Y')}" + except ValueError: + return raw + + +def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str) -> str: + first_name = client_name.split()[0] if client_name.strip() else "there" + dog_line = f" for {dog_name}" if dog_name.strip() else "" + formatted_start_date = _format_date_label(start_date) + + return f""" + + + + + Welcome to the pack + + + + +
+ + + + + + + +
+ Goodwalk +
+
+ Welcome to the pack +
+

+ Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk. +

+

+ We’ve set aside the details below{dog_line}. When you’re ready, complete your onboarding form and we’ll take it from there. +

+ + + +
+ + {_detail_row("Service", service_type)} + {_detail_row("Price", price_details)} + {_detail_row("Start date", formatted_start_date)} +
+
+ + + Complete onboarding + + +

+ Use the same email address you originally used with Goodwalk. We’ll send you a one-time code when you sign in. +

+
+
+ +""" + + +def _birthday_email_html(client_name: str, dog_name: str) -> str: + first_name = client_name.split()[0] if client_name.strip() else "there" + dog_name_clean = dog_name.strip() or "your dog" + + return f""" + + + + + Happy birthday from Goodwalk + + + + +
+ + + + + + + +
+ Goodwalk +
+
+ Happy birthday +
+

+ Happy birthday to {dog_name_clean}. +

+

+ Hi {first_name}, sending a little birthday love from all of us at Goodwalk. We hope {dog_name_clean} has a very good day. +

+

+ Aless and the Goodwalk pack +

+
+
+ +""" + + +def _upcoming_birthday_date(dog_birth_date: str, today: datetime | None = None): + raw = _trimmed(dog_birth_date) + if not raw: + return None + + try: + birth_date = datetime.strptime(raw, "%Y-%m-%d").date() + except ValueError: + return None + + today_date = (today or datetime.now()).date() + target_year = today_date.year + + while True: + try: + candidate = birth_date.replace(year=target_year) + break + except ValueError: + # Handle 29 Feb birthdays by moving them to 28 Feb on non-leap years. + candidate = birth_date.replace(year=target_year, month=2, day=28) + break + + if candidate < today_date: + target_year += 1 + try: + candidate = birth_date.replace(year=target_year) + except ValueError: + candidate = birth_date.replace(year=target_year, month=2, day=28) + + return candidate + + +async def _send_birthday_email_for_profile(email: str, profile: dict, request_id: str, mark_auto_year: int | None = None, preview: bool = False) -> None: + client_name = str(profile.get("fullName", "")).strip() + dog_name = str(profile.get("dogName", "")).strip() + recipient = OWNER_EMAIL.strip().lower() if preview else email + subject = f"Happy birthday {dog_name or 'from Goodwalk'}" + if preview: + subject = f"[PREVIEW for {client_name or email}] {subject}" + payload = { + "from": FROM_EMAIL, + "to": [recipient], + "reply_to": REPLY_TO, + "subject": subject, + "html": _birthday_email_html(client_name, dog_name), + } + if CLIENT_BCC and not preview: + payload["bcc"] = [CLIENT_BCC] + + await _send_email(payload, label="birthday_email_preview" if preview else "birthday_email", request_id=request_id) + + if preview: + return + + profile_update = { + "birthdayEmailLastSentAt": datetime.now().isoformat(timespec="seconds"), + } + if mark_auto_year is not None: + profile_update["birthdayEmailLastSentYear"] = str(mark_auto_year) + await _store_client_profile(email, profile_update) + + +async def _run_birthday_auto_sender_once() -> None: + today = datetime.now().date() + today_month_day = (today.month, today.day) + + for email, profile in list(_client_profiles.items()): + if not profile.get("onboardingCompleted"): + continue + if not profile.get("birthdayAutoSend"): + continue + + upcoming = _upcoming_birthday_date(str(profile.get("dogAge", ""))) + if not upcoming or (upcoming.month, upcoming.day) != today_month_day: + continue + + last_sent_year = str(profile.get("birthdayEmailLastSentYear", "")).strip() + if last_sent_year == str(today.year): + continue + + request_id = f"birthday-auto-{uuid.uuid4().hex[:6]}" + try: + await _send_birthday_email_for_profile(email, profile, request_id, mark_auto_year=today.year) + logger.info("[%s] auto birthday email sent: email=%s", request_id, email) + except Exception as exc: + logger.error("[%s] auto birthday email failed: %s", request_id, exc, exc_info=True) + + +async def _birthday_auto_sender_loop() -> None: + while True: + try: + await _run_birthday_auto_sender_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Birthday auto sender loop failed") + await asyncio.sleep(BIRTHDAY_CHECK_INTERVAL_SECONDS) + + _EMAIL_RE = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$') @@ -1627,21 +2071,7 @@ async def auth_verify_code(request: Request): @app.get("/auth/verify") async def auth_verify(request: Request): - auth_header = request.headers.get("Authorization", "") - token = auth_header.removeprefix("Bearer ").strip() - - if not token: - raise HTTPException(status_code=401, detail="No token provided.") - - async with _auth_lock: - session = _active_sessions.get(token) - if not session: - raise HTTPException(status_code=401, detail="Invalid session.") - if time.time() > session["expires_at"]: - _active_sessions.pop(token, None) - raise HTTPException(status_code=401, detail="Session expired. Please sign in again.") - - email = session["email"] + email = await _require_session_email(request) profile = _client_profiles.get(email, {}) draft = _drafts.get(email, {}) return {"ok": True, "email": email, "profile": profile, "draft": draft} @@ -1659,16 +2089,7 @@ async def auth_logout(request: Request): @app.post("/auth/save-draft") async def auth_save_draft(request: Request): - auth_header = request.headers.get("Authorization", "") - token = auth_header.removeprefix("Bearer ").strip() - if not token: - raise HTTPException(status_code=401, detail="No token provided.") - - async with _auth_lock: - session = _active_sessions.get(token) - if not session or time.time() > session["expires_at"]: - raise HTTPException(status_code=401, detail="Invalid or expired session.") - email = session["email"] + email = await _require_session_email(request) body = await request.json() form = str(body.get("form", "")).strip() @@ -1685,10 +2106,1031 @@ async def auth_save_draft(request: Request): snapshot = dict(_drafts) await asyncio.to_thread(_save_drafts_sync, snapshot) + await _persist_admin_state("drafts", snapshot) logger.info("Draft saved: email=%s form=%s", email, form) return {"ok": True} +MESSAGE_TEMPLATES: dict[str, dict[str, str]] = { + "general": { + "id": "general", + "name": "General update", + "description": "Clean Goodwalk branding for everyday news and updates.", + "kicker": "From Goodwalk", + "banner_emoji": "🐾", + "accent": "#ffd100", + "accent_text": "#213021", + "page_bg": "#f3f0e5", + "card_bg": "#fbfaf7", + "heading_color": "#171b20", + "body_color": "#4b584b", + "muted_color": "#6b766b", + "band_bg": "#213021", + "band_text": "#ffd100", + "band_decoration": "🐾 · 🐾 · 🐾 · 🐾 · 🐾", + "footer_bg": "#213021", + "footer_text": "#fbfaf7", + "highlight_bg": "#fff8d6", + "highlight_border": "#ffd100", + "highlight_text": "#213021", + "ornament_top": "", + "ornament_bottom": "", + "default_subject": "A note from Goodwalk", + "default_heading": "Hello from the pack", + "default_sub_heading": "A quick update from your dog walking team.", + "default_body": "Thank you for being part of our community. Every wag and woof matters to us, and we have a small update to share.\n\nWe're always here if you need to chat about walks, training, or anything else dog-related.", + "default_highlight": "", + "default_sign_off": "Aless & the Goodwalk pack", + "default_footer_note": "goodwalk.co.nz · Auckland, NZ", + }, + "christmas": { + "id": "christmas", + "name": "Christmas", + "description": "Deep green and red festive styling with snow accents.", + "kicker": "Season's greetings", + "banner_emoji": "🎄", + "accent": "#c0392b", + "accent_text": "#ffffff", + "page_bg": "#e8dccb", + "card_bg": "#fbf6ec", + "heading_color": "#0d3b1e", + "body_color": "#3a4a3a", + "muted_color": "#6b766b", + "band_bg": "#0d4d2a", + "band_text": "#ffffff", + "band_decoration": "❄ · 🎄 · ❄ · 🎁 · ❄ · 🦌 · ❄ · ⭐ · ❄", + "footer_bg": "#0d4d2a", + "footer_text": "#ffe8d6", + "highlight_bg": "#fff0ea", + "highlight_border": "#c0392b", + "highlight_text": "#7a1d12", + "ornament_top": "❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄ ❄", + "ornament_bottom": "🎄 ⭐ 🎁 🦌 ❄ 🎁 ⭐ 🎄", + "default_subject": "Merry Christmas from the Goodwalk pack 🎄", + "default_heading": "Wishing you a very woofy Christmas", + "default_sub_heading": "From our pack to yours — thank you for an incredible year.", + "default_body": "It's been a year full of muddy paws, sunny walks, and very good dogs. From all of us at Goodwalk, we wish you and your pup a warm, joyful Christmas.\n\nWe'll be taking a short break over the holidays and will be back in full swing for the new year. Looking forward to many more adventures in 2026.", + "default_highlight": "🎁 Holiday schedule: walks pause from 24 Dec, resuming 6 Jan.", + "default_sign_off": "Aless & the Goodwalk pack", + "default_footer_note": "Wishing you a warm and joyful Christmas", + }, + "easter": { + "id": "easter", + "name": "Easter", + "description": "Soft pastel styling with floral and bunny accents.", + "kicker": "Happy Easter", + "banner_emoji": "🐰", + "accent": "#d8a8de", + "accent_text": "#3a2a4a", + "page_bg": "#fdf3f8", + "card_bg": "#ffffff", + "heading_color": "#3a2a4a", + "body_color": "#5a4a5a", + "muted_color": "#8a7a8a", + "band_bg": "#f5d6e5", + "band_text": "#5a2a6b", + "band_decoration": "🌷 · 🐰 · 🌸 · 🥚 · 🐣 · 🌷 · 🌸", + "footer_bg": "#e7c9f0", + "footer_text": "#3a2a4a", + "highlight_bg": "#fff0fa", + "highlight_border": "#d8a8de", + "highlight_text": "#5a2a6b", + "ornament_top": "🌷 🌸 🌷 🌸 🌷 🌸 🌷 🌸 🌷 🌸", + "ornament_bottom": "🥚 🐰 🌸 🐣 🥚 🐰", + "default_subject": "Hop on into Easter with Goodwalk 🐰", + "default_heading": "A happy, hoppy Easter to you", + "default_sub_heading": "Spring is in the air and tails are wagging.", + "default_body": "Wishing you and your pup a beautiful Easter weekend. May your walks be sunny, your eggs uneaten by curious snouts, and your treats plentiful.\n\nA little reminder: chocolate is not for dogs, no matter how sweetly they ask. We'll be sticking to the good stuff on our walks.", + "default_highlight": "🐣 Keep chocolate well out of reach — even small amounts can be harmful to dogs.", + "default_sign_off": "Aless & the Goodwalk pack", + "default_footer_note": "Happy Easter from all of us", + }, + "halloween": { + "id": "halloween", + "name": "Halloween", + "description": "Dark purple and orange spooky styling.", + "kicker": "Trick or treat", + "banner_emoji": "🎃", + "accent": "#ff7518", + "accent_text": "#1a0d1f", + "page_bg": "#1a0d1f", + "card_bg": "#2b1838", + "heading_color": "#ffe8d0", + "body_color": "#d8c8d8", + "muted_color": "#9a8aaa", + "band_bg": "#0a0410", + "band_text": "#ff7518", + "band_decoration": "🎃 · 👻 · 🕷 · 🦇 · 🌙 · 🕸 · 🎃 · 👻", + "footer_bg": "#0a0410", + "footer_text": "#ff7518", + "highlight_bg": "#4a2b66", + "highlight_border": "#ff7518", + "highlight_text": "#ffe8d0", + "ornament_top": "🦇 🕸 🦇 🕸 🦇 🕸 🦇 🕸 🦇 🕸", + "ornament_bottom": "🎃 👻 🕷 🌙 🦇 🕸 🎃", + "default_subject": "Spooky season at Goodwalk 🎃", + "default_heading": "It's Howl-oween", + "default_sub_heading": "Costumes optional. Treats mandatory.", + "default_body": "Spooky season is upon us. We'll be out walking with extra vigilance — fireworks, doorbell mayhem, and rogue chocolate are all on our radar.\n\nIf your pup is nervous around fireworks or doorbells, let us know and we'll factor it into walks this week.", + "default_highlight": "🍫 Reminder: chocolate, raisins, and xylitol are all toxic to dogs. Keep the treat bowl high.", + "default_sign_off": "Aless & the Goodwalk pack", + "default_footer_note": "Stay spooky out there", + }, + "promo": { + "id": "promo", + "name": "Sale / promotional offer", + "description": "Bright yellow promotional styling with a clear discount callout.", + "kicker": "Limited offer", + "banner_emoji": "🦴", + "accent": "#ffd100", + "accent_text": "#213021", + "page_bg": "#fffaeb", + "card_bg": "#fffdf5", + "heading_color": "#171b20", + "body_color": "#3a4a3a", + "muted_color": "#6b766b", + "band_bg": "#213021", + "band_text": "#ffd100", + "band_decoration": "★ · SPECIAL OFFER · ★ · LIMITED TIME · ★", + "footer_bg": "#213021", + "footer_text": "#ffd100", + "highlight_bg": "#fff3a0", + "highlight_border": "#ffd100", + "highlight_text": "#213021", + "ornament_top": "★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★ ★", + "ornament_bottom": "🦴 ★ 🐾 ★ 🦴 ★ 🐾", + "default_subject": "A little something from Goodwalk 🦴", + "default_heading": "A special offer for our pack", + "default_sub_heading": "Because regulars are family.", + "default_body": "We're running a small thank-you offer for our existing clients. As a regular, you're first in line.\n\nReply to this email or hit the button below to take it up. Offer is limited and won't be around long.", + "default_highlight": "20% off your next week of walks · Use code PACKLOVE at booking", + "default_sign_off": "Aless & the Goodwalk pack", + "default_footer_note": "Limited time — be quick", + }, +} + + +MESSAGE_FONTS: dict[str, dict[str, str]] = { + "system": { + "id": "system", + "name": "System (clean sans-serif)", + "stack": "-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + "link": "", + "heading_stack": "Georgia,'Times New Roman',serif", + }, + "lora": { + "id": "lora", + "name": "Lora (warm serif)", + "stack": "'Lora',Georgia,'Times New Roman',serif", + "link": "https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400&display=swap", + "heading_stack": "'Lora',Georgia,'Times New Roman',serif", + }, + "playfair": { + "id": "playfair", + "name": "Playfair Display (editorial serif)", + "stack": "Georgia,'Times New Roman',serif", + "link": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Source+Sans+3:wght@400;600&display=swap", + "heading_stack": "'Playfair Display',Georgia,'Times New Roman',serif", + }, + "merriweather": { + "id": "merriweather", + "name": "Merriweather (readable serif)", + "stack": "'Merriweather',Georgia,'Times New Roman',serif", + "link": "https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap", + "heading_stack": "'Merriweather',Georgia,'Times New Roman',serif", + }, + "crimson": { + "id": "crimson", + "name": "Crimson Text (classic serif)", + "stack": "'Crimson Text',Georgia,'Times New Roman',serif", + "link": "https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&display=swap", + "heading_stack": "'Crimson Text',Georgia,'Times New Roman',serif", + }, + "inter": { + "id": "inter", + "name": "Inter (modern sans)", + "stack": "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + "link": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap", + "heading_stack": "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + }, + "montserrat": { + "id": "montserrat", + "name": "Montserrat (geometric sans)", + "stack": "'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + "link": "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap", + "heading_stack": "'Montserrat',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + }, + "opensans": { + "id": "opensans", + "name": "Open Sans (friendly sans)", + "stack": "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + "link": "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,600;0,700;1,400&display=swap", + "heading_stack": "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif", + }, +} + + +def _style_body_html(body_html: str, font_stack: str, body_color: str, accent_color: str) -> str: + """Apply email-safe inline styles to common HTML tags in user-provided body content.""" + import re + + base_p_style = f"margin:0 0 16px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};" + base_li_style = f"margin:0 0 6px;font-family:{font_stack};font-size:16px;line-height:1.7;color:{body_color};" + base_ul_style = f"margin:0 0 16px 0;padding:0 0 0 22px;font-family:{font_stack};color:{body_color};" + base_ol_style = base_ul_style + a_style = f"color:{accent_color};text-decoration:underline;" + + # Strip

wrappers (contenteditable often wraps in divs); convert to

+ s = body_html + s = re.sub(r"]*>", "

", s) + s = s.replace("

", "

") + s = s.replace("
", "
").replace("
", "
") + + # Apply inline styles by replacing opening tags (only if no style attribute already) + def _inject(tag: str, style: str, text: str) -> str: + return re.sub( + rf"<{tag}(\s[^>]*)?>", + lambda m: f"<{tag}{m.group(1) or ''} style=\"{style}\">", + text, + flags=re.IGNORECASE, + ) + + s = _inject("p", base_p_style, s) + s = _inject("ul", base_ul_style, s) + s = _inject("ol", base_ol_style, s) + s = _inject("li", base_li_style, s) + s = re.sub( + r"]*?)>", + lambda m: f"" if "style=" not in m.group(1).lower() else m.group(0), + s, + flags=re.IGNORECASE, + ) + + return s + + +def _body_to_html(body_text: str, font_stack: str, body_color: str, accent_color: str) -> str: + """Convert user body input to email-safe HTML. + + If the input already looks like HTML (contains a tag), we treat it as HTML and inline-style it. + Otherwise we split on blank lines and wrap each paragraph in a

. + """ + if not body_text or not body_text.strip(): + return "" + + if "<" in body_text and ">" in body_text: + return _style_body_html(body_text, font_stack, body_color, accent_color) + + parts = [p.strip() for p in body_text.split("\n\n") if p.strip()] + return "".join( + f'

{para}

' + for para in parts + ) + + +def _escape_attr(value: str) -> str: + return (value or "").replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") + + +def _bulletproof_button(label: str, url: str, bg: str, text_color: str, font_stack: str = "-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif") -> str: + if not label.strip() or not url.strip(): + return "" + safe_url = _escape_attr(url.strip()) + safe_label = (label.strip() + .replace("&", "&").replace("<", "<").replace(">", ">")) + return f""" + + +
+ + + {safe_label} + +
""" + + +def _render_message_html( + template_id: str, + heading: str, + body: str, + cta_label: str, + cta_url: str, + sub_heading: str = "", + highlight_text: str = "", + sign_off: str = "", + footer_note: str = "", + font_id: str = "system", +) -> str: + tmpl = MESSAGE_TEMPLATES.get(template_id, MESSAGE_TEMPLATES["general"]) + font = MESSAGE_FONTS.get(font_id, MESSAGE_FONTS["system"]) + font_stack = font["stack"] + heading_font_stack = font["heading_stack"] + font_link = font["link"] + accent = tmpl["accent"] + accent_text = tmpl["accent_text"] + page_bg = tmpl["page_bg"] + card_bg = tmpl["card_bg"] + heading_color = tmpl["heading_color"] + body_color = tmpl["body_color"] + muted_color = tmpl["muted_color"] + band_bg = tmpl["band_bg"] + band_text = tmpl["band_text"] + band_decoration = tmpl["band_decoration"] + footer_bg = tmpl["footer_bg"] + footer_text_color = tmpl["footer_text"] + highlight_bg = tmpl["highlight_bg"] + highlight_border = tmpl["highlight_border"] + highlight_text_color = tmpl["highlight_text"] + ornament_top = tmpl["ornament_top"] + ornament_bottom = tmpl["ornament_bottom"] + kicker = tmpl["kicker"] + emoji = tmpl["banner_emoji"] + + h = (heading or tmpl["default_heading"]).strip() + sh = (sub_heading or tmpl["default_sub_heading"]).strip() + so = (sign_off or tmpl.get("default_sign_off", "")).strip() + fn = (footer_note or tmpl["default_footer_note"]).strip() + hl = (highlight_text or tmpl["default_highlight"]).strip() + body_text = (body or tmpl["default_body"]).strip() + + body_html_inner = _body_to_html(body_text, font_stack, body_color, accent) + body_html = ( + f'
' + f'{body_html_inner}' + f'
' + ) + + highlight_html = "" + if hl: + highlight_html = f""" + + +
+

+ {hl} +

+
""" + + cta_html = _bulletproof_button(cta_label, cta_url, accent, accent_text, font_stack) + + sub_heading_html = "" + if sh: + sub_heading_html = f""" +

+ {sh} +

""" + + ornament_top_html = "" + if ornament_top: + ornament_top_html = f""" + + + {ornament_top} + +""" + + ornament_bottom_html = "" + if ornament_bottom: + ornament_bottom_html = f""" + + + {ornament_bottom} + +""" + + kicker_html = f""" + + +
+ {(emoji + '   ') if emoji else ''}{kicker} +
""" + + font_link_html = "" + if font_link: + font_link_html = ( + f'' + ) + + return f""" + + + + + + + {font_link_html} + + {h} + + +
{sh or h}
+ + +
+ + + + + + + + + + + + {ornament_top_html} + + + + + + {ornament_bottom_html} + + + + + +
+ {band_decoration} +
+ Goodwalk +
+ {kicker_html} +

+ {h} +

+ {sub_heading_html} + {body_html} + {highlight_html} + {cta_html} + {('

With love,
' + so + '

') if so else ''} +
+
{ornament_bottom or '🐾 · 🐾 · 🐾'}
+ {('
' + fn + '
') if fn else ''} +
+ +
+ +""" + + +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 + + +@app.get("/owner/message-templates") +async def owner_message_templates(request: Request): + await _require_owner_email(request) + templates = [ + { + "id": t["id"], + "name": t["name"], + "description": t["description"], + "accent": t["accent"], + "bannerEmoji": t["banner_emoji"], + "defaultSubject": t["default_subject"], + "defaultHeading": t["default_heading"], + "defaultSubHeading": t["default_sub_heading"], + "defaultBody": t["default_body"], + "defaultHighlight": t["default_highlight"], + "defaultSignOff": t.get("default_sign_off", ""), + "defaultFooterNote": t["default_footer_note"], + } + for t in MESSAGE_TEMPLATES.values() + ] + fonts = [ + {"id": f["id"], "name": f["name"], "link": f["link"], "stack": f["stack"]} + for f in MESSAGE_FONTS.values() + ] + return {"ok": True, "templates": templates, "fonts": fonts} + + +@app.post("/owner/render-message") +async def owner_render_message(data: RenderMessageRequest, request: Request): + await _require_owner_email(request) + if data.templateId not in MESSAGE_TEMPLATES: + raise HTTPException(status_code=400, detail="Unknown template.") + html = _render_message_html( + data.templateId, + data.heading, + data.body, + data.ctaLabel, + data.ctaUrl, + sub_heading=data.subHeading, + highlight_text=data.highlightText, + sign_off=data.signOff, + footer_note=data.footerNote, + font_id=data.fontId, + ) + return {"ok": True, "html": html} + + +@app.post("/owner/send-message") +async def owner_send_message(data: SendMessageRequest, request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + await _require_owner_email(request) + + if data.templateId not in MESSAGE_TEMPLATES: + raise HTTPException(status_code=400, detail="Unknown template.") + subject = _trimmed(data.subject) + if not subject: + raise HTTPException(status_code=400, detail="Please enter a subject.") + is_preview = bool(data.preview) + recipient_emails = [str(e).strip().lower() for e in (data.recipients or []) if str(e).strip()] + + if not is_preview and not recipient_emails: + raise HTTPException(status_code=400, detail="Please choose at least one recipient.") + + html = _render_message_html( + data.templateId, + data.heading, + data.body, + data.ctaLabel, + data.ctaUrl, + sub_heading=data.subHeading, + highlight_text=data.highlightText, + sign_off=data.signOff, + footer_note=data.footerNote, + font_id=data.fontId, + ) + owner_addr = OWNER_EMAIL.strip().lower() + + if is_preview: + payload = { + "from": FROM_EMAIL, + "to": [owner_addr], + "reply_to": REPLY_TO, + "subject": f"[PREVIEW] {subject}", + "html": html, + } + try: + await _send_email(payload, label="bulk_message_preview", request_id=request_id) + except Exception as exc: + logger.error("[%s] bulk message preview failed: %s", request_id, exc, exc_info=True) + raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The preview could not be sent."}) + return {"ok": True, "preview": True} + + # Real send — always BCC, To: owner. Each recipient sees only owner in To. + payload = { + "from": FROM_EMAIL, + "to": [owner_addr], + "bcc": recipient_emails, + "reply_to": REPLY_TO, + "subject": subject, + "html": html, + } + try: + await _send_email(payload, label="bulk_message", request_id=request_id) + except Exception as exc: + logger.error("[%s] bulk message failed: %s", request_id, exc, exc_info=True) + raise HTTPException(status_code=502, detail={"request_id": request_id, "message": "The message could not be sent."}) + + logger.info("[%s] bulk message sent: template=%s recipients=%d", request_id, data.templateId, len(recipient_emails)) + return {"ok": True, "recipientCount": len(recipient_emails)} + + +@app.get("/owner/client-enquiry") +async def owner_client_enquiry(request: Request): + await _require_owner_email(request) + email = (request.query_params.get("email") or "").strip().lower() + if not email: + raise HTTPException(status_code=400, detail="Email is required.") + profile = _client_profiles.get(email) + if not profile: + raise HTTPException(status_code=404, detail="Client not found.") + enquiry = profile.get("lastEnquiry") if isinstance(profile.get("lastEnquiry"), dict) else None + if not enquiry: + # Fall back to legacy profile fields if no enquiry snapshot was stored + enquiry = { + "submittedAt": profile.get("lastEnquiryAt", ""), + "enquiryType": profile.get("enquiryType", ""), + "fullName": profile.get("fullName", ""), + "email": email, + "phone": profile.get("phone", ""), + "petName": profile.get("dogName", ""), + "location": profile.get("location", ""), + "services": profile.get("services", []) if isinstance(profile.get("services"), list) else [], + "message": "", + "referrer": "", + "page": "", + } + return {"ok": True, "enquiry": enquiry} + + +@app.get("/owner/pending-onboarding") +async def owner_pending_onboarding(request: Request): + await _require_owner_email(request) + + def _sort_timestamp(value: Any) -> float: + if not isinstance(value, str) or not value: + return 0 + try: + return datetime.fromisoformat(value).timestamp() + except ValueError: + return 0 + + pending_clients: list[dict[str, Any]] = [] + for email, profile in _client_profiles.items(): + if email == OWNER_EMAIL.strip().lower(): + continue + if profile.get("onboardingCompleted"): + continue + + pending_clients.append({ + "email": email, + "fullName": profile.get("fullName", ""), + "phone": profile.get("phone", ""), + "dogName": profile.get("dogName", ""), + "dogBreed": profile.get("dogBreed", ""), + "services": profile.get("services", []) if isinstance(profile.get("services"), list) else [], + "lastEnquiryAt": profile.get("lastEnquiryAt", ""), + "welcomePackSentAt": profile.get("welcomePackSentAt", ""), + "welcomePackOffer": profile.get("welcomePackOffer", {}) if isinstance(profile.get("welcomePackOffer"), dict) else {}, + }) + + pending_clients.sort( + key=lambda item: ( + item.get("welcomePackSentAt", "") != "", + -_sort_timestamp(item.get("lastEnquiryAt")), + item.get("fullName", "").lower(), + ), + ) + + return {"ok": True, "clients": pending_clients} + + +@app.get("/owner/completed-onboarding") +async def owner_completed_onboarding(request: Request): + await _require_owner_email(request) + + def _sort_timestamp(value: Any) -> float: + if not isinstance(value, str) or not value: + return 0 + try: + return datetime.fromisoformat(value).timestamp() + except ValueError: + return 0 + + try: + page = max(1, int(request.query_params.get("page", "1"))) + except ValueError: + page = 1 + try: + page_size = min(24, max(1, int(request.query_params.get("page_size", "10")))) + except ValueError: + page_size = 10 + + completed_clients: list[dict[str, Any]] = [] + for email, profile in _client_profiles.items(): + if email == OWNER_EMAIL.strip().lower(): + continue + if not profile.get("onboardingCompleted"): + continue + + completed_clients.append({ + "email": email, + "fullName": profile.get("fullName", ""), + "phone": profile.get("phone", ""), + "address": profile.get("address", ""), + "dogName": profile.get("dogName", ""), + "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "onboardingSubmittedAt": profile.get("onboardingSubmittedAt", ""), + "hasBirthdayInvite": bool(_trimmed(str(profile.get("dogAge", "")))), + }) + + completed_clients.sort( + key=lambda item: ( + -_sort_timestamp(item.get("onboardingSubmittedAt")), + item.get("fullName", "").lower(), + ), + ) + + total = len(completed_clients) + total_pages = max(1, (total + page_size - 1) // page_size) + page = min(page, total_pages) + start = (page - 1) * page_size + end = start + page_size + + return { + "ok": True, + "clients": completed_clients[start:end], + "pagination": { + "page": page, + "pageSize": page_size, + "total": total, + "totalPages": total_pages, + }, + } + + +@app.get("/owner/all-clients") +async def owner_all_clients(request: Request): + await _require_owner_email(request) + + def _sort_timestamp(value: Any) -> float: + if not isinstance(value, str) or not value: + return 0 + try: + return datetime.fromisoformat(value).timestamp() + except ValueError: + return 0 + + try: + page = max(1, int(request.query_params.get("page", "1"))) + except ValueError: + page = 1 + try: + page_size = min(30, max(1, int(request.query_params.get("page_size", "12")))) + except ValueError: + page_size = 12 + + clients: list[dict[str, Any]] = [] + for email, profile in _client_profiles.items(): + if email == OWNER_EMAIL.strip().lower(): + continue + + clients.append({ + "email": email, + "fullName": profile.get("fullName", ""), + "phone": profile.get("phone", ""), + "dogName": profile.get("dogName", ""), + "dogBreed": profile.get("dogBreed", ""), + "status": "completed" if profile.get("onboardingCompleted") else "pending", + "lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""), + "welcomePackSentAt": profile.get("welcomePackSentAt", ""), + }) + + clients.sort( + key=lambda item: ( + item.get("status") != "pending", + -_sort_timestamp(item.get("lastActivityAt")), + item.get("fullName", "").lower(), + ), + ) + + total = len(clients) + total_pages = max(1, (total + page_size - 1) // page_size) + page = min(page, total_pages) + start = (page - 1) * page_size + end = start + page_size + + return { + "ok": True, + "clients": clients[start:end], + "pagination": { + "page": page, + "pageSize": page_size, + "total": total, + "totalPages": total_pages, + }, + } + + +@app.get("/owner/birthdays") +async def owner_birthdays(request: Request): + await _require_owner_email(request) + + try: + page = max(1, int(request.query_params.get("page", "1"))) + except ValueError: + page = 1 + try: + page_size = min(30, max(1, int(request.query_params.get("page_size", "12")))) + except ValueError: + page_size = 12 + + today = datetime.now() + birthdays: list[dict[str, Any]] = [] + for email, profile in _client_profiles.items(): + if email == OWNER_EMAIL.strip().lower(): + continue + if not profile.get("onboardingCompleted"): + continue + + upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today) + if not upcoming: + continue + + birthdays.append({ + "email": email, + "fullName": profile.get("fullName", ""), + "dogName": profile.get("dogName", ""), + "dogBreed": profile.get("dogBreed", ""), + "dogAge": profile.get("dogAge", ""), + "birthdayLabel": upcoming.isoformat(), + "daysUntil": (upcoming - today.date()).days, + "birthdayAutoSend": bool(profile.get("birthdayAutoSend")), + "birthdayEmailLastSentAt": profile.get("birthdayEmailLastSentAt", ""), + }) + + birthdays.sort( + key=lambda item: ( + item.get("daysUntil", 10**9), + item.get("dogName", "").lower(), + item.get("fullName", "").lower(), + ), + ) + + total = len(birthdays) + total_pages = max(1, (total + page_size - 1) // page_size) + page = min(page, total_pages) + start = (page - 1) * page_size + end = start + page_size + + return { + "ok": True, + "clients": birthdays[start:end], + "pagination": { + "page": page, + "pageSize": page_size, + "total": total, + "totalPages": total_pages, + }, + } + + +@app.get("/owner/birthday-ics") +async def owner_birthday_ics(request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + await _require_owner_email(request) + + email = _trimmed(request.query_params.get("email", "")).lower() + if not email: + raise HTTPException(status_code=400, detail="Email is required.") + + profile = _client_profiles.get(email, {}) + if not profile or not profile.get("onboardingCompleted"): + raise HTTPException(status_code=404, detail="Completed client not found.") + + attachment = _birthday_ics_attachment( + str(profile.get("dogName", "")), + str(profile.get("dogAge", "")), + str(profile.get("fullName", "")), + request_id, + ) + if not attachment: + raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.") + + content = base64.b64decode(attachment["content"]) + return Response( + content=content, + media_type="text/calendar; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{attachment["filename"]}"' + }, + ) + + +@app.post("/owner/send-welcome-pack") +async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + await _require_owner_email(request) + + email = str(data.email).strip().lower() + profile = _client_profiles.get(email, {}) + + if not profile: + raise HTTPException(status_code=404, detail="Client profile not found.") + if profile.get("onboardingCompleted"): + raise HTTPException(status_code=400, detail="This client has already completed onboarding.") + if not _trimmed(data.serviceType): + raise HTTPException(status_code=400, detail="Please enter a service.") + if not _trimmed(data.priceDetails): + raise HTTPException(status_code=400, detail="Please enter the price details.") + if not _trimmed(data.startDate): + raise HTTPException(status_code=400, detail="Please enter a start date.") + + owner_name = str(profile.get("fullName", "")).strip() + dog_name = str(profile.get("dogName", "")).strip() + sent_at = datetime.now().isoformat(timespec="seconds") + is_preview = bool(data.preview) + recipient = OWNER_EMAIL.strip().lower() if is_preview else email + subject = "Welcome to the pack | Goodwalk" + if is_preview: + subject = f"[PREVIEW for {owner_name or email}] {subject}" + + payload = { + "from": FROM_EMAIL, + "to": [recipient], + "reply_to": REPLY_TO, + "subject": subject, + "html": _welcome_pack_email_html(owner_name, dog_name, _trimmed(data.serviceType), _trimmed(data.priceDetails), _trimmed(data.startDate)), + } + if CLIENT_BCC and not is_preview: + payload["bcc"] = [CLIENT_BCC] + + try: + await _send_email(payload, label="welcome_pack_email_preview" if is_preview else "welcome_pack_email", request_id=request_id) + except Exception as exc: + logger.error("[%s] welcome pack email failed: %s", request_id, exc, exc_info=True) + raise HTTPException( + status_code=502, + detail={ + "request_id": request_id, + "message": "The welcome email could not be sent. Please try again shortly.", + "error_type": type(exc).__name__, + }, + ) + + if is_preview: + logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email) + return {"ok": True, "sentAt": sent_at, "preview": True} + + await _store_client_profile(email, { + "welcomePackSentAt": sent_at, + "welcomePackOffer": { + "serviceType": _trimmed(data.serviceType), + "priceDetails": _trimmed(data.priceDetails), + "startDate": _trimmed(data.startDate), + "sentAt": sent_at, + }, + }) + + logger.info("[%s] welcome pack sent: email=%s service=%s start=%s", request_id, email, data.serviceType, data.startDate) + return {"ok": True, "sentAt": sent_at} + + +@app.post("/owner/send-birthday-email") +async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + await _require_owner_email(request) + + email = str(data.email).strip().lower() + profile = _client_profiles.get(email, {}) + if not profile or not profile.get("onboardingCompleted"): + raise HTTPException(status_code=404, detail="Completed client not found.") + + if not _upcoming_birthday_date(str(profile.get("dogAge", ""))): + raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.") + + try: + await _send_birthday_email_for_profile(email, profile, request_id, preview=bool(data.preview)) + except Exception as exc: + logger.error("[%s] birthday email failed: %s", request_id, exc, exc_info=True) + raise HTTPException( + status_code=502, + detail={ + "request_id": request_id, + "message": "The birthday email could not be sent. Please try again shortly.", + "error_type": type(exc).__name__, + }, + ) + + return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)} + + +@app.post("/owner/birthday-auto-send") +async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Request): + await _require_owner_email(request) + + email = str(data.email).strip().lower() + profile = _client_profiles.get(email, {}) + if not profile or not profile.get("onboardingCompleted"): + raise HTTPException(status_code=404, detail="Completed client not found.") + + if not _upcoming_birthday_date(str(profile.get("dogAge", ""))): + raise HTTPException(status_code=400, detail="This client does not have a valid dog birthday on file.") + + await _store_client_profile(email, { + "birthdayAutoSend": data.enabled, + }) + + return {"ok": True, "enabled": data.enabled} + + @app.post("/submit") async def submit_booking(data: BookingSubmission, request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) @@ -1793,10 +3235,28 @@ async def submit_booking(data: BookingSubmission, request: Request): logger.warning("[%s] partial failure: %s", request_id, failures) await _register_email(str(data.email)) + enquiry_at = datetime.now().isoformat(timespec="seconds") await _store_client_profile(str(data.email), { "fullName": data.fullName, "phone": data.phone, "dogName": data.petName, + "services": data.services, + "location": data.location, + "enquiryType": data.enquiryType, + "lastEnquiryAt": enquiry_at, + "lastEnquiry": { + "submittedAt": enquiry_at, + "enquiryType": data.enquiryType, + "fullName": data.fullName, + "email": str(data.email), + "phone": data.phone, + "petName": data.petName, + "location": data.location, + "services": data.services, + "message": data.message, + "referrer": data.referrer, + "page": data.page, + }, }) return { @@ -2027,6 +3487,9 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request): "subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})", "html": owner_onboarding_email(data, ip, browser), } + birthday_attachment = _birthday_ics_attachment(data.dogName, data.dogAge, data.fullName, request_id) + if birthday_attachment: + owner_payload["attachments"] = [birthday_attachment] if OWNER_BCC: owner_payload["bcc"] = [OWNER_BCC] @@ -2056,6 +3519,8 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request): "dogBreed": data.dogBreed, "dogAge": data.dogAge, "onboardingCompleted": True, + "onboardingSubmittedAt": datetime.now().isoformat(timespec="seconds"), + "onboardingSubmission": data.submissionSnapshot, }) return { diff --git a/mail-api/requirements.txt b/mail-api/requirements.txt index cd7bfe1..75e5e51 100644 --- a/mail-api/requirements.txt +++ b/mail-api/requirements.txt @@ -2,3 +2,4 @@ fastapi>=0.115 uvicorn[standard]>=0.32 resend>=2.0 pydantic[email]>=2.10 +asyncpg>=0.30 diff --git a/marketing-voice-v2.md b/marketing-voice-v2.md new file mode 100644 index 0000000..e653ff6 --- /dev/null +++ b/marketing-voice-v2.md @@ -0,0 +1,422 @@ +# Marketing Voice v2 — Distillation Proposal + +Proposed copy changes across the site, measured against `marketing-voice.md`. Nothing applied yet — review, redline, then I'll commit the survivors. + +The aim: cut hedges, kill restated headings, collapse repetition across pages, and replace abstract phrasing with concrete nouns. Where two sentences carry one idea, one sentence wins. + +--- + +## 1. Cross-site naming + +### "1:1" still appears in 6+ places + +We already changed the FAQ heading on the dog-walking page. The phrase still lives in navigation, footer, mega menu, decision blocks, FAQs, and SEO metadata. It's calendar-speak — customers say "solo" or "one-on-one". + +| Location | Current | Proposed | +|---|---|---| +| `homepage.ts` mobileLinks | `1:1 Walks` | `Solo Walks` | +| `homepage.ts` megaMenuServices | `1:1 Walks` / `Personalised solo walks` | `Solo Walks` / `One dog. One walker.` | +| `homepage.ts` services[1] | `1:1 Walks` | `Solo Walks` | +| `homepage.ts` testimonials.service | `1:1 Walk` | `Solo Walk` | +| `homepage.ts` booking.serviceOptions | `1:1 Walks` | `Solo Walks` | +| `homepage.ts` footer.navigationLinks | `1:1 Walks` | `Solo Walks` | +| `pack-walks.ts` paragraphs[4] | `our 1:1 walks are the better fit` | `our solo walks are the better fit` | +| `dog-walking.ts` hero.eyebrow | `1:1 Walks` | `Solo Walks` | +| `dog-walking.ts` decision.title | `Is a 1:1 walk right for your dog?` | `Is a solo walk right for your dog?` | +| `dog-walking.ts` FAQ | `1:1 walks start from...` | `Solo walks start from...` | + +Open question: keep `1:1` in SEO `` / `description` if it carries search volume, or sweep that too? I'd keep it on `llms.txt` and meta titles where the keyword has weight, drop it in body copy. + +--- + +## 2. Homepage (`homepage.ts`) + +### Hero subtitle — already tight, leave it. + +✅ Current: "Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings." Keep. + +### Intro + +Current: `"Professional dog walking services across Auckland."` + +Proposal: **`"Dog walking across Auckland Central."`** +Why: "Professional services" is filler — readers already assume it. The location is the real specificity; drop "Auckland", say "Auckland Central" since that's where we actually are. + +### Founder story body + +Current: +> "Most companies sell walks. We sell a calmer evening at home." +> "Same walker. Small groups. Real attention. Your dog learns to trust one face at the door — not a rotating roster." +> "You know who has your dog. Your dog knows who is collecting them. And you come home to a tired, happy one. Ready to" + +Proposal — line 3 is doing two jobs and stumbles into the CTA: +> "Most companies sell walks. We sell a calmer evening at home." +> "Same walker. Small groups. Real attention. Your dog learns one face at the door, not a rotating roster." +> "You know who has your dog. They know who's at the door. You come home to a tired, happy one. Ready to" + +Reasons: kill "trust one face" (abstract + redundant with later "tired, happy one"), trim "to trust" (we just said we sell trust). Contraction on "they know who's" makes it sound spoken. + +### How it works — phase benefits + +The "benefit" line under each phase is doing the job of the title. Right now both compete. + +| Phase | Current title + benefit | Proposal | +|---|---|---| +| Meet | `No pressure, just clarity` + `A proper Meet & Greet at home` | Drop the benefit subtitle. Title carries enough. | +| Settle | `A smoother start for nervous dogs` + `Your dog settles in. No rushing.` | Drop benefit subtitle. | +| Thrive | `The outcome you actually want` + `Then the routine does the work` | Drop benefit subtitle — it's meta-commentary. | + +Alternative if benefit subtitles are structural: rewrite as deliverables, not vibes. `Free, 30 mins, your home` / `Two walks before regulars` / `Photo updates, every walk`. + +### Values + +Current titles read OK. Bodies have a few hedges to fix: + +- `"4 to 8 dogs. Always. Calm, structured walks with real attention for every dog."` → **`"4 to 8 dogs. Always. Calm walks, real attention."`** (drop "for every dog" — implied) +- `"Pet first aid certified. Careful screening. Proactive handling. Not extras — the baseline."` → keep, but "proactive handling" is fluff. → **`"Pet first aid certified. Careful screening. Not extras — the baseline."`** +- `"You should not have to chase your dog walker. Consistent pickup. Clear communication. Nothing to manage."` → **`"You shouldn't have to chase a dog walker. Same pickup time. Clear updates. Nothing to manage."`** (contraction, "consistent pickup" → "same pickup time") + +### Booking subtitles + +Current generalSubtitle: `"A few details. We reply properly within 24 hours."` + +"Properly" is a hedge that sounds defensive. Drop it. +→ **`"A few details. We reply within 24 hours."`** + +### Locations & Hours intro + +Current: `"We cover most of Auckland Central's suburbs:"` + +Proposal: **`"We cover Auckland Central:"`** +"Most of" is a hedge that undercuts the long list of suburbs immediately below. + +--- + +## 3. Pack Walks (`pack-walks.ts`) + +### Hero — 5 paragraphs is too many + +The hero currently delivers: who it's for, how it works, walk length, coverage, who it isn't for. Five paragraphs. Most heroes earn their keep in 2-3. The "who it isn't for" line belongs in the decision block (and it's already there). + +Proposal — collapse to three paragraphs: + +> "Tiny Gang is built for small and medium dogs who like the right kind of company. 4 to 8 dogs, matched on size and energy." +> +> "Free Meet & Greet at home, then two assessment walks. Regular slot only once we know the fit is right." +> +> "60 to 75 minutes on the ground. We rotate Western Springs, Fowlds Park, Cornwall Park, Grey Lynn Park, and Oakley Creek — picked on the day for weather and group." + +Coverage line moves down to the Highlight or Pricing block where it does work. The "if your dog isn't a fit" line lives in the decision block — no need to preview it here. + +### Pricing intro + +Current: `"Right amount of exercise. Right amount of social time. Same walker every week."` + +Proposal: **`"Right exercise. Right social time. Same walker every week."`** Cut the repeated "amount of". Reads faster. + +### Pricing plan features + +The features hedge with "Best for…" which sounds product-y. Rewrite as straight facts: + +| Plan | Current 4th feature | Proposal | +|---|---|---| +| 1 Walk Per Week | `Best for dogs starting out` | `Good for dogs starting out` (or drop) | +| 2-3 Walks Per Week | `Best fit for busy owners` | `Most popular routine` (matches `popular: true` flag) | +| 4-5 Walks Per Week | `Best for high-energy social dogs` | `For high-energy social dogs` | +| Casual Pack Walk | `Higher rate than weekly routines` | Drop — price already shows it | + +### Benefits intro + +Current: `"Small groups. Compatible dogs. No chaos. That is why it works."` + +Proposal: **`"Small groups. Compatible dogs. No chaos. That's why it works."`** Contraction. (Same pattern repeats elsewhere — see § 6.) + +### Benefit titles — abstract vs concrete + +- `"No overwhelming dynamics"` → **`"No bigger dogs to dodge"`** (concrete) +- `"A routine you can rely on"` → **`"A weekly routine that sticks"`** (matches highlight block phrasing already used on the same page) +- `"Real individual attention"` → **`"Eyes on every dog"`** +- `"Safety, built in"` → **`"Safer than a one-size pack"`** + +### Booking dogIntro + +Current: `"Tell us about your dog. Where you are. Anything we should know. We will come back about whether Tiny Gang is the right fit."` + +Proposal: **`"Tell us about your dog. Where you are. Anything we should know. We'll come back about the fit."`** Contraction, drop "whether Tiny Gang is the right" (implied from page). + +--- + +## 4. Solo Walks (`dog-walking.ts`) + +### Hero — five paragraphs again, same pattern + +Current paragraphs 1-5 cover: who it's for, the dog types, walk length, coverage, honesty-at-Meet-&-Greet. Same compression opportunity. + +Proposal — three paragraphs: + +> "Built for dogs who do better one-on-one. Their pace. Their walk. Same walker every time." +> +> "Reactive on the lead. Recovering from surgery. A senior who needs it slower. An anxious rescue still finding their feet. These are the dogs solo walks are for." +> +> "30 minutes for seniors or lower energy. 45 for most. 60 for dogs who want a longer outing. Door-to-door, photo update after every walk." + +The "honesty at Meet & Greet" sentence is already said on every other page — it's a brand-wide promise, not a hero claim. + +### Highlight points + +- `"For larger or more sensitive dogs"` body: `"When your dog needs more space, more clarity, or more attention — this gives us room to do it properly."` → **`"For dogs who need more space, clarity, or attention. We have room to give it."`** (drop "When your dog needs" → just state who it's for) + +### Pricing intro + +Current: `"Shaped around your dog, not a group schedule. For dogs who need extra attention, a steadier pace, or a more personal routine."` + +Proposal: **`"Shaped around your dog. For dogs who need extra attention, a steadier pace, or more personal time."`** Cuts "not a group schedule" (over-explained by context), trims "more personal routine" → "more personal time" (concrete). + +### Pricing plan features + +Same pattern as pack-walks. Drop hedging "Best for…" labels or rewrite as facts. + +| Plan | Current 4th feature | Proposal | +|---|---|---| +| 30 Min | `Good fit for lower-energy dogs` | `For seniors and lower-energy dogs` | +| 45 Min | `Best fit for many routines` | `Most popular length` | +| 60 Min | `Best for dogs needing a fuller outing` | `For dogs who want a longer outing` | + +### Pricing scarcityNote + +Current: `"A limited number of 1:1 slots are available each week."` + +Proposal: **`"Solo slots are limited each week."`** Active, shorter, no passive "are available". + +### Benefits intro + +Current: `"More space. Steadier handling. A pace that fits. The whole week feels easier."` + +Already strong. Keep. + +### Benefit titles + +- `"Full attention. No competition."` — keep. Strong. +- `"The walk matches their pace"` → **`"A walk at their pace"`** (more direct) +- `"Room to relax"` body has 21 words and ends in "without group pressure" — third time the page implies group pressure. Compress: **`"Without group pressure, anxious dogs move through the world more easily."`** +- `"A routine built around you both"` body: `"1:1 gives us flexibility to build a routine that works for your dog and your week."` → **`"We shape the routine around your dog and your week."`** (drop "1:1 gives us flexibility" — meta-framing, redundant) + +### Booking dogIntro + +Current: `"Tell us about your dog. Where you are. Anything we should know. We will come back about the right fit."` + +Proposal: **`"Tell us about your dog. Where you are. Anything we should know. We'll come back about the fit."`** Match pack-walks pattern; contraction. + +--- + +## 5. Puppy Visits (`puppy-visits.ts`) + +### Hero subtitle + +Current: `"While you're at work, your puppy is fed, played with, and looked after. At home."` + +This is the v1 voice-doc's chosen example. Keep. + +### Hero paragraphs + +Currently four. Paragraph 3 is the strongest (the growth-plates / vet rationale) and is buried. Reorder + tighten: + +Proposal: +> "A visit means a toilet break, fresh water, a feed if scheduled, play, and calm settling time before we leave. Photo update lands in your phone." +> +> "Short visits beat long walks while your puppy is growing. Vets recommend low-impact exercise until growth plates settle — usually 12 to 18 months. Visits give them company and stimulation without the joint stress." +> +> "Visits are also where Goodwalk usually starts. We know your puppy early, so the move to solo walks or Tiny Gang later is smooth." + +Coverage line ("Across Auckland Central — Mt Eden, Ponsonby...") goes to the chip / FAQ. Three paragraphs, clearer order: what happens → why it's right for puppies → where it leads. + +### Highlight title + +Current: `"Calm routines now. A smoother Tiny Gang later."` + +Already nice. Keep. + +### Decision footnote + +Current: `"Free Meet & Greet first. Always."` + +Already nice. Keep. + +### Pricing intro + +Current: `"Built around your puppy. Real support now. Foundations for later, if Tiny Gang is the right fit."` + +Proposal: **`"Built around your puppy. Real support now. Foundations for whatever comes next."`** "If Tiny Gang is the right fit" hedges and over-explains. The "whatever comes next" implies the same thing without conditional language. + +### Plan features — same hedge pattern + +| Plan | Current 4th feature | Proposal | +|---|---|---| +| 20 Min | `Good for shorter midday support` | `For shorter midday support` | +| 45 Min | `Best fit for many puppies` | `Most popular visit length` | +| 60 Min | `Best for pups needing more time` | `For pups who need more time` | + +### scarcityNote + +Current: `"Puppy Visit spaces are limited so we can keep care consistent."` + +Proposal: **`"Puppy Visit spaces are limited."`** The reason is obvious and over-explained. + +### Benefits intro + +Current: `"The puppy stage moves fast. Daytime visits give your puppy support now — and build the routines that make later life easier."` + +Proposal: **`"The puppy stage moves fast. Daytime visits help now, and build routines that make later life easier."`** Drop "give your puppy support" (abstract), use "help now" (concrete verb). + +### Benefit body fixes + +- `"Foundations for Tiny Gang later"` body: `"For puppies who may join Tiny Gang one day, early visits build the confidence and routines that make the next step smooth."` — TWO hedges in one sentence ("may", "one day"). → **`"For puppies who'll join Tiny Gang later, early visits build the confidence and routines that make the next step smooth."`** +- `"Support for busy owners too"` body has `"during a demanding stage"` — vague. → **`"Real help when puppies are learning fast. Guidance from someone who's been through this stage with dozens of dogs."`** + +### FAQ "How long is each visit?" + +Current answer says "30 minutes — the sweet spot" but the pricing plan starts at **20 minutes**. Inconsistency — fix the FAQ, not the price. + +Proposal: **`"20 minutes for shorter midday support. 45 minutes for most puppies. 60 minutes if your pup needs more time."`** Matches the pricing plans exactly. + +### FAQ "Can Puppy Visits lead into Tiny Gang…" + +Current: `"Exactly what they are designed for. When your puppy is old enough and the right temperament fit, we already know them well. The next step is smooth, not new."` + +Proposal: **`"Exactly what they're for. By the time your puppy is old enough, we already know them. The next step is smooth, not new."`** Contraction, drop "and the right temperament fit" (implied), drop "designed for" (passive-corporate). + +### Booking dogIntro + +Current: `"Tell us about your puppy. Where you are. Their routine. Anything we should know — and we will plan the right visit."` + +Proposal: **`"Tell us about your puppy. Where you are. Their routine. We'll plan the right visit."`** Drop "Anything we should know — and we will" (redundant, hedging). + +--- + +## 6. About page (`about.ts`) + +### "Who we are" section + +Current: +> "Alessandra started Goodwalk because she could not find a walker she trusted. So she became one." +> "She walks every dog herself. Posts photos to Instagram so you can see your dog's day. Knows some of the Tiny Gang from ten weeks old." +> "Thirty-plus five-star Google reviews say the same thing: the dogs adore her, and their owners finally stop worrying." + +Line 1 is gold. Keep. Line 2 is fine. Line 3 has "say the same thing" which is filler. + +Proposal line 3: **`"Thirty-plus five-star Google reviews: the dogs adore her, and their owners stop worrying."`** Drop "say the same thing" and "finally" (mild hedge). + +### "How we do things" + +Current: +> "Calm handling. Positive reinforcement. A walker who already knows your dog. Same principles, every walk." +> "Small packs because attention matters. Free pickup and drop-off because your day should not work around us. First aid certified. Public liability insured. That part is not negotiable." + +Line 2 is doing six things at once. Split: + +Proposal: +> "Calm handling. Positive reinforcement. A walker who already knows your dog. Same principles, every walk." +> "Small packs because attention matters. Free pickup and drop-off because your day shouldn't work around ours." +> "First aid certified. Public liability insured. Not negotiable." + +Three paragraphs, three jobs. Contraction on "shouldn't". + +### "Meet the founder" — line 3 (Maya) + +Current: `"Maya is the reason small dogs sit at the centre of everything. A Cavalier King Charles cross Shih Tzu. Opinionated. Dramatic in the rain. Completely impossible to ignore on a walk — and the best argument we have for building a service around small dogs, not one that just makes room for them."` + +Strong texture, but ends with a 27-word sentence past the voice budget (max ~24). Split: + +Proposal: +> "Maya is the reason small dogs sit at the centre of everything. A Cavalier King Charles cross Shih Tzu. Opinionated. Dramatic in the rain. Impossible to ignore on a walk." +> "She's the best argument we have for a service built around small dogs — not one that just makes room for them." + +### FAQ "Why do you specialise in small dogs?" + +Current: `"Small dogs need different pace, different group dynamics, different handling. Goodwalk was built around that — not adapted from a generic dog-walking model."` + +Proposal: **`"Small dogs need a different pace, different group dynamics, different handling. We were built around that, not adapted from a generic model."`** Active voice ("We were built" instead of "Goodwalk was built"), trim "dog-walking" (implied). + +### FAQ "What suburbs do you cover?" + +Current has 16 suburb names listed inline. The map / chips already show them. Compress: + +Proposal: **`"Most of Auckland Central — Ponsonby, Grey Lynn, Mt Eden, Kingsland, Herne Bay, Remuera and surrounds. If you're nearby and unsure, just ask."`** Cuts the list to the highest-recognition six. The exhaustive list lives on the homepage Locations block and the coverage map. + +--- + +## 7. Locations (`locations.ts`) — sweeping pattern + +Every location intro is structured the same way: descriptive lead → "well-suited / natural home / ideal place for…" → Goodwalk services available → free pickup line. Three of these in a row, the pattern shows. + +### Park descriptions — universal cleanup + +The voice doc says "Replace abstract nouns with concrete verbs". Park blurbs lean on adjective-stacks: + +- `"offers wide open paths, panoramic views across Auckland, and a mix of gentle and steeper terrain"` → **`"Wide open paths, panoramic views, gentle and steep terrain."`** (drop "offers") +- `"Popular with local dog walkers and a staple route for the Tiny Gang"` → keep. +- `"A well-used neighbourhood park… with open grass areas and shade trees"` → **`"Neighbourhood park with open grass and shade."`** + +Recommend a pass on all 30+ park descriptions: cut every "offers", "provides", "with X and Y" sentence opener, and every "well-suited / ideal place / natural home". + +### Intro pattern — propose a template + +Right now Mt Eden's intro is 71 words. Most location intros are 50-80 words. Voice doc says "Body sentences: max ~24". Propose a 3-sentence template: + +> "[Suburb] [one specific thing — geography, vibe, dog density]. [Goodwalk fact — who from here is in the Tiny Gang / what we run here]. Free pickup and drop-off." + +Worked example, Mt Eden: + +> "Mt Eden's volcanic cone, leafy streets, and mix of reserves and quiet paths make it a daily-outing favourite. We run pack walks and solo walks here weekly, with several Tiny Gang regulars based in the suburb. Free pickup and drop-off included." + +53 words → still long, but every clause does work. Apply the template to all 17 suburbs in a follow-up pass. + +--- + +## 8. Repeated lines across pages + +The phrase **"Free pickup and drop-off across Auckland Central"** (or close variants) appears 14+ times: in hero chips, paragraph 4 of every service hero, FAQ answers, the coverage map, the locations page, and the about page. It's a real selling point — but said this often it stops landing. + +Proposal — vary the wording by surface: + +- **Chip** (compact): `"Free pickup & drop-off"` +- **Hero paragraph**: usually deletable since the chip is right there. If kept: `"Pickup and drop-off included, across Auckland Central."` +- **FAQ**: keep specific — that's where someone is actually checking. +- **Footer / coverage block**: `"Door-to-door across Auckland Central."` + +Save the full sentence for places where the suburb list matters. + +--- + +## 9. Summary of recurring fixes + +If we agree on the principles below, I can sweep these patterns site-wide without you reviewing each one: + +1. **Drop "Best for / Best fit"** in pricing features. State who it's for, or drop. +2. **Contractions** ("we'll", "shouldn't", "they're") where the surrounding tone is conversational. +3. **"1:1" → "Solo"** in body copy, navigation, decision blocks. Keep "1:1" in SEO meta/title where keyword volume might matter. +4. **Cut "properly", "actually", "genuinely", "really"** unless the sentence dies without them. +5. **Cut "we will come back about"** → "we'll reply about" / "we'll come back" (less corporate). +6. **Park descriptions**: rewrite the "offers / provides / well-suited / a mix of" sentences as concrete noun phrases. +7. **Service hero paragraphs**: target 3 paragraphs, not 5. Coverage and disqualifier lines move down the page. +8. **Drop reasons that are already obvious from context** — "so we can keep care consistent", "Higher rate than weekly routines", "in your home" after "in-home". + +--- + +## What this changes for the reader + +- Faster scan on every service page (3 hero paragraphs instead of 5). +- Consistent terminology between nav, body, and CTAs (no "1:1" / "Solo" / "one-on-one" mix). +- Pricing tables stop sounding like a SaaS comparison grid. +- Location pages stop reading like council brochures. + +## What it does NOT change + +- The brand voice from `marketing-voice.md` — this proposal is a stricter application of that voice, not a rewrite of it. +- SEO `<title>` / `meta description` / `llms.txt` keywords — those remain under a separate review (the "1:1" tradeoff lives there). +- Customer testimonials — never edited. +- Service names ("Tiny Gang", "Meet & Greet") — kept verbatim. + +--- + +**Next step**: redline this file. Strike the changes you don't want, mark anything that needs different phrasing, and I'll apply the rest in one sweep. Or pick a single section to start with (homepage? service heroes? locations?) so we can validate the voice before going site-wide. diff --git a/marketing-voice.md b/marketing-voice.md new file mode 100644 index 0000000..6da0810 --- /dev/null +++ b/marketing-voice.md @@ -0,0 +1,197 @@ +# Goodwalk Marketing Voice + +A practical guide for writing site copy that sells without sounding like it's selling. + +## The voice in one line + +**A trusted neighbour who happens to be brilliant at this.** Calm, certain, warm, specific. Not corporate. Not chirpy. Not over-promising. + +## What we're borrowing from Apple + +Apple's marketing works because it does three things ruthlessly: + +1. **Leads with the outcome, not the process.** "A thousand songs in your pocket" — not "5GB of solid-state storage." +2. **Makes the decision feel small.** Confident, declarative sentences. No hedging. +3. **Cuts every word the meaning doesn't need.** Short. Then one longer line for texture. Then short again. + +For a service business, the equivalent is selling the **evening** (calm dog, settled house, no guilt), not the **walk** (60 minutes, pickup included, group size 4–8). + +## Voice attributes + +| Attribute | What it means | What it isn't | +|---|---|---| +| **Calm** | Even cadence. No exclamation marks. No "amazing!" or "incredible!" | Hyped, sales-y | +| **Certain** | "We do X." Not "We try to X" or "We may be able to X." | Arrogant, brash | +| **Warm** | Real feeling for dogs and owners. "Your dog comes home tired and happy." | Saccharine, cutesy ("fur babies", "pawsome") | +| **Specific** | Names suburbs, parks, times. Numbers when they help. | Vague ("various", "a wide range", "we offer") | +| **Honest** | If a service isn't right for a dog, we say so. | False scarcity, manipulative urgency | + +## Principles + +### 1. Lead with the customer's win, not your feature + +Open every section with what the **owner gets** or what the **dog feels**. The mechanism comes second. + +> ❌ "Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine." +> +> ✅ "Your dog comes home tired and happy. You stop worrying through the workday. That's the whole point." + +### 2. Cut every hedge + +Search-and-destroy these words: *can, may, might, try to, more, genuinely, properly, generally, often, typically, possibly.* Each one quietly weakens the sentence. + +> ❌ "Walks tailored to your dog's pace, confidence, and routine." +> +> ✅ "Built around your dog. Their pace. Their walk." + +### 3. Short. Then long. Then short. + +Vary the rhythm. A wall of medium-length sentences is the most boring possible cadence. + +> ❌ "Goodwalk Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday." +> +> ✅ "A walk your dog looks forward to. A routine you don't have to manage. Pickup, walk, drop-off, photo update — every time, without you having to ask." + +### 4. Active voice, present tense + +Things happen. We do them. Your dog enjoys them. Avoid "are designed to" / "is intended for" / "can be tailored." + +> ❌ "Our visits are intended to provide enrichment and support during the day." +> +> ✅ "We visit. We play. We feed. You get a photo when we leave." + +### 5. Replace abstract nouns with concrete verbs + +"Provide structure" → "settles them." "Build confidence" → "they stop pulling on the lead." "Ensure consistency" → "same walker, every time." + +### 6. Specifics build trust faster than adjectives + +"A well-loved local park" tells me nothing. "Western Springs at 9:15, Cornwall Park on Wednesdays" tells me you're real. + +### 7. Sell the relief + +Owners aren't buying a walk. They're buying: a quieter evening, a guilt-free workday, one fewer thing to manage. Name those. + +### 8. One idea per sentence + +If you wrote a comma, ask whether it should be a full stop. + +## Patterns we use + +### Headlines + +Two flavours, used purposefully: + +- **Outcome line:** "Come home to a calm, happy dog." +- **Definitional line:** "Pack walks for small dogs that actually suit small dogs." + +Avoid: "Welcome to Goodwalk." / "About Us." / "Our Services." + +### Subheads / leads + +One sentence. Says what the section delivers, not what it is. + +> ❌ "About our pack walks" +> +> ✅ "Four to eight dogs. Same walker every time. Home by mid-afternoon." + +### Body copy + +- 1–3 short paragraphs max per section +- Lead sentence is the most important; treat it like a headline +- One link or CTA per paragraph, max + +### CTAs + +Action + outcome, never just "Submit" or "Learn more." + +| ❌ | ✅ | +|---|---| +| Submit | Book a free Meet & Greet | +| Learn more | See if Tiny Gang fits your dog | +| Contact us | Talk to Aless | +| Get started | Start with a Meet & Greet | + +### FAQ answers + +- First sentence answers the question completely +- Second sentence (if any) adds the texture +- No "Great question!" / "Glad you asked" + +> Q: How big are the pack walks? +> +> ✅ "4–8 dogs, carefully matched on size and energy. We never run oversized packs — the small group size is the whole point." + +## Words and phrases + +### Use + +- **You / your dog** — far more than "owners" or "clients" +- **We** — direct, owned. Not "the team" or "our walkers" +- **Walk, visit, pickup, drop-off** — the customer's words +- **Tiny Gang** — our signature, use sparingly so it stays distinct +- **Auckland Central** — anchors local intent +- Concrete park names, suburb names, times + +### Avoid + +- "Solutions" — never. We're not enterprise software. +- "Services" as a noun in body copy — too distant. Name the thing. +- "Pet parents" / "fur babies" / "pup parents" — twee +- "Pawsome" / "pawfect" / any pun — never +- "We are passionate about" — show, don't tell +- "Industry-leading" / "best in class" / "premium" — empty +- "Reach out" — say "email" or "text" or "call" +- Exclamation marks in headlines or body copy + +## Sentence length budget + +- **Headlines:** ≤ 8 words +- **Subheads:** ≤ 14 words +- **Body sentences:** average 12–16 words, max ~24 +- **First sentence of any section:** ≤ 12 words + +If you wrote a 30-word sentence, it's two sentences. + +## Before / after, from the live site + +### Hero subtitle (homepage) + +> ❌ "Reliable dog walking for busy Auckland owners who want happier dogs, calmer evenings, and a team they can trust." +> +> ✅ "Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings." + +### Pack walks intro paragraph + +> ❌ "Goodwalk Tiny Gang Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday." +> +> ✅ "Tiny Gang is built for small and medium dogs who like the right kind of company. Small groups. Same walker. A real walk, every time." + +### Puppy visits subtitle + +> ❌ "Toilet breaks, play, feeding, and calm one-on-one attention — at home, while you're out." +> +> ✅ "While you're at work, your puppy is fed, played with, and looked after. At home." + +### Benefits-section intro + +> ❌ "Small, compatible groups give dogs the exercise, confidence, and routine they need without the chaos of oversized pack walks." +> +> ✅ "Small groups. Compatible dogs. No chaos. That's why it works." + +## A 60-second editing pass + +Before any new copy ships, run it through this: + +1. **Cut 20%.** If you can't, cut 10%. +2. **First sentence test.** Could it be a headline? If not, rewrite. +3. **Hedge sweep.** Delete every *can/may/might/try to/generally/typically* and re-read. Most are improvements. +4. **Active voice check.** Search for "is/are [verb-ed] by" or "is intended to" and rewrite. +5. **Specific vs vague.** Replace one vague phrase per paragraph with a real name, number, or detail. +6. **Read it aloud.** If you take a breath mid-sentence, it's too long. + +## When to break these rules + +- **Legal pages, contracts, privacy.** Be precise and complete, not punchy. +- **Onboarding instructions.** Clarity > rhythm. +- **Genuine warmth moments.** A short, slightly longer line about a dog or a moment is allowed — it's the texture. Just don't make it the default. diff --git a/nginx/goodwalk.co.nz.svelte.conf.example b/nginx/goodwalk.co.nz.svelte.conf.example index 36ff556..d3bbf60 100644 --- a/nginx/goodwalk.co.nz.svelte.conf.example +++ b/nginx/goodwalk.co.nz.svelte.conf.example @@ -28,6 +28,20 @@ server { } } +server { + listen 80; + server_name admin.goodwalk.co.nz; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + try_files $uri =404; + } + + location / { + return 301 https://admin.goodwalk.co.nz$request_uri; + } +} + server { listen 443 ssl; server_name goodwalk.co.nz; @@ -205,3 +219,74 @@ server { proxy_set_header Connection "upgrade"; } } + +server { + listen 443 ssl; + server_name admin.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/admin.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.goodwalk.co.nz/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Robots-Tag "noindex, nofollow" always; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml; + + resolver 127.0.0.11 ipv6=off valid=30s; + + location ~* /\.(git|env|htaccess) { + deny all; + } + + # Auth endpoints proxied to mail-api (verify / login / logout). + location /api/auth/ { + set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; + limit_req zone=goodwalk_limit burst=10 nodelay; + rewrite ^/api/auth/(.*)$ /auth/$1 break; + proxy_pass http://$goodwalk_mail_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # All /api/owner/* endpoints proxied to mail-api. + location /api/owner/ { + set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; + limit_req zone=goodwalk_limit burst=20 nodelay; + rewrite ^/api/owner/(.*)$ /owner/$1 break; + proxy_pass http://$goodwalk_mail_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + set $goodwalk_frontend goodwalk_svelte_app:3000; + proxy_pass http://$goodwalk_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/package-lock.json b/package-lock.json index 834280a..74ab5cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "goodwalk-svelte-port", - "version": "4.2.3", + "name": "gw-svelte", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "goodwalk-svelte-port", - "version": "4.2.3", + "name": "gw-svelte", + "version": "4.0.0", "dependencies": { "canvas-confetti": "^1.9.4", "pg": "^8.13.1" diff --git a/package.json b/package.json index f5e1ffb..da6ac6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "goodwalk-svelte-port", - "version": "4.2.3", + "name": "gw-svelte", + "version": "4.0.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 15eef1d..7893d2d 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -12,6 +12,7 @@ NGINX_COMPOSE_FILE="" NGINX_PROJECT_NAME="" MAINTENANCE_HOST_DIR="" MAINTENANCE_FLAG_PATH="" +SEED_ADMIN_DATA=0 usage() { cat <<'EOF' @@ -84,6 +85,10 @@ while [[ $# -gt 0 ]]; do usage exit 0 ;; + --seed-admin-data) + SEED_ADMIN_DATA=1 + shift 1 + ;; *) fail "Unknown argument: $1" ;; @@ -349,6 +354,13 @@ if (( nginx_args_present )); then MAINTENANCE_ACTIVE=1 fi +if [[ "$SEED_ADMIN_DATA" -eq 1 ]]; then + echo "[deploy-remote] Admin data seed requested: mail-api will overwrite admin_kv from JSON on next boot" + export ADMIN_DATA_SEED_FROM_JSON="force" +else + export ADMIN_DATA_SEED_FROM_JSON="auto" +fi + if [[ -n "$SERVICE_NAME" ]]; then echo "[deploy-remote] Stopping only the Goodwalk service: $SERVICE_NAME" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs index 26cd627..868206f 100644 --- a/scripts/optimize-images.mjs +++ b/scripts/optimize-images.mjs @@ -4,6 +4,7 @@ import { join, extname, basename } from 'node:path'; const MAX_WIDTH = 1600; const MIN_BYTES_TO_OPTIMISE = 250 * 1024; +const OPTIMISE_WEBP = process.env.OPTIMISE_WEBP === '1'; const dirs = ['src/lib/images', 'static/images']; @@ -31,6 +32,7 @@ async function optimiseFile(file) { } else if (ext === '.jpg' || ext === '.jpeg') { buf = await pipeline.jpeg({ quality: 82, mozjpeg: true }).toBuffer(); } else if (ext === '.webp') { + if (!OPTIMISE_WEBP) return { file, original: (await stat(file)).size, optimised: (await stat(file)).size, skipped: true }; buf = await pipeline.webp({ quality: 80, effort: 6 }).toBuffer(); } else { return null; @@ -54,6 +56,10 @@ for (const dir of dirs) { const file = join(dir, name); const s = await stat(file); if (s.size < MIN_BYTES_TO_OPTIMISE) continue; + if (/\.webp$/i.test(name) && !OPTIMISE_WEBP) { + console.log(`${basename(file).padEnd(58)} ${(s.size / 1024).toFixed(0).padStart(5)}KB (skipped: existing WebP)`); + continue; + } try { const res = await optimiseFile(file); if (!res) continue; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..776500a --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,33 @@ +import type { Handle } from '@sveltejs/kit'; + +const ADMIN_HOSTNAME = 'admin.goodwalk.co.nz'; +const ADMIN_PATH = '/owner/welcome'; + +function isAdminHost(hostname: string | undefined | null): boolean { + if (!hostname) return false; + return hostname.toLowerCase() === ADMIN_HOSTNAME; +} + +export const handle: Handle = async ({ event, resolve }) => { + const onAdminHost = isAdminHost(event.url.hostname); + const path = event.url.pathname; + + // The admin host serves the dashboard at its root. + if (onAdminHost && (path === '/' || path === '')) { + return new Response(null, { + status: 302, + headers: { location: ADMIN_PATH }, + }); + } + + // Block the admin dashboard from the public marketing host so it only + // lives on admin.goodwalk.co.nz in production. Localhost and the + // onboarding subdomain are still allowed for development and migration. + const hostname = event.url.hostname.toLowerCase(); + const isPublicMarketingHost = hostname === 'goodwalk.co.nz' || hostname === 'www.goodwalk.co.nz'; + if (isPublicMarketingHost && path.startsWith('/owner/')) { + return new Response('Not Found', { status: 404 }); + } + + return resolve(event); +}; diff --git a/src/lib/components/AboutPage.svelte b/src/lib/components/AboutPage.svelte index 74c9160..4a93401 100644 --- a/src/lib/components/AboutPage.svelte +++ b/src/lib/components/AboutPage.svelte @@ -164,11 +164,13 @@ <div class="page-inner"> <CtaCard title={pageContent.contact.title} - description="Questions, pricing, or your first Meet & Greet — start here and we'll reply within 24 hours." + description="Questions, pricing, or a first Meet & Greet. Email, call, or send an Instagram DM. We'll reply within 24 hours." ctaHref={pageContent.contact.cta.href} ctaLabel={pageContent.contact.cta.label} email={pageContent.contact.email} phone={pageContent.contact.phone} + instagramHref="https://www.instagram.com/goodwalk.nz/" + contactNote="Email, call, or send an Instagram DM. We want to be easy to reach in the way that suits you best." showIcons={true} /> </div> diff --git a/src/lib/components/BookingPage.svelte b/src/lib/components/BookingPage.svelte index 45b76f4..e2a2cb4 100644 --- a/src/lib/components/BookingPage.svelte +++ b/src/lib/components/BookingPage.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import BookingSection from '$lib/components/BookingSection.svelte'; + import BookingWizard from '$lib/components/BookingWizard.svelte'; import Icon from '$lib/components/Icon.svelte'; import InfoSection from '$lib/components/InfoSection.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; @@ -7,7 +7,10 @@ export let booking: BookingContent; export let info: InfoContent; + // General-enquiry mode disabled site-wide for now. Accepted as a prop so + // existing callers keep type-checking, but the value is intentionally ignored. export let allowGeneralEnquiry = false; + void allowGeneralEnquiry; const email = 'info@goodwalk.co.nz'; const phone = '(022) 642 1011'; @@ -18,9 +21,7 @@ <PageHeader variant="green" title="Contact Us" - subtitle={allowGeneralEnquiry - ? "Book a Meet & Greet or send a general enquiry. We'll come back within 24 hours." - : "Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."} + subtitle="Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet." > <div class="booking-page-contact"> <a href="mailto:{email}" class="booking-contact-link"> @@ -34,7 +35,7 @@ </div> </PageHeader> - <BookingSection {booking} {allowGeneralEnquiry} variant="contact-modern" /> + <BookingWizard {booking} pagePath="/contact-us" /> <InfoSection {info} /> </main> diff --git a/src/lib/components/BookingSection.svelte b/src/lib/components/BookingSection.svelte index 48f8e71..123db2c 100644 --- a/src/lib/components/BookingSection.svelte +++ b/src/lib/components/BookingSection.svelte @@ -29,12 +29,12 @@ messagePlaceholder: 'For example: age, confidence around other dogs, recall, and anything else that would help us place them well.' }, - '1:1 Walks': { + 'Solo Walks': { intro: - 'Tell us about your dog, your suburb, and what you want from one-to-one walks so we can plan the right routine.', + 'Tell us about your dog, your suburb, and what you want from solo walks so we can plan the right routine.', messageLabel: 'What your dog needs', messagePlaceholder: - 'For example: size, pace, lead manners, confidence, and anything else that would help us tailor a one-to-one walk.' + 'For example: size, pace, lead manners, confidence, and anything else that would help us tailor a solo walk.' }, 'Puppy Visits': { intro: diff --git a/src/lib/components/BookingWizard.svelte b/src/lib/components/BookingWizard.svelte new file mode 100644 index 0000000..301d730 --- /dev/null +++ b/src/lib/components/BookingWizard.svelte @@ -0,0 +1,1110 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { fade } from 'svelte/transition'; + import Icon from '$lib/components/Icon.svelte'; + import { reveal } from '$lib/actions/reveal'; + import type { BookingContent } from '$lib/types'; + + type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default; + type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default; + + export let booking: BookingContent; + export let pagePath = ''; + + const defaultServices = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Other Services']; + $: serviceOptions = booking.serviceOptions && booking.serviceOptions.length > 0 + ? booking.serviceOptions + : defaultServices; + + const serviceDescriptions: Record<string, string> = { + 'Tiny Gang Pack Walks': 'Small-group dog walks for sociable dogs.', + 'Solo Walks': 'One-on-one dog walking, your dog\'s pace.', + 'Puppy Visits': 'In-home visits while you are at work.', + 'Other Services': 'Something else? Tell us in the notes.' + }; + + const pathToService: Record<string, string> = { + '/pack-walks': 'Tiny Gang Pack Walks', + '/dog-walking': 'Solo Walks', + '/puppy-visits': 'Puppy Visits' + }; + + const avatarDogs = [ + { image: '/images/archie-goodwalk-dog-walking-review-auckland.webp', alt: 'Archie' }, + { image: '/images/monty-goodwalk-dog-walking-review-auckland.webp', alt: 'Monty' }, + { image: '/images/otis-goodwalk-dog-walking-review-auckland.webp', alt: 'Otis' } + ]; + + const visitStartedStorageKey = 'goodwalk_visit_started_at'; + const journeyStorageKey = 'goodwalk_journey'; + const maxJourneyEntries = 8; + + let step = 1; + let petName = ''; + let location = ''; + let message = ''; + let fullName = ''; + let email = ''; + let phone = ''; + let selectedServices: string[] = []; + let website = ''; + + let formStartedAt = 0; + let visitStartedAt = 0; + let pageEnteredAt = 0; + let firstInteractionAt = 0; + let sendClickedAt = 0; + let stepChanges = 0; + let journey: string[] = []; + + let errors: Record<string, string> = {}; + let submitting = false; + let submitted = false; + let showErrorModal = false; + let submitErrorDetail = ''; + + let SuccessModalComponent: SuccessModalComponentType | null = null; + let ErrorModalComponent: ErrorModalComponentType | null = null; + + $: headingParts = splitTitle(booking.title || "Let's meet!"); + $: leadCopy = booking.subtitle && booking.subtitle.trim() + ? booking.subtitle + : 'Start with a few details about your dog. We will come back within 24 hours with the right next step.'; + $: trustTitle = petName.trim() + ? `${capitalizeFirst(petName.trim())} could be a great fit for Goodwalk.` + : 'Trusted by Goodwalk dog owners across Auckland.'; + $: trustNote = selectedServices.length === 1 + ? `${selectedServices[0]}, planned around your dog rather than a generic routine.` + : 'Tell us a little about your dog and we will help you find the right fit.'; + + $: if (submitted) ensureSuccessModal(); + $: if (showErrorModal) ensureErrorModal(); + + onMount(() => { + const now = Date.now(); + formStartedAt = now; + pageEnteredAt = now; + visitStartedAt = readOrCreateVisitStartedAt(now); + journey = updateJourneySnapshot(window.location.pathname, window.location.search); + + const path = pagePath || window.location.pathname; + const preset = pathToService[path]; + if (preset && serviceOptions.includes(preset) && selectedServices.length === 0) { + selectedServices = [preset]; + } + + const handleRequestedService = (event: Event) => { + const detail = (event as CustomEvent<{ service?: string }>).detail; + const service = detail?.service?.trim(); + if (service && serviceOptions.includes(service) && !selectedServices.includes(service)) { + selectedServices = [...selectedServices, service]; + } + }; + + window.addEventListener('goodwalk:service-selected', handleRequestedService as EventListener); + return () => { + window.removeEventListener('goodwalk:service-selected', handleRequestedService as EventListener); + }; + }); + + function splitTitle(title: string) { + const trimmed = title.trim(); + const lastSpace = trimmed.lastIndexOf(' '); + if (lastSpace === -1) return { plain: trimmed, highlight: '' }; + return { + plain: trimmed.slice(0, lastSpace), + highlight: trimmed.slice(lastSpace + 1) + }; + } + + function capitalizeFirst(value: string) { + if (!value) return value; + return value.charAt(0).toLocaleUpperCase() + value.slice(1); + } + + function readOrCreateVisitStartedAt(fallback: number) { + try { + const raw = window.sessionStorage.getItem(visitStartedStorageKey); + const parsed = raw ? Number(raw) : NaN; + if (Number.isFinite(parsed) && parsed > 0) return parsed; + window.sessionStorage.setItem(visitStartedStorageKey, String(fallback)); + } catch { + return fallback; + } + return fallback; + } + + function updateJourneySnapshot(pathname: string, search: string) { + const nextEntry = `${pathname}${search}`; + try { + const raw = window.sessionStorage.getItem(journeyStorageKey); + const previous = raw ? (JSON.parse(raw) as string[]) : []; + const cleaned = previous.filter((value) => typeof value === 'string' && value.trim()); + const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry]; + const nextJourney = deduped.slice(-maxJourneyEntries); + window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney)); + return nextJourney; + } catch { + return [nextEntry]; + } + } + + async function ensureSuccessModal() { + if (SuccessModalComponent) return; + SuccessModalComponent = (await import('$lib/components/SuccessModal.svelte')).default; + } + + async function ensureErrorModal() { + if (ErrorModalComponent) return; + ErrorModalComponent = (await import('$lib/components/ErrorModal.svelte')).default; + } + + function noteInteraction() { + if (!firstInteractionAt) firstInteractionAt = Date.now(); + } + + function clearError(field: string) { + if (errors[field]) errors = { ...errors, [field]: '' }; + } + + function toggleService(service: string) { + noteInteraction(); + selectedServices = selectedServices.includes(service) + ? selectedServices.filter((s) => s !== service) + : [...selectedServices, service]; + clearError('services'); + } + + function validateEmail(raw: string): string { + const value = raw.trim(); + if (!value) return 'Please enter your email'; + if (!value.includes('@')) return 'Email is missing the @ sign'; + const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/; + if (!re.test(value)) return "That email doesn't look quite right"; + return ''; + } + + function validateStep(target: number): boolean { + const next: Record<string, string> = {}; + if (target === 1) { + 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 (target === 2) { + 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; + step += 1; + stepChanges += 1; + } + + function goBack() { + noteInteraction(); + if (step > 1) { + step -= 1; + stepChanges += 1; + errors = {}; + } + } + + async function handleSubmit() { + noteInteraction(); + if (!validateStep(2)) return; + + submitting = true; + sendClickedAt = Date.now(); + submitErrorDetail = ''; + showErrorModal = false; + + try { + const res = await fetch('/api/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enquiryType: 'booking', + fullName, + email, + phone, + petName, + location, + message, + services: selectedServices, + website, + formStartedAt, + visitStartedAt, + pageEnteredAt, + firstInteractionAt, + sendClickedAt, + stepChanges, + journey, + referrer: typeof document !== 'undefined' ? document.referrer : '', + page: typeof window !== 'undefined' ? window.location.href : '', + variant: 'booking-wizard' + }) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const detail = + typeof body?.detail === 'string' + ? body.detail + : body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`; + throw new Error(detail); + } + + submitted = true; + } catch (err: unknown) { + submitErrorDetail = err instanceof Error ? err.message : String(err); + showErrorModal = true; + } finally { + submitting = false; + } + } +</script> + +<section id="newlead" use:reveal={{ delay: 70 }} class="wiz reveal-block"> + <div class="wiz-inner"> + {#if submitted && SuccessModalComponent} + <svelte:component + this={SuccessModalComponent} + firstName={fullName.split(' ')[0]} + petName={petName.trim() ? capitalizeFirst(petName.trim()) : 'your dog'} + {email} + enquiryType="booking" + onClose={() => (submitted = false)} + /> + {/if} + + {#if showErrorModal && ErrorModalComponent} + <svelte:component + this={ErrorModalComponent} + detail={submitErrorDetail} + enquiryType="booking" + onClose={() => (showErrorModal = false)} + onRetry={() => (showErrorModal = false)} + /> + {/if} + + <div class="wiz-header"> + <span class="wiz-eyebrow">Free Meet & Greet</span> + <h2 class="wiz-title"> + <span class="wiz-title-plain">{headingParts.plain}</span> + {#if headingParts.highlight} + {' '} + <span class="wiz-title-highlight">{headingParts.highlight}</span> + {/if} + </h2> + <p class="wiz-lead">{leadCopy}</p> + </div> + + <div class="wiz-trust" aria-label="Goodwalk dog families"> + <div class="wiz-avatars" aria-hidden="true"> + {#each avatarDogs as dog} + <span class="wiz-avatar"> + <img src={dog.image} alt="" loading="lazy" /> + </span> + {/each} + </div> + <div class="wiz-trust-copy"> + <p>{trustTitle}</p> + <span>{trustNote}</span> + </div> + </div> + + <div class="wiz-progress" role="progressbar" aria-valuemin="1" aria-valuemax="2" aria-valuenow={step}> + <span class="wiz-step-mark" class:active={step >= 1} class:done={step > 1}> + <span class="wiz-step-num">1</span> + <span class="wiz-step-label">{booking.dogStepLabel || 'Your dog'}</span> + </span> + <span class="wiz-step-line" class:done={step > 1} aria-hidden="true"></span> + <span class="wiz-step-mark" class:active={step === 2}> + <span class="wiz-step-num">2</span> + <span class="wiz-step-label">{booking.ownerStepLabel || 'Your details'}</span> + </span> + </div> + + <form + class="wiz-form" + novalidate + on:submit|preventDefault={handleSubmit} + on:focusin={noteInteraction} + on:input={noteInteraction} + > + <div class="wiz-honeypot" aria-hidden="true"> + <label for="website">Website</label> + <input + bind:value={website} + type="text" + id="website" + name="website" + tabindex="-1" + autocomplete="new-password" + /> + </div> + + <article class="wiz-card"> + {#key step} + <div class="wiz-step" in:fade={{ duration: 200 }}> + {#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> + + <label class="wiz-field"> + <span class="wiz-label"> + <Icon name="fas fa-dog" /> 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" /> Which service are you interested in? + </legend> + <div + class="wiz-service-grid" + 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: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> + + <label class="wiz-field"> + <span class="wiz-label"> + <Icon name="fas fa-comment" /> 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="button" class="wiz-btn wiz-btn-primary" on:click={goNext}> + Continue + <Icon name="fas fa-arrow-right" /> + </button> + </div> + {:else if step === 2} + <span class="wiz-step-eyebrow">Step two of two</span> + <h3 class="wiz-step-heading">How do we reach you?</h3> + <p class="wiz-step-helper">A real person replies within 24 hours, usually sooner.</p> + + <div class="wiz-grid-two"> + <label class="wiz-field"> + <span class="wiz-label"> + <Icon name="fas fa-user" /> 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" /> 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" /> 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" /> 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> + + <div class="wiz-actions wiz-actions-split"> + <button type="button" class="wiz-btn wiz-btn-back" on:click={goBack}> + <Icon name="fas fa-arrow-left" /> + Back + </button> + <button type="submit" class="wiz-btn wiz-btn-primary" disabled={submitting}> + {#if submitting} + Sending + {:else} + Send my details + {/if} + <Icon name="fas fa-paper-plane" /> + </button> + </div> + {/if} + </div> + {/key} + </article> + </form> + + <p class="wiz-reassurance" aria-live="polite"> + <Icon name="fas fa-bolt" /> + A real reply within 24 hours, usually sooner. + </p> + </div> +</section> + +<style> + :global(#newlead).wiz { + padding-left: var(--space-container-x); + padding-right: var(--space-container-x); + background: transparent; + } + + .wiz-inner { + max-width: 56rem; + margin: 0 auto; + } + + .wiz-header { + text-align: center; + margin-bottom: 28px; + } + + .wiz-eyebrow { + display: inline-block; + padding: 8px 16px; + margin-bottom: 18px; + border-radius: 999px; + background: rgba(var(--brand-rgb), 0.08); + color: var(--text-brand); + font-family: var(--font-head); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + } + + .wiz-title { + margin: 0 0 32px; + font-family: var(--font-head); + font-size: clamp(36px, 5.4vw, 64px); + line-height: 1.05; + letter-spacing: -0.02em; + color: var(--text-heading); + } + + .wiz-title-highlight { + position: relative; + display: inline-block; + color: var(--yellow); + } + + .wiz-title-highlight::after { + content: ''; + position: absolute; + left: 0; + right: -8px; + bottom: -18px; + height: 28px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E") + center / contain no-repeat; + pointer-events: none; + } + + @media (max-width: 640px) { + .wiz-title-highlight::after { + left: 12px; + right: 12px; + bottom: -14px; + height: 20px; + } + } + + .wiz-lead { + max-width: 38rem; + margin: 0 auto; + color: var(--text-subtle); + font-size: clamp(15px, 1.4vw, 17px); + line-height: 1.55; + } + + .wiz-trust { + display: flex; + align-items: center; + gap: clamp(14px, 2vw, 22px); + max-width: 44rem; + margin: 0 auto 32px; + padding: 14px 18px; + border-radius: 18px; + background: rgba(var(--white-rgb), 0.72); + border: 1px solid rgba(var(--brand-rgb), 0.08); + box-shadow: 0 14px 32px rgba(var(--ink-rgb), 0.04); + } + + .wiz-avatars { + display: inline-flex; + flex: 0 0 auto; + } + + .wiz-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + overflow: hidden; + border: 2px solid rgba(var(--white-rgb), 1); + box-shadow: 0 4px 10px rgba(var(--ink-rgb), 0.08); + } + + .wiz-avatar + .wiz-avatar { + margin-left: -10px; + } + + .wiz-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .wiz-trust-copy p { + margin: 0 0 2px; + font-family: var(--font-head); + font-weight: 700; + color: var(--text-heading); + font-size: 15px; + line-height: 1.3; + } + + .wiz-trust-copy span { + color: var(--text-subtle); + font-size: 13px; + line-height: 1.45; + } + + .wiz-progress { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-bottom: 24px; + } + + .wiz-step-mark { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 16px 8px 8px; + border-radius: 999px; + background: rgba(var(--brand-rgb), 0.04); + color: var(--text-subtle); + font-family: var(--font-head); + font-size: 13px; + font-weight: 600; + transition: background 200ms ease, color 200ms ease; + } + + .wiz-step-mark.active { + background: rgba(var(--brand-rgb), 0.1); + color: var(--text-heading); + } + + .wiz-step-mark.done { + background: rgba(var(--accent-rgb), 0.18); + color: var(--text-heading); + } + + .wiz-step-num { + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(var(--white-rgb), 1); + color: var(--text-heading); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + box-shadow: inset 0 0 0 1px rgba(var(--brand-rgb), 0.12); + } + + .wiz-step-mark.active .wiz-step-num { + background: var(--yellow); + box-shadow: inset 0 0 0 1px rgba(var(--brand-rgb), 0.18); + } + + .wiz-step-mark.done .wiz-step-num { + background: var(--gw-green); + color: var(--text-inverse); + box-shadow: none; + } + + .wiz-step-line { + flex: 0 0 36px; + height: 2px; + border-radius: 2px; + background: rgba(var(--brand-rgb), 0.12); + } + + .wiz-step-line.done { + background: var(--gw-green); + } + + .wiz-form { + position: relative; + } + + .wiz-honeypot { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; + } + + .wiz-card { + position: relative; + max-width: 44rem; + margin: 0 auto; + padding: clamp(24px, 3.5vw, 36px); + border-radius: 26px; + background: rgba(var(--white-rgb), 1); + border: 1px solid rgba(var(--brand-rgb), 0.08); + box-shadow: 0 30px 60px rgba(var(--ink-rgb), 0.08); + } + + .wiz-step { + display: flex; + flex-direction: column; + gap: 18px; + } + + .wiz-step-eyebrow { + align-self: flex-start; + padding: 6px 12px; + border-radius: 999px; + background: rgba(var(--brand-rgb), 0.06); + color: var(--text-subtle); + font-family: var(--font-head); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .wiz-step-heading { + margin: 0; + font-family: var(--font-head); + font-size: clamp(24px, 3vw, 32px); + line-height: 1.15; + letter-spacing: -0.015em; + color: var(--text-heading); + } + + .wiz-step-helper { + margin: 0 0 6px; + color: var(--text-subtle); + font-size: 15px; + line-height: 1.5; + } + + .wiz-field { + display: flex; + flex-direction: column; + gap: 8px; + } + + .wiz-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--font-head); + font-weight: 700; + font-size: 14px; + 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%; + padding: 13px 14px; + border-radius: 14px; + border: 1px solid rgba(var(--brand-rgb), 0.14); + background: rgba(var(--white-rgb), 1); + font-family: var(--font-body); + font-size: 15px; + color: var(--text-heading); + transition: border-color 180ms ease, box-shadow 180ms ease; + } + + .wiz-field textarea { + resize: vertical; + min-height: 88px; + line-height: 1.5; + } + + .wiz-field input:focus, + .wiz-field textarea:focus { + outline: none; + border-color: var(--gw-green); + box-shadow: 0 0 0 4px rgba(var(--brand-rgb), 0.12); + } + + .wiz-field input.invalid, + .wiz-field textarea.invalid { + border-color: oklch(0.55 0.18 27); + background: oklch(0.985 0.012 27); + } + + .wiz-error { + display: inline-flex; + align-items: center; + gap: 6px; + color: oklch(0.5 0.18 27); + font-size: 13px; + font-weight: 500; + } + + .wiz-fieldset { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 0; + border: none; + } + + .wiz-service-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .wiz-service-grid.invalid { + outline: 1px solid oklch(0.55 0.18 27); + outline-offset: 4px; + border-radius: 18px; + } + + .wiz-service { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 16px; + border: 1.5px solid rgba(var(--brand-rgb), 0.12); + background: rgba(var(--white-rgb), 1); + text-align: left; + cursor: pointer; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; + } + + .wiz-service-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .wiz-service-desc { + font-family: var(--font-body); + font-weight: 400; + font-size: 13px; + line-height: 1.4; + color: var(--text-subtle); + } + + .wiz-service.active .wiz-service-desc { + color: var(--text-heading); + } + + .wiz-service:hover { + border-color: rgba(var(--brand-rgb), 0.32); + } + + .wiz-service.active { + border-color: var(--gw-green); + background: rgba(var(--accent-rgb), 0.1); + } + + .wiz-service-check { + width: 22px; + height: 22px; + flex: 0 0 auto; + border-radius: 6px; + border: 1.5px solid rgba(var(--brand-rgb), 0.32); + background: rgba(var(--white-rgb), 1); + color: var(--text-heading); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: background 180ms ease, border-color 180ms ease; + } + + .wiz-service.active .wiz-service-check { + background: var(--yellow); + border-color: var(--gw-green); + } + + .wiz-service-label { + font-family: var(--font-head); + font-weight: 700; + font-size: 15px; + color: var(--text-heading); + line-height: 1.25; + } + + .wiz-grid-two { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + } + + .wiz-actions { + display: flex; + justify-content: flex-end; + margin-top: 6px; + } + + .wiz-actions-split { + justify-content: space-between; + } + + .wiz-btn { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 13px 22px; + border-radius: 999px; + font-family: var(--font-head); + font-weight: 700; + font-size: 15px; + border: 1.5px solid transparent; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease; + } + + .wiz-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .wiz-btn-primary { + background: var(--yellow); + color: var(--text-heading); + border-color: var(--yellow); + box-shadow: 0 10px 22px rgba(255, 209, 0, 0.32); + } + + .wiz-btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + background: oklch(0.88 0.18 95); + } + + .wiz-btn-back { + background: transparent; + color: var(--text-subtle); + border-color: rgba(var(--brand-rgb), 0.18); + } + + .wiz-reassurance { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + max-width: 44rem; + margin: 18px auto 0; + color: var(--text-subtle); + font-size: 14px; + font-weight: 500; + text-align: center; + } + + .wiz-reassurance :global(.icon) { + color: var(--yellow); + font-size: 13px; + } + + .wiz-btn-back:hover { + color: var(--text-heading); + border-color: rgba(var(--brand-rgb), 0.4); + } + + @media (max-width: 768px) { + :global(#newlead).wiz { + padding-left: var(--space-container-x-mobile); + padding-right: var(--space-container-x-mobile); + } + } + + @media (max-width: 640px) { + .wiz-trust { + flex-direction: column; + text-align: center; + gap: 10px; + } + + .wiz-progress { + gap: 8px; + } + + .wiz-step-label { + display: none; + } + + .wiz-step-mark { + padding: 6px 10px 6px 6px; + } + + .wiz-step-line { + flex: 0 0 24px; + } + + .wiz-service-grid { + grid-template-columns: 1fr; + } + + .wiz-grid-two { + grid-template-columns: 1fr; + } + + .wiz-actions-split { + flex-direction: column-reverse; + gap: 10px; + } + + .wiz-btn { + width: 100%; + justify-content: center; + } + } + + @media (prefers-reduced-motion: reduce) { + .wiz-step-mark, + .wiz-service, + .wiz-btn { + transition: none; + } + } +</style> diff --git a/src/lib/components/ContractPage.svelte b/src/lib/components/ContractPage.svelte index ef2880e..003976e 100644 --- a/src/lib/components/ContractPage.svelte +++ b/src/lib/components/ContractPage.svelte @@ -14,7 +14,7 @@ const ownerEmail = 'info@goodwalk.co.nz'; const ownerPhone = '(022) 642 1011'; - const services = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits']; + const services = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits']; const visitStartedStorageKey = 'goodwalk_visit_started_at'; const draftStorageKey = 'goodwalk_contract_draft'; @@ -173,14 +173,14 @@ } catch { /* storage unavailable */ } } - function applyProfile(serverEmail: string, profile: Record<string, string> = {}) { + function applyProfile(serverEmail: string, profile: Record<string, unknown> = {}) { if (!email) email = serverEmail; - if (!fullName) fullName = profile.fullName ?? ''; - if (!phone) phone = profile.phone ?? ''; - if (!address) address = profile.address ?? ''; - if (!dogName) dogName = profile.dogName ?? ''; - if (!dogBreed) dogBreed = profile.dogBreed ?? ''; - if (!dogAge) dogAge = profile.dogAge ?? ''; + if (!fullName && typeof profile.fullName === 'string') fullName = profile.fullName; + if (!phone && typeof profile.phone === 'string') phone = profile.phone; + if (!address && typeof profile.address === 'string') address = profile.address; + if (!dogName && typeof profile.dogName === 'string') dogName = profile.dogName; + if (!dogBreed && typeof profile.dogBreed === 'string') dogBreed = profile.dogBreed; + if (!dogAge && typeof profile.dogAge === 'string') dogAge = profile.dogAge; onboardingCompleted = Boolean((profile as Record<string, unknown>).onboardingCompleted); contractCompleted = Boolean((profile as Record<string, unknown>).contractCompleted); } @@ -206,7 +206,7 @@ authChecking = false; } - function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, string>; draft?: Record<string, unknown> }>) { + function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, unknown>; draft?: Record<string, unknown> }>) { isAuthenticated = true; userEmail = e.detail.email; applyProfile(e.detail.email, e.detail.profile ?? {}); @@ -1396,8 +1396,7 @@ } .field input, - .field textarea, - .field select { + .field textarea { width: 100%; padding: 15px 16px; border: 1px solid rgba(33, 48, 33, 0.14); @@ -1411,8 +1410,7 @@ } .field input:focus, - .field textarea:focus, - .field select:focus { + .field textarea:focus { border-color: rgba(255, 209, 0, 0.9); box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16); } diff --git a/src/lib/components/CtaCard.svelte b/src/lib/components/CtaCard.svelte index d2b5266..544cf79 100644 --- a/src/lib/components/CtaCard.svelte +++ b/src/lib/components/CtaCard.svelte @@ -9,6 +9,9 @@ export let email: string | undefined = undefined; export let phone: string | undefined = undefined; export let phoneHref: string | undefined = undefined; + export let instagramHref: string | undefined = undefined; + export let instagramLabel = 'Instagram DM'; + export let contactNote: string | undefined = undefined; export let showIcons = false; $: resolvedPhoneHref = phoneHref ?? (phone ? `tel:${phone.replace(/[^0-9+]/g, '')}` : undefined); @@ -22,7 +25,7 @@ {ctaLabel} <Icon name="fas fa-arrow-right" /> </a> - {#if email || phone} + {#if email || phone || instagramHref} <div class="cta-card__links"> {#if email} <a class="cta-card__link" href="mailto:{email}"> @@ -36,7 +39,16 @@ {phone} </a> {/if} + {#if instagramHref} + <a class="cta-card__link" href={instagramHref} target="_blank" rel="noopener"> + {#if showIcons}<Icon name="fab fa-instagram" />{/if} + {instagramLabel} + </a> + {/if} </div> + {#if contactNote} + <p class="cta-card__contact-note">{contactNote}</p> + {/if} {/if} </div> @@ -96,6 +108,14 @@ margin-top: 22px; } + .cta-card__contact-note { + margin: 14px auto 0; + max-width: 460px; + color: rgba(255, 255, 255, 0.68); + font-size: 14px; + line-height: 1.5; + } + .cta-card__link { display: inline-flex; align-items: center; @@ -122,5 +142,10 @@ align-items: center; gap: 14px; } + + .cta-card__contact-note { + margin-top: 12px; + font-size: 13px; + } } </style> diff --git a/src/lib/components/FounderStorySection.svelte b/src/lib/components/FounderStorySection.svelte index e32a2e0..bcb0b97 100644 --- a/src/lib/components/FounderStorySection.svelte +++ b/src/lib/components/FounderStorySection.svelte @@ -27,7 +27,7 @@ <article class="founder-note"> <div class="founder-intro"> <span class="eyebrow founder-kicker">A note from Aless</span> - <span class="founder-greeting">Hi, I'm Aless.</span> + <span class="founder-greeting">Hi, Aless from Goodwalk <span class="founder-greeting-wave" aria-hidden="true">👋</span></span> </div> <h2 class="founder-heading"> @@ -57,7 +57,7 @@ <div class="founder-actions"> <a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk"> <span class="founder-contact-wave" aria-hidden="true">👋</span> - <span>If you are unsure about anything, feel free to email me anytime.</span> + <span>If you are unsure about anything, feel free to email, call, or send me an Instagram DM anytime.</span> </a> <a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta"> @@ -157,6 +157,11 @@ line-height: 1.5; } + .founder-greeting-wave { + display: inline-block; + margin-left: 4px; + } + .founder-heading { display: grid; gap: 8px; diff --git a/src/lib/components/InfoSection.svelte b/src/lib/components/InfoSection.svelte index d7a2d4c..3b6dd32 100644 --- a/src/lib/components/InfoSection.svelte +++ b/src/lib/components/InfoSection.svelte @@ -238,8 +238,8 @@ text-align: left; } - .faq summary, - .faq details p { + :global(.faq summary), + :global(.faq details p) { text-align: left; } } diff --git a/src/lib/components/InstagramSection.svelte b/src/lib/components/InstagramSection.svelte index f7a459e..c513578 100644 --- a/src/lib/components/InstagramSection.svelte +++ b/src/lib/components/InstagramSection.svelte @@ -21,7 +21,7 @@ </div> <div class="instagram-dog-wrap" aria-hidden="true"> - <enhanced:img src="$lib/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" /> + <img src="/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" /> </div> </div> </aside> diff --git a/src/lib/components/LocationPage.svelte b/src/lib/components/LocationPage.svelte index d14be9d..a8b0bc5 100644 --- a/src/lib/components/LocationPage.svelte +++ b/src/lib/components/LocationPage.svelte @@ -41,7 +41,7 @@ icon: 'fas fa-paw', label: 'Services', value: '3 ways to help', - detail: 'Pack walks, 1:1 walks, and puppy visits' + detail: 'Pack walks, solo walks, and puppy visits' }, { icon: 'fas fa-van-shuttle', @@ -164,7 +164,7 @@ <span class="loc-eyebrow">What we offer</span> <h2>Goodwalk services in {location.suburb}</h2> <p class="loc-section-intro"> - We offer pack walks, 1:1 walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet & Greet so we can understand your dog and recommend the right fit. + We offer pack walks, solo walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet & Greet so we can understand your dog and recommend the right fit. </p> </div> <div class="loc-services-grid"> @@ -205,12 +205,14 @@ <div class="page-inner"> <CtaCard title="Ready to get started in {location.suburb}?" - description="A free Meet & Greet is the first step — no commitment, no pressure. We meet your dog, answer your questions, and see if Goodwalk is the right fit." + description="A free Meet & Greet is the first step. No commitment, no pressure. Email, call, or send an Instagram DM to talk through what your dog needs." ctaHref="/contact-us" ctaLabel="Book a free Meet & Greet" email="info@goodwalk.co.nz" phone="(022) 642 1011" phoneHref="tel:+64226421011" + instagramHref="https://www.instagram.com/goodwalk.nz/" + contactNote="Email, call, or send an Instagram DM. We want to be easy to reach wherever you already are." /> </div> </section> diff --git a/src/lib/components/OnboardingAuth.svelte b/src/lib/components/OnboardingAuth.svelte index 62d87cc..0afffc7 100644 --- a/src/lib/components/OnboardingAuth.svelte +++ b/src/lib/components/OnboardingAuth.svelte @@ -2,10 +2,15 @@ import { createEventDispatcher } from 'svelte'; import Icon from '$lib/components/Icon.svelte'; - export let context: 'onboarding' | 'contract' = 'onboarding'; - $: flowLabel = context === 'contract' ? 'contract' : 'onboarding'; + export let context: 'onboarding' | 'contract' | 'owner' = 'onboarding'; + $: introText = + context === 'contract' + ? "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your contract." + : context === 'owner' + ? "Enter the Goodwalk owner email address. We'll send you a one-time code so you can manage onboarding welcome emails." + : "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your onboarding."; - const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, string>; draft: Record<string, unknown> } }>(); + const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, unknown>; draft: Record<string, unknown> } }>(); const ownerEmail = 'info@goodwalk.co.nz'; const ownerPhone = '(022) 642 1011'; @@ -94,7 +99,7 @@ {#if stage === 'email'} <h2>Sign in to continue</h2> - <p>Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your {flowLabel}.</p> + <p>{introText}</p> <div class="auth-field"> <label for="auth-email">Email address</label> diff --git a/src/lib/components/OnboardingFooter.svelte b/src/lib/components/OnboardingFooter.svelte index 4c07ee7..7b18fbe 100644 --- a/src/lib/components/OnboardingFooter.svelte +++ b/src/lib/components/OnboardingFooter.svelte @@ -108,7 +108,32 @@ @media (max-width: 768px) { .ob-footer-inner { - padding: 0 18px; + height: auto; + padding: 12px 18px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + row-gap: 10px; + } + + .ob-footer-email { + order: 3; + flex: 0 0 100%; + width: 100%; + margin-left: 0; + white-space: normal; + overflow: visible; + text-overflow: clip; + line-height: 1.4; + } + + .ob-footer-back, + .ob-footer-logout { + flex: 0 0 auto; + } + + .ob-footer-logout { + margin-left: auto; } } </style> diff --git a/src/lib/components/OnboardingPage.svelte b/src/lib/components/OnboardingPage.svelte index 2c2a5a3..f546900 100644 --- a/src/lib/components/OnboardingPage.svelte +++ b/src/lib/components/OnboardingPage.svelte @@ -5,59 +5,107 @@ import OnboardingAuth from '$lib/components/OnboardingAuth.svelte'; import OnboardingFooter from '$lib/components/OnboardingFooter.svelte'; import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced'; - import logoMobile from '$lib/images/goodwalk-auckland-dog-walking-logo-mobile.png?enhanced'; import type { Picture } from '@sveltejs/enhanced-img'; const desktop = logoDesktop as Picture; - const mobile = logoMobile as Picture; export let preview = false; - $: contractPageHref = preview ? '/contract?preview=contract' : '/contract'; const ownerEmail = 'info@goodwalk.co.nz'; const ownerPhone = '(022) 642 1011'; - const services = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits', 'Unsure yet']; + const services = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Unsure yet']; const visitStartedStorageKey = 'goodwalk_visit_started_at'; const draftStorageKey = 'goodwalk_onboarding_draft'; + type SubmissionField = { label: string; value: string | string[] }; + type SubmissionSection = { title: string; icon: string; fields: SubmissionField[] }; + type SubmissionSummary = { + submittedAt?: string; + sections?: SubmissionSection[]; + }; + const steps = [ - { icon: 'fas fa-user', title: 'Your details', desc: 'The basics we need to contact you and identify the household.' }, - { icon: 'fas fa-paw', title: 'Your dog', desc: 'Enough background for us to understand the fit and any care notes.' }, - { icon: 'fas fa-shield-alt', title: 'Safety and access', desc: 'Who to call, which clinic to use, and how we access your dog safely.' }, - { icon: 'fas fa-file-signature', title: 'Sign and send', desc: 'Confirm these details are accurate and sign the onboarding declaration.' }, + { icon: 'fas fa-user', title: 'Owner Details', desc: 'Your contact details.' }, + { icon: 'fas fa-paw', title: 'Dog Details', desc: 'Basic information about your dog.' }, + { icon: 'fas fa-heart-pulse', title: 'Health', desc: 'Vet, emergency, health, and medication.' }, + { icon: 'fas fa-bone', title: 'Behaviour', desc: 'Social, recall, and behaviour notes.' }, + { icon: 'fas fa-file-signature', title: 'Sign', desc: 'Final notes and signature.' }, ]; let currentStep = 1; - let fullName = ''; + // Owner + let ownerFirstName = ''; + let ownerLastName = ''; let email = ''; let phone = ''; let address = ''; + + // Dog basics let dogName = ''; + let dogLastName = ''; let dogBreed = ''; - let dogAge = ''; + let dogDateOfBirth = ''; let servicesNeeded: string[] = []; let temperament = ''; - let medicalNotes = ''; let accessInstructions = ''; + + // Vet & emergency let vetName = ''; + let vetAddress = ''; let vetPhone = ''; let emergencyContactName = ''; let emergencyContactPhone = ''; - let councilRegistrationConfirmed = false; - let vaccinationsConfirmed = false; + + // Health & diet + let isVaccinated = ''; // 'yes' | 'no' + let hasFoodAllergies = ''; + let foodAllergiesDetail = ''; + let hasEnvAllergies = ''; + let envAllergiesDetail = ''; + let onSpecialDiet = ''; + let specialDietDetail = ''; + let onMedication = ''; + let medicationDetail = ''; + + // Behaviour & socialisation + let wellSocialised = ''; + let dogsInteractedWeekly = ''; + let visitsBeach = ''; + let visitsDogParks = ''; + let dogParksFrequency = ''; + let biteHistory = ''; + let reactiveToDogs = ''; + let reactiveToAnimals = ''; + let reactiveToChildren = ''; + let reactiveToPeople = ''; + let isDesexed = ''; + let isRegistered = ''; + let leashTrained = ''; + let recallRating = 0; // 0 = not set, 1-5 valid + let ranAwayBefore = ''; + let carBehaviour = ''; + let knownCommands = ''; + let additionalNotes = ''; + let socialMediaAccount = ''; + let howDidYouHear = ''; + + // Declaration let emergencyVetConsent = false; let termsAccepted = false; let signatureDataUrl = ''; + let signedOnDate = ''; // Auto-populated on submit let website = ''; + // Computed legacy field for downstream consumers expecting a single full name. + $: fullName = `${ownerFirstName.trim()} ${ownerLastName.trim()}`.trim(); + let formStartedAt = 0; let visitStartedAt = 0; let pageEnteredAt = 0; let firstInteractionAt = 0; let sendClickedAt = 0; let submitting = false; - let submitted = false; let submitError = ''; let signaturePad: OnboardingSignaturePad; @@ -67,7 +115,8 @@ let isAuthenticated = false; let userEmail = ''; let onboardingCompleted = false; - let contractCompleted = false; + let onboardingSummary: SubmissionSummary | null = null; + let onboardingSubmittedAt = ''; // Idle timeout const IDLE_TIMEOUT_MS = 20 * 60 * 1000; @@ -76,6 +125,29 @@ let idleLastActivity = Date.now(); let idleInterval: ReturnType<typeof setInterval> | null = null; + function yesNoText(value: string | boolean): string { + if (value === true || value === 'yes') return 'Yes'; + if (value === false || value === 'no') return 'No'; + return '—'; + } + + function formatSummaryDate(value: string): string { + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('en-NZ', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + + function fieldValue(value: string): string { + return value.trim() || '—'; + } + function resetIdle() { idleLastActivity = Date.now(); idleWarning = false; @@ -114,6 +186,31 @@ } } + function getDraftSnapshot() { + return { + currentStep, + ownerFirstName, ownerLastName, email, phone, address, + dogName, dogLastName, dogBreed, dogDateOfBirth, + servicesNeeded, temperament, accessInstructions, + vetName, vetAddress, vetPhone, + emergencyContactName, emergencyContactPhone, + isVaccinated, + hasFoodAllergies, foodAllergiesDetail, + hasEnvAllergies, envAllergiesDetail, + onSpecialDiet, specialDietDetail, + onMedication, medicationDetail, + wellSocialised, dogsInteractedWeekly, + visitsBeach, visitsDogParks, dogParksFrequency, + biteHistory, + reactiveToDogs, reactiveToAnimals, reactiveToChildren, reactiveToPeople, + isDesexed, isRegistered, leashTrained, + recallRating, ranAwayBefore, + carBehaviour, knownCommands, additionalNotes, + socialMediaAccount, howDidYouHear, + emergencyVetConsent, termsAccepted, + }; + } + async function saveDraft() { try { const token = window.localStorage.getItem('gw_onboarding_session'); @@ -121,67 +218,107 @@ await fetch('/api/save-draft', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - form: 'onboarding', - data: { - currentStep, - fullName, email, phone, address, - dogName, dogBreed, dogAge, - servicesNeeded, temperament, medicalNotes, accessInstructions, - vetName, vetPhone, emergencyContactName, emergencyContactPhone, - councilRegistrationConfirmed, vaccinationsConfirmed, emergencyVetConsent, termsAccepted, - }, - }), + body: JSON.stringify({ form: 'onboarding', data: getDraftSnapshot() }), }); } catch { /* ignore */ } } + function pickString(draft: Record<string, unknown>, key: string, fallback: string): string { + const value = draft[key]; + return typeof value === 'string' ? value : fallback; + } + + function pickBool(draft: Record<string, unknown>, key: string, fallback: boolean): boolean { + const value = draft[key]; + return typeof value === 'boolean' ? value : fallback; + } + + function normalizeDraftStep(rawStep: unknown): number { + const step = typeof rawStep === 'number' ? rawStep : 1; + + // Migrate older 6-step drafts into the current 5-step flow. + if (step <= 1) return 1; + if (step === 2) return 2; + if (step === 3 || step === 4) return 3; + if (step === 5) return 4; + return 5; + } + function applyDraft(draft: Record<string, unknown>) { - currentStep = (draft.currentStep as number) ?? 1; - fullName = (draft.fullName as string) ?? fullName; - email = (draft.email as string) ?? email; - phone = (draft.phone as string) ?? phone; - address = (draft.address as string) ?? address; - dogName = (draft.dogName as string) ?? dogName; - dogBreed = (draft.dogBreed as string) ?? dogBreed; - dogAge = (draft.dogAge as string) ?? dogAge; - servicesNeeded = (draft.servicesNeeded as string[]) ?? servicesNeeded; - temperament = (draft.temperament as string) ?? temperament; - medicalNotes = (draft.medicalNotes as string) ?? medicalNotes; - accessInstructions = (draft.accessInstructions as string) ?? accessInstructions; - vetName = (draft.vetName as string) ?? vetName; - vetPhone = (draft.vetPhone as string) ?? vetPhone; - emergencyContactName = (draft.emergencyContactName as string) ?? emergencyContactName; - emergencyContactPhone = (draft.emergencyContactPhone as string) ?? emergencyContactPhone; - councilRegistrationConfirmed = (draft.councilRegistrationConfirmed as boolean) ?? councilRegistrationConfirmed; - vaccinationsConfirmed = (draft.vaccinationsConfirmed as boolean) ?? vaccinationsConfirmed; - emergencyVetConsent = (draft.emergencyVetConsent as boolean) ?? emergencyVetConsent; - termsAccepted = (draft.termsAccepted as boolean) ?? termsAccepted; + currentStep = normalizeDraftStep(draft.currentStep); + ownerFirstName = pickString(draft, 'ownerFirstName', ownerFirstName); + ownerLastName = pickString(draft, 'ownerLastName', ownerLastName); + email = pickString(draft, 'email', email); + phone = pickString(draft, 'phone', phone); + address = pickString(draft, 'address', address); + dogName = pickString(draft, 'dogName', dogName); + dogLastName = pickString(draft, 'dogLastName', dogLastName); + dogBreed = pickString(draft, 'dogBreed', dogBreed); + dogDateOfBirth = pickString(draft, 'dogDateOfBirth', dogDateOfBirth); + servicesNeeded = Array.isArray(draft.servicesNeeded) ? (draft.servicesNeeded as string[]) : servicesNeeded; + temperament = pickString(draft, 'temperament', temperament); + accessInstructions = pickString(draft, 'accessInstructions', accessInstructions); + vetName = pickString(draft, 'vetName', vetName); + vetAddress = pickString(draft, 'vetAddress', vetAddress); + vetPhone = pickString(draft, 'vetPhone', vetPhone); + emergencyContactName = pickString(draft, 'emergencyContactName', emergencyContactName); + emergencyContactPhone = pickString(draft, 'emergencyContactPhone', emergencyContactPhone); + isVaccinated = pickString(draft, 'isVaccinated', isVaccinated); + hasFoodAllergies = pickString(draft, 'hasFoodAllergies', hasFoodAllergies); + foodAllergiesDetail = pickString(draft, 'foodAllergiesDetail', foodAllergiesDetail); + hasEnvAllergies = pickString(draft, 'hasEnvAllergies', hasEnvAllergies); + envAllergiesDetail = pickString(draft, 'envAllergiesDetail', envAllergiesDetail); + onSpecialDiet = pickString(draft, 'onSpecialDiet', onSpecialDiet); + specialDietDetail = pickString(draft, 'specialDietDetail', specialDietDetail); + onMedication = pickString(draft, 'onMedication', onMedication); + medicationDetail = pickString(draft, 'medicationDetail', medicationDetail); + wellSocialised = pickString(draft, 'wellSocialised', wellSocialised); + dogsInteractedWeekly = pickString(draft, 'dogsInteractedWeekly', dogsInteractedWeekly); + visitsBeach = pickString(draft, 'visitsBeach', visitsBeach); + visitsDogParks = pickString(draft, 'visitsDogParks', visitsDogParks); + dogParksFrequency = pickString(draft, 'dogParksFrequency', dogParksFrequency); + biteHistory = pickString(draft, 'biteHistory', biteHistory); + reactiveToDogs = pickString(draft, 'reactiveToDogs', reactiveToDogs); + reactiveToAnimals = pickString(draft, 'reactiveToAnimals', reactiveToAnimals); + reactiveToChildren = pickString(draft, 'reactiveToChildren', reactiveToChildren); + reactiveToPeople = pickString(draft, 'reactiveToPeople', reactiveToPeople); + isDesexed = pickString(draft, 'isDesexed', isDesexed); + isRegistered = pickString(draft, 'isRegistered', isRegistered); + leashTrained = pickString(draft, 'leashTrained', leashTrained); + recallRating = typeof draft.recallRating === 'number' ? draft.recallRating : recallRating; + ranAwayBefore = pickString(draft, 'ranAwayBefore', ranAwayBefore); + carBehaviour = pickString(draft, 'carBehaviour', carBehaviour); + knownCommands = pickString(draft, 'knownCommands', knownCommands); + additionalNotes = pickString(draft, 'additionalNotes', additionalNotes); + socialMediaAccount = pickString(draft, 'socialMediaAccount', socialMediaAccount); + howDidYouHear = pickString(draft, 'howDidYouHear', howDidYouHear); + emergencyVetConsent = pickBool(draft, 'emergencyVetConsent', emergencyVetConsent); + termsAccepted = pickBool(draft, 'termsAccepted', termsAccepted); } $: if (typeof window !== 'undefined') { try { - window.localStorage.setItem(draftStorageKey, JSON.stringify({ - currentStep, - fullName, email, phone, address, - dogName, dogBreed, dogAge, - servicesNeeded, temperament, medicalNotes, accessInstructions, - vetName, vetPhone, emergencyContactName, emergencyContactPhone, - councilRegistrationConfirmed, vaccinationsConfirmed, emergencyVetConsent, termsAccepted, - })); + window.localStorage.setItem(draftStorageKey, JSON.stringify(getDraftSnapshot())); } catch { /* storage unavailable */ } } - function applyProfile(serverEmail: string, profile: Record<string, string> = {}) { + function applyProfile(serverEmail: string, profile: Record<string, unknown> = {}) { if (!email) email = serverEmail; - if (!fullName) fullName = profile.fullName ?? ''; - if (!phone) phone = profile.phone ?? ''; - if (!address) address = profile.address ?? ''; - if (!dogName) dogName = profile.dogName ?? ''; - if (!dogBreed) dogBreed = profile.dogBreed ?? ''; - if (!dogAge) dogAge = profile.dogAge ?? ''; + if (!ownerFirstName && typeof profile.fullName === 'string' && profile.fullName) { + const parts = profile.fullName.trim().split(/\s+/); + ownerFirstName = parts[0] ?? ''; + ownerLastName = parts.slice(1).join(' '); + } + if (!phone && typeof profile.phone === 'string') phone = profile.phone; + if (!address && typeof profile.address === 'string') address = profile.address; + if (!dogName && typeof profile.dogName === 'string') dogName = profile.dogName; + if (!dogBreed && typeof profile.dogBreed === 'string') dogBreed = profile.dogBreed; onboardingCompleted = Boolean((profile as Record<string, unknown>).onboardingCompleted); - contractCompleted = Boolean((profile as Record<string, unknown>).contractCompleted); + onboardingSummary = + profile.onboardingSubmission && typeof profile.onboardingSubmission === 'object' + ? (profile.onboardingSubmission as SubmissionSummary) + : null; + onboardingSubmittedAt = typeof profile.onboardingSubmittedAt === 'string' ? profile.onboardingSubmittedAt : ''; } async function checkAuth() { @@ -196,7 +333,7 @@ isAuthenticated = true; userEmail = data.email; applyProfile(data.email, data.profile ?? {}); - if (data.draft?.onboarding) applyDraft(data.draft.onboarding); + if (!Boolean(data.profile?.onboardingCompleted) && data.draft?.onboarding) applyDraft(data.draft.onboarding); } else { window.localStorage.removeItem('gw_onboarding_session'); } @@ -205,11 +342,13 @@ authChecking = false; } - function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, string>; draft?: Record<string, unknown> }>) { + function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, unknown>; draft?: Record<string, unknown> }>) { isAuthenticated = true; userEmail = e.detail.email; applyProfile(e.detail.email, e.detail.profile ?? {}); - if (e.detail.draft?.onboarding) applyDraft(e.detail.draft.onboarding as Record<string, unknown>); + if (!Boolean(e.detail.profile?.onboardingCompleted) && e.detail.draft?.onboarding) { + applyDraft(e.detail.draft.onboarding as Record<string, unknown>); + } startIdleTimer(); const now = Date.now(); formStartedAt = now; @@ -237,28 +376,8 @@ try { const raw = window.localStorage.getItem(draftStorageKey); - if (raw) { - const d = JSON.parse(raw); - currentStep = d.currentStep ?? 1; - fullName = d.fullName ?? ''; - email = d.email ?? ''; - phone = d.phone ?? ''; - address = d.address ?? ''; - dogName = d.dogName ?? ''; - dogBreed = d.dogBreed ?? ''; - dogAge = d.dogAge ?? ''; - servicesNeeded = d.servicesNeeded ?? []; - temperament = d.temperament ?? ''; - medicalNotes = d.medicalNotes ?? ''; - accessInstructions = d.accessInstructions ?? ''; - vetName = d.vetName ?? ''; - vetPhone = d.vetPhone ?? ''; - emergencyContactName = d.emergencyContactName ?? ''; - emergencyContactPhone = d.emergencyContactPhone ?? ''; - councilRegistrationConfirmed = d.councilRegistrationConfirmed ?? false; - vaccinationsConfirmed = d.vaccinationsConfirmed ?? false; - emergencyVetConsent = d.emergencyVetConsent ?? false; - termsAccepted = d.termsAccepted ?? false; + if (raw && !onboardingCompleted) { + applyDraft(JSON.parse(raw) as Record<string, unknown>); } } catch { /* storage unavailable */ } }); @@ -310,7 +429,8 @@ const next: Record<string, string> = {}; if (step === 1) { - if (!fullName.trim()) next.fullName = 'Please enter your full name'; + if (!ownerFirstName.trim()) next.ownerFirstName = 'Please enter your first name'; + if (!ownerLastName.trim()) next.ownerLastName = 'Please enter your last name'; const emailError = validateEmail(email); if (emailError) next.email = emailError; if (!phone.trim()) next.phone = 'Please enter your phone number'; @@ -318,15 +438,35 @@ } else if (step === 2) { if (!dogName.trim()) next.dogName = "Please enter your dog's name"; if (!dogBreed.trim()) next.dogBreed = "Please enter your dog's breed"; + if (!dogDateOfBirth.trim()) next.dogDateOfBirth = "Please enter your dog's date of birth"; if (!servicesNeeded.length) next.servicesNeeded = 'Choose at least one service'; } else if (step === 3) { if (!vetName.trim()) next.vetName = 'Please enter your vet clinic name'; + if (!vetAddress.trim()) next.vetAddress = 'Please enter the vet address'; if (!vetPhone.trim()) next.vetPhone = 'Please enter your vet phone number'; if (!emergencyContactName.trim()) next.emergencyContactName = 'Please add an emergency contact'; if (!emergencyContactPhone.trim()) next.emergencyContactPhone = 'Please add an emergency contact number'; + if (!isVaccinated) next.isVaccinated = 'Please answer the vaccination question'; + if (!hasFoodAllergies) next.hasFoodAllergies = 'Please answer the food allergy question'; + if (hasFoodAllergies === 'yes' && !foodAllergiesDetail.trim()) { + next.foodAllergiesDetail = 'Please describe the food allergy'; + } + if (!hasEnvAllergies) next.hasEnvAllergies = 'Please answer the environmental allergy question'; + if (hasEnvAllergies === 'yes' && !envAllergiesDetail.trim()) { + next.envAllergiesDetail = 'Please describe the environmental allergy'; + } + if (!onSpecialDiet) next.onSpecialDiet = 'Please answer the special diet question'; + if (onSpecialDiet === 'yes' && !specialDietDetail.trim()) { + next.specialDietDetail = 'Please describe the special diet'; + } + if (!onMedication) next.onMedication = 'Please answer the medication question'; + if (onMedication === 'yes' && !medicationDetail.trim()) { + next.medicationDetail = 'Please describe the medication'; + } } else if (step === 4) { - if (!councilRegistrationConfirmed) next.councilRegistrationConfirmed = 'Please confirm council registration'; - if (!vaccinationsConfirmed) next.vaccinationsConfirmed = 'Please confirm vaccinations are current'; + if (!isRegistered) next.isRegistered = 'Please confirm whether your dog is registered with council'; + } else if (step === 5) { + if (!socialMediaAccount.trim()) next.socialMediaAccount = 'Please add your Instagram or social handle'; if (!emergencyVetConsent) next.emergencyVetConsent = 'Please confirm emergency treatment consent'; if (!termsAccepted) next.termsAccepted = 'Please confirm the onboarding declaration'; if (!signatureDataUrl) next.signature = 'Please add your signature before sending'; @@ -339,38 +479,160 @@ function nextStep() { noteInteraction(); if (!validateStep(currentStep)) return; - currentStep += 1; + currentStep = Math.min(currentStep + 1, steps.length); void saveDraft(); window.scrollTo({ top: 0, behavior: 'smooth' }); } function prevStep() { errors = {}; - currentStep -= 1; + currentStep = Math.max(currentStep - 1, 1); window.scrollTo({ top: 0, behavior: 'smooth' }); } + function clearOptionalHealthFields() { + noteInteraction(); + accessInstructions = ''; + hasFoodAllergies = 'no'; + foodAllergiesDetail = ''; + hasEnvAllergies = 'no'; + envAllergiesDetail = ''; + onSpecialDiet = 'no'; + specialDietDetail = ''; + onMedication = 'no'; + medicationDetail = ''; + + errors = { + ...errors, + accessInstructions: '', + hasFoodAllergies: '', + foodAllergiesDetail: '', + hasEnvAllergies: '', + envAllergiesDetail: '', + onSpecialDiet: '', + specialDietDetail: '', + onMedication: '', + medicationDetail: '', + }; + } + + function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary { + return { + submittedAt, + sections: [ + { + title: 'Owner Details', + icon: 'fas fa-user', + fields: [ + { label: 'Owner First Name', value: fieldValue(ownerFirstName) }, + { label: 'Owner Surname', value: fieldValue(ownerLastName) }, + { label: 'Email', value: fieldValue(email) }, + { label: 'Contact Number', value: fieldValue(phone) }, + { label: 'Home Address', value: fieldValue(address) }, + ], + }, + { + title: 'Dog Details', + icon: 'fas fa-paw', + fields: [ + { label: 'Dog Name', value: fieldValue(dogName) }, + { label: 'Dog Surname', value: fieldValue(dogLastName) }, + { label: 'Breed', value: fieldValue(dogBreed) }, + { label: 'Date of Birth', value: fieldValue(dogDateOfBirth) }, + { label: 'Services', value: servicesNeeded.length ? servicesNeeded : ['—'] }, + { label: 'Temperament, Routine, or Handling Notes', value: fieldValue(temperament) }, + ], + }, + { + title: 'Health', + icon: 'fas fa-heart-pulse', + fields: [ + { label: 'Vet Name', value: fieldValue(vetName) }, + { label: 'Vet Contact Number', value: fieldValue(vetPhone) }, + { label: 'Vet Address', value: fieldValue(vetAddress) }, + { label: 'Emergency Contact Name', value: fieldValue(emergencyContactName) }, + { label: 'Emergency Contact Number', value: fieldValue(emergencyContactPhone) }, + { label: 'Home Access Instructions', value: fieldValue(accessInstructions) }, + { label: 'Are Vaccinations Current?', value: yesNoText(isVaccinated) }, + { label: 'Any Food Allergies?', value: hasFoodAllergies === 'yes' ? fieldValue(foodAllergiesDetail) : yesNoText(hasFoodAllergies) }, + { label: 'Any Environmental Allergies?', value: hasEnvAllergies === 'yes' ? fieldValue(envAllergiesDetail) : yesNoText(hasEnvAllergies) }, + { label: 'On a Special Diet?', value: onSpecialDiet === 'yes' ? fieldValue(specialDietDetail) : yesNoText(onSpecialDiet) }, + { label: 'Any Medication That Could Affect Walks?', value: onMedication === 'yes' ? fieldValue(medicationDetail) : yesNoText(onMedication) }, + ], + }, + { + title: 'Behaviour', + icon: 'fas fa-bone', + fields: [ + { label: 'Well Socialised?', value: yesNoText(wellSocialised) }, + { label: 'Dogs Interacted With Weekly', value: fieldValue(dogsInteractedWeekly) }, + { label: 'Visits the Beach?', value: yesNoText(visitsBeach) }, + { label: 'Visits Dog Parks Frequently?', value: visitsDogParks === 'yes' ? fieldValue(dogParksFrequency ? `${dogParksFrequency} per week` : 'Yes') : yesNoText(visitsDogParks) }, + { label: 'Any Bite History?', value: yesNoText(biteHistory) }, + { label: 'Reactive to Other Dogs?', value: yesNoText(reactiveToDogs) }, + { label: 'Reactive to Other Animals?', value: yesNoText(reactiveToAnimals) }, + { label: 'Reactive to Children?', value: yesNoText(reactiveToChildren) }, + { label: 'Reactive to Other People?', value: yesNoText(reactiveToPeople) }, + { label: 'Desexed?', value: yesNoText(isDesexed) }, + { label: 'Registered With Council?', value: yesNoText(isRegistered) }, + { label: 'Leash Trained?', value: yesNoText(leashTrained) }, + { label: 'Recall Rating', value: recallRating ? String(recallRating) : '—' }, + { label: 'Run Away Before?', value: yesNoText(ranAwayBefore) }, + { label: 'Behaviour in the Car', value: fieldValue(carBehaviour) }, + { label: 'Commands Your Dog Knows', value: fieldValue(knownCommands) }, + ], + }, + { + title: 'Sign', + icon: 'fas fa-file-signature', + fields: [ + { label: 'Anything Else We Should Know?', value: fieldValue(additionalNotes) }, + { label: 'Instagram or Social Handle', value: fieldValue(socialMediaAccount) }, + { label: 'How Did You Hear About Goodwalk?', value: fieldValue(howDidYouHear) }, + { label: 'Emergency Vet Consent', value: yesNoText(emergencyVetConsent) }, + { label: 'Accuracy Declaration', value: yesNoText(termsAccepted) }, + ], + }, + ], + }; + } + async function handleSubmit(event: SubmitEvent) { event.preventDefault(); noteInteraction(); sendClickedAt = Date.now(); submitError = ''; - if (!validateStep(4)) return; + if (!validateStep(5)) return; submitting = true; + signedOnDate = new Date().toISOString(); + const submissionSnapshot = buildSubmissionSnapshot(signedOnDate); + + const medicalNotes = [ + temperament.trim(), + hasFoodAllergies === 'yes' ? `Food allergies: ${foodAllergiesDetail.trim()}` : '', + hasEnvAllergies === 'yes' ? `Environmental allergies: ${envAllergiesDetail.trim()}` : '', + onSpecialDiet === 'yes' ? `Special diet: ${specialDietDetail.trim()}` : '', + onMedication === 'yes' ? `Medication: ${medicationDetail.trim()}` : '', + additionalNotes.trim(), + ].filter(Boolean).join('\n'); try { const response = await fetch('/api/onboarding-submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - fullName, email, phone, address, - dogName, dogBreed, dogAge, - servicesNeeded, temperament, medicalNotes, accessInstructions, - vetName, vetPhone, emergencyContactName, emergencyContactPhone, - councilRegistrationConfirmed, vaccinationsConfirmed, emergencyVetConsent, termsAccepted, - signatureDataUrl, website, + ...getDraftSnapshot(), + fullName, + dogAge: dogDateOfBirth, + medicalNotes, + councilRegistrationConfirmed: isRegistered === 'yes', + vaccinationsConfirmed: isVaccinated === 'yes', + signatureDataUrl, + signedOnDate, + submissionSnapshot, + website, formStartedAt, visitStartedAt, pageEnteredAt, firstInteractionAt, sendClickedAt, referrer: document.referrer, page: window.location.href, @@ -382,7 +644,9 @@ throw new Error(payload?.detail?.message ?? payload?.detail ?? 'Submission failed'); } - submitted = true; + onboardingCompleted = true; + onboardingSummary = submissionSnapshot; + onboardingSubmittedAt = signedOnDate; try { window.localStorage.removeItem(draftStorageKey); } catch { /* ignore */ } window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (error) { @@ -407,7 +671,6 @@ <div class="onboarding-topbar"> <div class="onboarding-shell onboarding-topbar-inner"> <div class="onboarding-brand"> - <span class="onboarding-pill">Onboarding</span> <a href="https://goodwalk.co.nz" class="onboarding-logo" aria-label="Goodwalk home"> <picture> {#if desktop.sources?.webp} @@ -423,6 +686,7 @@ </picture> </a> </div> + <span class="onboarding-pill">Onboarding</span> </div> </div> @@ -434,44 +698,6 @@ <OnboardingAuth on:authenticated={handleAuthenticated} /> {:else} - <div class="journey-bar"> - <div class="onboarding-shell journey-bar-inner"> - <div class="journey-stage journey-stage-active"> - <span class="journey-stage-icon"> - {#if onboardingCompleted} - <Icon name="fas fa-check" /> - {:else} - <Icon name="fas fa-clipboard-list" /> - {/if} - </span> - <span class="journey-stage-label">Onboarding form</span> - {#if onboardingCompleted} - <span class="journey-badge journey-badge-done">Done</span> - {:else} - <span class="journey-badge journey-badge-current">In progress</span> - {/if} - </div> - - <div class="journey-connector" class:journey-connector-done={onboardingCompleted}></div> - - <a href={contractPageHref} class="journey-stage" class:journey-done={contractCompleted}> - <span class="journey-stage-icon"> - {#if contractCompleted} - <Icon name="fas fa-check" /> - {:else} - <Icon name="fas fa-file-signature" /> - {/if} - </span> - <span class="journey-stage-label">Service contract</span> - {#if contractCompleted} - <span class="journey-badge journey-badge-done">Signed</span> - {:else} - <span class="journey-badge journey-badge-todo">To do</span> - {/if} - </a> - </div> - </div> - {#if idleWarning} <div class="idle-warning" role="alert"> <div class="onboarding-shell idle-warning-inner"> @@ -485,21 +711,15 @@ <section class="onboarding-hero"> <div class="onboarding-shell onboarding-hero-inner"> <div class="onboarding-hero-copy"> - <span class="onboarding-eyebrow">Client onboarding</span> - <h1>Tell us about your dog, sign the form, and we’ll take it from there.</h1> - <p> - This gives us the details we need before your dog starts with Goodwalk. Once it is - submitted, Aless receives it directly for review. - </p> + <h1>{onboardingCompleted ? 'Your submitted onboarding details.' : 'A few details so we can care for your dog properly.'}</h1> </div> - <div class="onboarding-contact-card"> - <div class="onboarding-contact-title">Questions before you send it?</div> - <a href={`mailto:${ownerEmail}`} class="onboarding-contact-link"> + <div class="onboarding-hero-meta" aria-label="Onboarding guidance"> + <a href={`mailto:${ownerEmail}`} class="onboarding-meta-link"> <Icon name="fas fa-envelope" /> {ownerEmail} </a> - <a href={`tel:${ownerPhone.replace(/[^0-9+]/g, '')}`} class="onboarding-contact-link"> + <a href={`tel:${ownerPhone.replace(/[^0-9+]/g, '')}`} class="onboarding-meta-link"> <Icon name="fas fa-phone" /> {ownerPhone} </a> @@ -507,20 +727,9 @@ </div> </section> - <section class="onboarding-form-section"> - <div class="onboarding-shell"> - {#if submitted} - <div class="onboarding-success-card"> - <span class="onboarding-success-badge">Submitted</span> - <h2>Thanks. Your onboarding form is with us.</h2> - <p> - Aless has received your signed onboarding form and will review it directly. If - anything else is needed, we’ll come back to you using the contact details you - provided. - </p> - </div> - {:else} - <!-- Step progress indicator --> + {#if !onboardingCompleted} + <section class="onboarding-steps-section" aria-label="Onboarding progress"> + <div class="onboarding-shell"> <div class="step-progress"> {#each steps as step, i} <button @@ -532,58 +741,166 @@ disabled={currentStep <= i + 1} aria-label="Go to step {i + 1}: {step.title}" > - <div class="step-indicator-circle"> + <span class="step-indicator-circle"> {#if currentStep > i + 1} <Icon name="fas fa-check" /> {:else} <Icon name={step.icon} /> {/if} - </div> + </span> <span class="step-indicator-label">{step.title}</span> </button> - {#if i < steps.length - 1} - <div class="step-connector" class:filled={currentStep > i + 1}></div> - {/if} {/each} </div> + </div> + </section> + {/if} - <form class="onboarding-form" novalidate on:submit={handleSubmit}> + <section class="onboarding-form-section"> + <div class="onboarding-shell"> + <div class="onboarding-form-shell"> + {#if onboardingCompleted} + <div class="onboarding-submission-view"> + <div class="onboarding-success-card onboarding-success-card-left"> + <span class="onboarding-success-badge">Completed</span> + <h2>Your onboarding has been submitted.</h2> + <p> + You can sign back in any time with a one-time code to review the details you sent through. + </p> + {#if onboardingSubmittedAt || onboardingSummary?.submittedAt} + <div class="onboarding-submission-meta"> + Submitted {formatSummaryDate(onboardingSubmittedAt || onboardingSummary?.submittedAt || '')} + </div> + {/if} + </div> - <!-- Step 1: Your details --> + {#if onboardingSummary?.sections?.length} + <div class="submission-section-list"> + {#each onboardingSummary.sections as section} + <section class="submission-section-card"> + <div class="submission-section-head"> + <div class="submission-section-icon"><Icon name={section.icon} /></div> + <div> + <h3>{section.title}</h3> + </div> + </div> + + <div class="submission-field-list"> + {#each section.fields as field} + <div class="submission-field-row"> + <div class="submission-field-label">{field.label}</div> + <div class="submission-field-value"> + {#if Array.isArray(field.value)} + <div class="submission-tag-list"> + {#each field.value as item} + <span class="submission-tag">{item}</span> + {/each} + </div> + {:else} + {field.value} + {/if} + </div> + </div> + {/each} + </div> + </section> + {/each} + </div> + {:else} + <div class="onboarding-success-card onboarding-success-card-left"> + <h2>Submission details</h2> + <p>Your onboarding is complete. We do not have the full summary available for this older submission.</p> + </div> + {/if} + </div> + {:else} + <form class="onboarding-form" novalidate on:submit={handleSubmit}> + + {#snippet yesNo(label: string, key: string, value: string, setter: (v: string) => void)} + <div class="field yn-field"> + <span class="yn-label">{label}</span> + <div class="yn-buttons" role="radiogroup" aria-label={label}> + <button + type="button" + class="yn-btn" + class:active={value === 'yes'} + role="radio" + aria-checked={value === 'yes'} + on:click={() => { setter('yes'); noteInteraction(); if (errors[key]) errors = { ...errors, [key]: '' }; }} + > + Yes + </button> + <button + type="button" + class="yn-btn" + class:active={value === 'no'} + role="radio" + aria-checked={value === 'no'} + on:click={() => { setter('no'); noteInteraction(); if (errors[key]) errors = { ...errors, [key]: '' }; }} + > + No + </button> + </div> + {#if errors[key]}<small>{errors[key]}</small>{/if} + </div> + {/snippet} + + {#snippet specifyField(label: string, key: string, value: string, setter: (v: string) => void)} + <label class="field field-detail"> + <span>{label}</span> + <textarea + value={value} + on:input={(event) => { + setter((event.currentTarget as HTMLTextAreaElement).value); + noteInteraction(); + if (errors[key]) errors = { ...errors, [key]: '' }; + }} + rows="2" + placeholder="Please describe." + ></textarea> + {#if errors[key]}<small>{errors[key]}</small>{/if} + </label> + {/snippet} + + <!-- Step 1: Owner details --> {#if currentStep === 1} <section class="onboarding-panel"> <div class="onboarding-panel-head"> - <div class="onboarding-step-icon"> - <Icon name="fas fa-user" /> - </div> + <div class="onboarding-step-icon"><Icon name="fas fa-user" /></div> <div> - <h2>{steps[0].title}</h2> + <h2>Owner Details</h2> <p>{steps[0].desc}</p> </div> </div> <div class="onboarding-fields two-up"> <label class="field"> - <span>Full name</span> - <input bind:value={fullName} on:input={noteInteraction} /> - {#if errors.fullName}<small>{errors.fullName}</small>{/if} + <span>Owner First Name</span> + <input bind:value={ownerFirstName} on:input={noteInteraction} autocomplete="given-name" placeholder="First name" /> + {#if errors.ownerFirstName}<small>{errors.ownerFirstName}</small>{/if} + </label> + + <label class="field"> + <span>Owner Surname</span> + <input bind:value={ownerLastName} on:input={noteInteraction} autocomplete="family-name" placeholder="Surname" /> + {#if errors.ownerLastName}<small>{errors.ownerLastName}</small>{/if} </label> <label class="field"> <span>Email</span> - <input bind:value={email} type="email" on:input={noteInteraction} /> + <input bind:value={email} type="email" on:input={noteInteraction} autocomplete="email" placeholder="you@example.com" /> {#if errors.email}<small>{errors.email}</small>{/if} </label> <label class="field"> - <span>Phone</span> - <input bind:value={phone} type="tel" on:input={noteInteraction} /> + <span>Contact Number</span> + <input bind:value={phone} type="tel" on:input={noteInteraction} autocomplete="tel" placeholder="021 234 5678" /> {#if errors.phone}<small>{errors.phone}</small>{/if} </label> <label class="field field-full"> - <span>Home address</span> - <input bind:value={address} on:input={noteInteraction} /> + <span>Home Address</span> + <input bind:value={address} on:input={noteInteraction} autocomplete="street-address" placeholder="Street address" /> {#if errors.address}<small>{errors.address}</small>{/if} </label> </div> @@ -596,40 +913,44 @@ </section> {/if} - <!-- Step 2: Your dog --> + <!-- Step 2: Dog details --> {#if currentStep === 2} <section class="onboarding-panel"> <div class="onboarding-panel-head"> - <div class="onboarding-step-icon"> - <Icon name="fas fa-paw" /> - </div> + <div class="onboarding-step-icon"><Icon name="fas fa-paw" /></div> <div> - <h2>{steps[1].title}</h2> + <h2>Dog Details</h2> <p>{steps[1].desc}</p> </div> </div> <div class="onboarding-fields two-up"> <label class="field"> - <span>Dog name</span> - <input bind:value={dogName} on:input={noteInteraction} /> + <span>Dog Name</span> + <input bind:value={dogName} on:input={noteInteraction} placeholder="Dog name" /> {#if errors.dogName}<small>{errors.dogName}</small>{/if} </label> + <label class="field"> + <span>Dog Surname</span> + <input bind:value={dogLastName} on:input={noteInteraction} placeholder="If they have one" /> + </label> + <label class="field"> <span>Breed</span> - <input bind:value={dogBreed} on:input={noteInteraction} /> + <input bind:value={dogBreed} on:input={noteInteraction} placeholder="Breed" /> {#if errors.dogBreed}<small>{errors.dogBreed}</small>{/if} </label> <label class="field"> - <span>Age</span> - <input bind:value={dogAge} placeholder="Optional" on:input={noteInteraction} /> + <span>Date of Birth</span> + <input bind:value={dogDateOfBirth} type="date" on:input={noteInteraction} /> + {#if errors.dogDateOfBirth}<small>{errors.dogDateOfBirth}</small>{/if} </label> </div> <div class="field"> - <span>Which service are you onboarding for?</span> + <span>Which Service Are You Onboarding For?</span> <div class="chip-grid"> {#each services as service} <label class:chip-selected={servicesNeeded.includes(service)} class="chip-option"> @@ -647,21 +968,11 @@ </div> <label class="field"> - <span>Temperament, routine, or handling notes</span> + <span>Temperament, Routine, or Handling Notes</span> <textarea bind:value={temperament} - rows="4" - placeholder="Confidence around other dogs, recall, triggers, pace, preferred handling, daily routine." - on:input={noteInteraction} - ></textarea> - </label> - - <label class="field"> - <span>Medical notes or medications</span> - <textarea - bind:value={medicalNotes} - rows="4" - placeholder="Anything we should know about injuries, medications, allergies, or limitations." + rows="3" + placeholder="Confidence, triggers, pace, preferred handling, daily routine." on:input={noteInteraction} ></textarea> </label> @@ -677,51 +988,232 @@ </section> {/if} - <!-- Step 3: Safety and access --> + <!-- Step 3: Health --> {#if currentStep === 3} <section class="onboarding-panel"> <div class="onboarding-panel-head"> - <div class="onboarding-step-icon"> - <Icon name="fas fa-shield-alt" /> - </div> + <div class="onboarding-step-icon"><Icon name="fas fa-heart-pulse" /></div> <div> <h2>{steps[2].title}</h2> <p>{steps[2].desc}</p> </div> </div> - <div class="onboarding-fields two-up"> - <label class="field"> - <span>Vet clinic</span> - <input bind:value={vetName} on:input={noteInteraction} /> - {#if errors.vetName}<small>{errors.vetName}</small>{/if} - </label> + <div class="panel-subsection"> + <div class="panel-subsection-head"> + <div class="panel-subsection-icon"><Icon name="fas fa-stethoscope" /></div> + <div> + <h3>Vet</h3> + <p>Your regular clinic details.</p> + </div> + </div> - <label class="field"> - <span>Vet phone</span> - <input bind:value={vetPhone} type="tel" on:input={noteInteraction} /> - {#if errors.vetPhone}<small>{errors.vetPhone}</small>{/if} - </label> + <div class="onboarding-fields two-up"> + <label class="field"> + <span>Vet Name</span> + <input bind:value={vetName} on:input={noteInteraction} placeholder="Vet clinic or vet name" /> + {#if errors.vetName}<small>{errors.vetName}</small>{/if} + </label> - <label class="field"> - <span>Emergency contact</span> - <input bind:value={emergencyContactName} on:input={noteInteraction} /> - {#if errors.emergencyContactName}<small>{errors.emergencyContactName}</small>{/if} - </label> + <label class="field"> + <span>Vet Contact Number</span> + <input bind:value={vetPhone} type="tel" on:input={noteInteraction} placeholder="Vet phone number" /> + {#if errors.vetPhone}<small>{errors.vetPhone}</small>{/if} + </label> - <label class="field"> - <span>Emergency contact phone</span> - <input bind:value={emergencyContactPhone} type="tel" on:input={noteInteraction} /> - {#if errors.emergencyContactPhone}<small>{errors.emergencyContactPhone}</small>{/if} - </label> + <label class="field field-full"> + <span>Vet Address</span> + <input bind:value={vetAddress} on:input={noteInteraction} placeholder="Vet address" /> + {#if errors.vetAddress}<small>{errors.vetAddress}</small>{/if} + </label> + </div> </div> + <div class="panel-subsection"> + <div class="panel-subsection-head"> + <div class="panel-subsection-icon"><Icon name="fas fa-phone-volume" /></div> + <div> + <h3>Emergency Contact</h3> + <p>Who we should call first if we cannot reach you.</p> + </div> + </div> + + <div class="onboarding-fields two-up"> + <label class="field"> + <span>Emergency Contact Name</span> + <input bind:value={emergencyContactName} on:input={noteInteraction} placeholder="Emergency contact name" /> + {#if errors.emergencyContactName}<small>{errors.emergencyContactName}</small>{/if} + </label> + + <label class="field"> + <span>Emergency Contact Number</span> + <input bind:value={emergencyContactPhone} type="tel" on:input={noteInteraction} placeholder="Emergency contact number" /> + {#if errors.emergencyContactPhone}<small>{errors.emergencyContactPhone}</small>{/if} + </label> + </div> + </div> + + <div class="panel-subsection panel-subsection-soft"> + <div class="panel-subsection-head"> + <div class="panel-subsection-icon"><Icon name="fas fa-notes-medical" /></div> + <div> + <h3>Health Notes</h3> + <p>Day-to-day care, access, and anything we should be aware of.</p> + </div> + </div> + + <div class="panel-subsection-actions"> + <button class="subsection-quick-action" type="button" on:click={clearOptionalHealthFields}> + <Icon name="fas fa-check" /> + No Extra Health Notes + </button> + </div> + + <label class="field"> + <span>Home Access Instructions</span> + <textarea + bind:value={accessInstructions} + rows="3" + placeholder="Keys, gate code, where to collect your dog, alarm notes." + on:input={noteInteraction} + ></textarea> + </label> + + {@render yesNo('Are Vaccinations Current?', 'isVaccinated', isVaccinated, (v) => (isVaccinated = v))} + + {@render yesNo('Any Food Allergies?', 'hasFoodAllergies', hasFoodAllergies, (v) => (hasFoodAllergies = v))} + {#if hasFoodAllergies === 'yes'} + {@render specifyField('Specify Food Allergies', 'foodAllergiesDetail', foodAllergiesDetail, (v) => (foodAllergiesDetail = v))} + {/if} + + {@render yesNo('Any Environmental Allergies?', 'hasEnvAllergies', hasEnvAllergies, (v) => (hasEnvAllergies = v))} + {#if hasEnvAllergies === 'yes'} + {@render specifyField('Specify Environmental Allergies', 'envAllergiesDetail', envAllergiesDetail, (v) => (envAllergiesDetail = v))} + {/if} + + {@render yesNo('On a Special Diet?', 'onSpecialDiet', onSpecialDiet, (v) => (onSpecialDiet = v))} + {#if onSpecialDiet === 'yes'} + {@render specifyField('Specify the Diet', 'specialDietDetail', specialDietDetail, (v) => (specialDietDetail = v))} + {/if} + + {@render yesNo('Any Medication That Could Affect Walks?', 'onMedication', onMedication, (v) => (onMedication = v))} + {#if onMedication === 'yes'} + {@render specifyField('Specify the Medication', 'medicationDetail', medicationDetail, (v) => (medicationDetail = v))} + {/if} + </div> + + <div class="panel-nav"> + <button class="panel-nav-back" type="button" on:click={prevStep}> + <Icon name="fas fa-arrow-left" /> Back + </button> + <button class="btn btn-yellow panel-nav-next" type="button" on:click={nextStep}> + Save and continue <Icon name="fas fa-arrow-right" /> + </button> + </div> + </section> + {/if} + + <!-- Step 4: Behaviour & socialisation --> + {#if currentStep === 4} + <section class="onboarding-panel"> + <div class="onboarding-panel-head"> + <div class="onboarding-step-icon"><Icon name="fas fa-bone" /></div> + <div> + <h2>{steps[3].title}</h2> + <p>{steps[3].desc}</p> + </div> + </div> + + {@render yesNo('Well Socialised?', 'wellSocialised', wellSocialised, (v) => (wellSocialised = v))} + <label class="field"> - <span>Home access instructions</span> + <span>Dogs Interacted With Weekly <span class="field-hint">Excluding your family dogs</span></span> + <input + bind:value={dogsInteractedWeekly} + type="number" + min="0" + inputmode="numeric" + placeholder="0" + on:input={() => { noteInteraction(); if (errors.dogsInteractedWeekly) errors = { ...errors, dogsInteractedWeekly: '' }; }} + /> + {#if errors.dogsInteractedWeekly}<small>{errors.dogsInteractedWeekly}</small>{/if} + </label> + + {@render yesNo('Visits the Beach?', 'visitsBeach', visitsBeach, (v) => (visitsBeach = v))} + + {@render yesNo('Visits Dog Parks Frequently?', 'visitsDogParks', visitsDogParks, (v) => (visitsDogParks = v))} + {#if visitsDogParks === 'yes'} + <label class="field field-detail"> + <span>Times Per Week</span> + <input + bind:value={dogParksFrequency} + type="number" + min="0" + inputmode="numeric" + placeholder="0" + on:input={() => { noteInteraction(); if (errors.dogParksFrequency) errors = { ...errors, dogParksFrequency: '' }; }} + /> + {#if errors.dogParksFrequency}<small>{errors.dogParksFrequency}</small>{/if} + </label> + {/if} + + {@render yesNo('Any Bite History?', 'biteHistory', biteHistory, (v) => (biteHistory = v))} + {@render yesNo('Reactive to Other Dogs?', 'reactiveToDogs', reactiveToDogs, (v) => (reactiveToDogs = v))} + {@render yesNo('Reactive to Other Animals?', 'reactiveToAnimals', reactiveToAnimals, (v) => (reactiveToAnimals = v))} + {@render yesNo('Reactive to Children?', 'reactiveToChildren', reactiveToChildren, (v) => (reactiveToChildren = v))} + {@render yesNo('Reactive to Other People?', 'reactiveToPeople', reactiveToPeople, (v) => (reactiveToPeople = v))} + {@render yesNo('Desexed?', 'isDesexed', isDesexed, (v) => (isDesexed = v))} + {@render yesNo('Registered With Council?', 'isRegistered', isRegistered, (v) => (isRegistered = v))} + {@render yesNo('Leash Trained?', 'leashTrained', leashTrained, (v) => (leashTrained = v))} + + <div class="field"> + <span class="yn-label">Recall Rating <span class="field-hint">1 lowest, 5 highest</span></span> + <div class="recall-scale-wrap"> + <div class="recall-scale" role="radiogroup" aria-label="Recall rating"> + {#each [1, 2, 3, 4, 5] as rating} + <button + type="button" + class="recall-btn" + class:active={recallRating === rating} + role="radio" + aria-checked={recallRating === rating} + aria-label={`${rating} out of 5`} + on:click={() => { + recallRating = rating; + noteInteraction(); + if (errors.recallRating) errors = { ...errors, recallRating: '' }; + }} + > + <span class="recall-btn-number">{rating}</span> + </button> + {/each} + </div> + <div class="recall-scale-labels" aria-hidden="true"> + <span>Lowest</span> + <span>Highest</span> + </div> + </div> + {#if errors.recallRating}<small>{errors.recallRating}</small>{/if} + </div> + + {@render yesNo('Run Away Before?', 'ranAwayBefore', ranAwayBefore, (v) => (ranAwayBefore = v))} + + <label class="field"> + <span>Behaviour in the Car</span> <textarea - bind:value={accessInstructions} - rows="4" - placeholder="Keys, gate code, where to collect your dog, alarm notes, or anything else we should know." + bind:value={carBehaviour} + rows="2" + placeholder="Calm, anxious, vocal, prefers a harness, motion sick, etc." + on:input={noteInteraction} + ></textarea> + </label> + + <label class="field"> + <span>Commands Your Dog Knows</span> + <textarea + bind:value={knownCommands} + rows="2" + placeholder="Sit, stay, come, leave it, drop it, heel." on:input={noteInteraction} ></textarea> </label> @@ -737,32 +1229,48 @@ </section> {/if} - <!-- Step 4: Declaration and signature --> - {#if currentStep === 4} + <!-- Step 5: Sign and send --> + {#if currentStep === 5} <section class="onboarding-panel onboarding-panel-signoff"> <div class="onboarding-panel-head"> - <div class="onboarding-step-icon"> - <Icon name="fas fa-file-signature" /> - </div> + <div class="onboarding-step-icon"><Icon name="fas fa-file-signature" /></div> <div> - <h2>{steps[3].title}</h2> - <p>{steps[3].desc}</p> + <h2>{steps[4].title}</h2> + <p>{steps[4].desc}</p> </div> </div> + <label class="field"> + <span>Anything Else We Should Know?</span> + <textarea + bind:value={additionalNotes} + rows="3" + placeholder="Optional. Anything else that helps us prepare." + on:input={noteInteraction} + ></textarea> + </label> + + <div class="onboarding-fields two-up"> + <label class="field"> + <span>Instagram or Social Handle</span> + <input + bind:value={socialMediaAccount} + on:input={() => { + noteInteraction(); + if (errors.socialMediaAccount) errors = { ...errors, socialMediaAccount: '' }; + }} + placeholder="@yourhandle" + /> + {#if errors.socialMediaAccount}<small>{errors.socialMediaAccount}</small>{/if} + </label> + + <label class="field"> + <span>How Did You Hear About Goodwalk?</span> + <input bind:value={howDidYouHear} on:input={noteInteraction} placeholder="Friend, Instagram, Google" /> + </label> + </div> + <div class="onboarding-confirm-list"> - <label class="confirm-row"> - <input bind:checked={councilRegistrationConfirmed} type="checkbox" on:change={noteInteraction} /> - <span>My dog has a current Auckland Council registration, where required.</span> - </label> - {#if errors.councilRegistrationConfirmed}<small>{errors.councilRegistrationConfirmed}</small>{/if} - - <label class="confirm-row"> - <input bind:checked={vaccinationsConfirmed} type="checkbox" on:change={noteInteraction} /> - <span>My dog is up to date with vaccinations.</span> - </label> - {#if errors.vaccinationsConfirmed}<small>{errors.vaccinationsConfirmed}</small>{/if} - <label class="confirm-row"> <input bind:checked={emergencyVetConsent} type="checkbox" on:change={noteInteraction} /> <span>I authorise Goodwalk to seek urgent veterinary treatment if I cannot be reached in an emergency.</span> @@ -780,7 +1288,7 @@ <div class="signature-header"> <div> <span>Signature</span> - <p>Draw your signature below.</p> + <p>Sign below. Today's date is added automatically.</p> </div> <button class="signature-clear" @@ -823,6 +1331,7 @@ </form> {/if} + </div> </div> </section> @@ -835,8 +1344,8 @@ .onboarding-page { min-height: 100vh; background: - radial-gradient(circle at top left, rgba(255, 209, 0, 0.14), transparent 28%), - linear-gradient(180deg, #f6f5ef 0%, #fbfbf9 42%, #f3f1e8 100%); + radial-gradient(circle at top left, rgba(255, 209, 0, 0.11), transparent 26%), + linear-gradient(180deg, #f5f3eb 0%, #fbfaf7 34%, #f3f0e5 100%); color: var(--gw-green); } @@ -846,107 +1355,6 @@ padding: 0 28px; } - /* ── Journey bar ── */ - .journey-bar { - background: #1a2a1a; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - } - - .journey-bar-inner { - display: flex; - align-items: center; - height: 48px; - } - - .journey-stage { - display: flex; - align-items: center; - gap: 8px; - padding: 0 4px; - text-decoration: none; - color: rgba(255, 255, 255, 0.45); - font-family: var(--font-head); - font-size: 13px; - font-weight: 700; - white-space: nowrap; - transition: color 0.15s; - } - - .journey-stage:hover { - color: rgba(255, 255, 255, 0.7); - } - - .journey-stage-active { - color: rgba(255, 255, 255, 0.9); - cursor: default; - } - - .journey-stage-active:hover { - color: rgba(255, 255, 255, 0.9); - } - - .journey-done { - color: rgba(255, 255, 255, 0.65); - } - - .journey-stage-icon { - width: 22px; - height: 22px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 10px; - background: rgba(255, 255, 255, 0.1); - flex-shrink: 0; - } - - .journey-done .journey-stage-icon { - background: #213021; - color: #7aaa7a; - } - - .journey-stage-active .journey-stage-icon { - background: linear-gradient(180deg, #ffe36b 0%, #ffd100 100%); - color: #213021; - } - - .journey-badge { - padding: 2px 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - } - - .journey-badge-done { - background: rgba(122, 170, 122, 0.18); - color: #7aaa7a; - } - - .journey-badge-todo { - background: rgba(255, 100, 60, 0.15); - color: #ff8060; - } - - .journey-badge-current { - background: rgba(255, 209, 0, 0.18); - color: #ffd100; - } - - .journey-connector { - flex: 1; - height: 1px; - background: rgba(255, 255, 255, 0.1); - margin: 0 12px; - max-width: 60px; - } - - .journey-connector-done { - background: rgba(122, 170, 122, 0.4); - } - /* ── Top nav bar ── */ .onboarding-auth-checking { display: flex; @@ -974,13 +1382,14 @@ align-items: center; justify-content: space-between; gap: 16px; - height: 56px; + min-height: 72px; + padding-top: 14px; + padding-bottom: 14px; } .onboarding-brand { display: flex; align-items: center; - gap: 10px; flex-shrink: 0; } @@ -996,15 +1405,15 @@ } .onboarding-pill { - padding: 4px 10px; + padding: 6px 12px; border-radius: 999px; background: var(--yellow); - font-family: var(--font-head); - font-size: 11px; + font-family: var(--font-body); + font-size: 12px; font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + letter-spacing: 0.04em; color: var(--gw-green); + white-space: nowrap; } /* ── Idle warning ── */ @@ -1044,81 +1453,55 @@ /* ── Hero ── */ .onboarding-hero { - padding: 28px 0 22px; + padding: 18px 0 8px; } .onboarding-hero-inner { display: grid; - gap: 20px; + gap: 8px; } .onboarding-hero-copy { - max-width: 760px; - } - - .onboarding-eyebrow { - display: inline-block; - margin-bottom: 10px; - font-family: var(--font-head); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: #688568; + max-width: 520px; } .onboarding-hero-copy h1 { - margin: 0 0 12px; - font-family: var(--font-head); - font-size: clamp(22px, 2.6vw, 32px); - font-weight: 800; - line-height: 1.1; - letter-spacing: -0.03em; - max-width: 32ch; - } - - .onboarding-hero-copy p { margin: 0; - max-width: 620px; - font-size: 15px; - line-height: 1.65; - color: rgba(33, 48, 33, 0.78); + font-family: var(--font-head); + font-size: clamp(22px, 4.4vw, 30px); + font-weight: 700; + line-height: 1.12; + letter-spacing: -0.03em; + max-width: 18ch; + color: rgba(17, 23, 27, 0.84); } - .onboarding-contact-card { + .onboarding-hero-meta { display: flex; align-items: center; - gap: 14px; + gap: 8px; flex-wrap: wrap; - padding: 22px 24px; - border-radius: 24px; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(33, 48, 33, 0.08); - box-shadow: 0 18px 40px rgba(33, 48, 33, 0.06); + max-width: 100%; } - .onboarding-contact-title { - margin-right: auto; - font-family: var(--font-head); - font-size: 18px; - font-weight: 700; - letter-spacing: -0.02em; - } - - .onboarding-contact-link { + .onboarding-meta-link { display: inline-flex; align-items: center; gap: 8px; - padding: 11px 18px; + min-height: 40px; + padding: 9px 14px; border-radius: 999px; - background: #fff; - border: 1px solid rgba(33, 48, 33, 0.1); - font-family: var(--font-head); - font-size: 14px; - font-weight: 700; - line-height: 1.2; - letter-spacing: 0.01em; - color: var(--gw-green); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 248, 0.98)); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.05), + 0 8px 18px rgba(17, 20, 24, 0.04); + font-size: 13px; + font-weight: 600; + color: rgba(33, 48, 33, 0.78); + } + + .onboarding-steps-section { + padding: 6px 0 12px; } /* ── Form section ── */ @@ -1126,46 +1509,100 @@ padding: 0 0 64px; } + .onboarding-form-shell { + width: 100%; + max-width: none; + margin: 0; + padding: 16px 16px 22px; + border-radius: 28px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 248, 0.98)); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.42), + 0 18px 38px rgba(17, 20, 24, 0.06); + } + /* ── Step progress ── */ .step-progress { - display: flex; - align-items: center; - gap: 0; - margin-bottom: 24px; - padding: 20px 24px; - border-radius: 24px; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(33, 48, 33, 0.08); - box-shadow: 0 8px 24px rgba(33, 48, 33, 0.05); + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: stretch; + gap: 6px; + margin-bottom: 0; + padding: 0; + width: 100%; + min-width: 0; + overflow-x: visible; + scrollbar-width: none; + -ms-overflow-style: none; + } + + .step-progress::-webkit-scrollbar { + display: none; } .step-indicator { - display: flex; + min-width: 0; + width: 100%; + display: inline-flex; flex-direction: column; align-items: center; - gap: 8px; - flex: 1; - background: none; - border: none; - padding: 0; + justify-content: center; + gap: 6px; + padding: 10px 6px; + border: 0; + border-radius: 20px; + background: rgba(255, 255, 255, 0.72); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.06), + 0 6px 16px rgba(17, 20, 24, 0.035); + text-align: center; + white-space: nowrap; cursor: default; + transition: + transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), + background 0.18s ease, + box-shadow 0.18s ease, + opacity 0.18s ease; } .step-indicator.completed { cursor: pointer; } + .step-indicator.active { + background: linear-gradient(180deg, rgba(255, 209, 0, 0.18), rgba(255, 209, 0, 0.12)); + box-shadow: + inset 0 0 0 1px rgba(255, 209, 0, 0.34), + 0 10px 24px rgba(17, 20, 24, 0.06); + } + + .step-indicator:disabled { + opacity: 0.72; + cursor: not-allowed; + } + + @media (hover: hover) { + .step-indicator.completed:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.98); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.08), + 0 10px 22px rgba(17, 20, 24, 0.06); + } + } + .step-indicator-circle { - width: 44px; - height: 44px; - border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; - font-size: 17px; - background: rgba(33, 48, 33, 0.07); - color: rgba(33, 48, 33, 0.35); - transition: background 0.2s, color 0.2s; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(33, 48, 33, 0.08); + color: rgba(33, 48, 33, 0.45); + font-size: 12px; + flex: none; } .step-indicator.active .step-indicator-circle { @@ -1179,43 +1616,31 @@ } .step-indicator-label { - font-family: var(--font-head); - font-size: 12px; + color: rgba(33, 48, 33, 0.45); + font-size: 10px; font-weight: 700; letter-spacing: 0.01em; - color: rgba(33, 48, 33, 0.4); + line-height: 1.1; text-align: center; - transition: color 0.2s; - } - - .step-indicator.active .step-indicator-label { - color: #213021; + text-wrap: pretty; + white-space: normal; } + .step-indicator.active .step-indicator-label, .step-indicator.completed .step-indicator-label { color: #213021; } - .step-connector { - flex: 1; - height: 2px; - background: rgba(33, 48, 33, 0.1); - margin-bottom: 26px; - transition: background 0.3s; - } - - .step-connector.filled { - background: #213021; - } - /* ── Panels ── */ .onboarding-panel, .onboarding-success-card { padding: 30px; border-radius: 28px; - background: rgba(255, 255, 255, 0.84); - border: 1px solid rgba(33, 48, 33, 0.08); - box-shadow: 0 20px 44px rgba(33, 48, 33, 0.07); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 247, 0.98)); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.3), + 0 10px 22px rgba(17, 20, 24, 0.04); } .onboarding-success-card { @@ -1224,6 +1649,11 @@ text-align: center; } + .onboarding-success-card-left { + max-width: none; + text-align: left; + } + .onboarding-success-badge { display: inline-flex; align-items: center; @@ -1244,9 +1674,9 @@ .onboarding-panel-head h2 { margin: 0; font-family: var(--font-head); - font-size: clamp(26px, 3vw, 36px); + font-size: clamp(26px, 2.8vw, 32px); font-weight: 700; - line-height: 1.06; + line-height: 1.08; letter-spacing: -0.03em; text-wrap: balance; } @@ -1259,6 +1689,15 @@ color: rgba(33, 48, 33, 0.7); } + .onboarding-submission-meta { + margin-top: 16px; + font-size: 13px; + font-weight: 700; + color: rgba(33, 48, 33, 0.55); + letter-spacing: 0.03em; + text-transform: uppercase; + } + .onboarding-panel-head { display: flex; gap: 18px; @@ -1266,6 +1705,195 @@ margin-bottom: 28px; } + .onboarding-submission-view { + display: grid; + gap: 18px; + } + + .submission-section-list { + display: grid; + gap: 16px; + } + + .submission-section-card { + padding: 22px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 247, 0.98)); + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.32), + 0 10px 22px rgba(17, 20, 24, 0.04); + } + + .submission-section-head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; + } + + .submission-section-head h3 { + margin: 0; + color: #171b20; + font-family: var(--font-head); + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + } + + .submission-section-icon { + width: 42px; + height: 42px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #ffe36b 0%, #ffd100 100%); + color: #213021; + font-size: 17px; + flex: none; + } + + .submission-field-list { + display: grid; + gap: 14px; + } + + .submission-field-row { + display: grid; + gap: 6px; + padding-top: 14px; + border-top: 1px solid rgba(33, 48, 33, 0.08); + } + + .submission-field-row:first-child { + padding-top: 0; + border-top: 0; + } + + .submission-field-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(33, 48, 33, 0.5); + } + + .submission-field-value { + color: #171b20; + font-size: 15px; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .submission-tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .submission-tag { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: rgba(33, 48, 33, 0.08); + color: #213021; + font-size: 13px; + font-weight: 700; + } + + .panel-subsection + .panel-subsection { + margin-top: 28px; + } + + .panel-subsection-soft { + margin-top: 30px; + padding: 20px 20px 4px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(255, 248, 225, 0.52), rgba(255, 255, 255, 0.84)); + box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.06); + } + + .panel-subsection-head { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; + } + + .panel-subsection-actions { + display: flex; + justify-content: flex-start; + margin: -2px 0 4px; + } + + .panel-subsection-head h3 { + margin: 0; + color: #171b20; + font-family: var(--font-head); + font-size: 18px; + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.02em; + } + + .panel-subsection-head p { + margin: 6px 0 0; + color: rgba(33, 48, 33, 0.66); + font-size: 14px; + line-height: 1.55; + } + + .panel-subsection-icon { + flex: none; + width: 38px; + height: 38px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 209, 0, 0.18); + color: #213021; + box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.05); + font-size: 15px; + } + + .subsection-quick-action { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 0 14px; + border: 1px solid rgba(33, 48, 33, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: rgba(33, 48, 33, 0.84); + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.18s ease; + } + + .subsection-quick-action:hover { + border-color: rgba(33, 48, 33, 0.22); + background: #fff; + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(17, 20, 24, 0.05); + } + + .subsection-quick-action:focus-visible { + outline: none; + box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.14); + } + .onboarding-step-icon { flex: none; width: 48px; @@ -1306,29 +1934,36 @@ .field span, .signature-header span { - font-family: var(--font-head); + color: #171b20; font-size: 15px; - font-weight: 700; - letter-spacing: -0.01em; + font-weight: 600; + letter-spacing: 0; } .field input, .field textarea { width: 100%; padding: 15px 16px; - border: 1px solid rgba(33, 48, 33, 0.14); + border: 1px solid rgba(17, 20, 24, 0.1); border-radius: 18px; - background: #fff; + background: #fffdfa; font: inherit; color: var(--gw-green); outline: none; - transition: border-color 0.18s ease, box-shadow 0.18s ease; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + } + + .field input:hover, + .field textarea:hover { + border-color: rgba(17, 20, 24, 0.18); + background: #fff; } .field input:focus, .field textarea:focus { - border-color: rgba(255, 209, 0, 0.9); - box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16); + border-color: rgba(33, 48, 33, 0.45); + background: #fff; + box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.12); } .field textarea { @@ -1344,6 +1979,125 @@ line-height: 1.4; } + .yn-field { + gap: 10px; + } + + .yn-buttons { + display: inline-grid; + grid-template-columns: repeat(2, minmax(92px, 1fr)); + gap: 8px; + width: 100%; + max-width: 240px; + } + + .yn-btn { + min-height: 46px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid rgba(33, 48, 33, 0.12); + background: #fff; + color: rgba(33, 48, 33, 0.72); + font: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.18s ease; + } + + .yn-btn:hover { + border-color: rgba(33, 48, 33, 0.22); + background: rgba(255, 255, 255, 0.98); + } + + .yn-btn.active { + background: #213021; + border-color: #213021; + color: #fff; + box-shadow: 0 8px 18px rgba(17, 20, 24, 0.08); + } + + .yn-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.14); + } + + .recall-scale-wrap { + display: grid; + gap: 10px; + } + + .recall-scale { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + } + + .recall-btn { + min-height: 54px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid rgba(33, 48, 33, 0.12); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 248, 0.98)); + color: rgba(33, 48, 33, 0.72); + font: inherit; + cursor: pointer; + transition: + transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease; + } + + .recall-btn-number { + color: inherit; + font-size: 16px; + font-weight: 700; + line-height: 1; + } + + .recall-btn:hover { + border-color: rgba(33, 48, 33, 0.22); + transform: translateY(-1px); + } + + .recall-btn.active { + background: #213021; + border-color: #213021; + color: #fff; + box-shadow: 0 10px 20px rgba(17, 20, 24, 0.08); + } + + .recall-btn.active .recall-btn-number { + color: #fff; + } + + .recall-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.14); + } + + .recall-scale-labels { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 0 2px; + color: rgba(33, 48, 33, 0.52); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + /* ── Service chips ── */ .chip-grid { display: flex; @@ -1360,13 +2114,16 @@ border-radius: 999px; border: 1px solid rgba(33, 48, 33, 0.12); background: #fff; - font-family: var(--font-head); font-size: 14px; font-weight: 700; cursor: pointer; transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; } + .chip-option span { + color: inherit; + } + .chip-option input { position: absolute; opacity: 0; @@ -1376,7 +2133,7 @@ .chip-option.chip-selected { background: #213021; border-color: #213021; - color: #ffd100; + color: #fff; transform: translateY(-1px); } @@ -1393,8 +2150,8 @@ align-items: start; padding: 14px 16px; border-radius: 18px; - background: #fff; - border: 1px solid rgba(33, 48, 33, 0.08); + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(17, 20, 24, 0.08); } .confirm-row input { @@ -1526,70 +2283,166 @@ } .onboarding-topbar-inner { - height: 50px; - } - - .journey-bar-inner { - height: auto; - padding: 12px 0; - } - - .journey-stage-label { - display: none; + min-height: 84px; + padding-top: 16px; + padding-bottom: 16px; } .onboarding-hero { - padding: 20px 0 16px; + padding: 12px 0 6px; } - .onboarding-contact-card { - align-items: stretch; + .onboarding-hero-copy h1 { + font-size: 24px; + max-width: 15ch; } - .onboarding-contact-title { - width: 100%; - margin-right: 0; + .onboarding-hero-meta { + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: none; } - .onboarding-contact-link { + .onboarding-meta-link { + flex: 0 0 auto; justify-content: center; - width: 100%; + width: auto; + } + + .onboarding-steps-section { + padding: 4px 0 10px; + } + + .onboarding-form-shell { + padding: 16px 16px 22px; + border-radius: 26px; } .step-progress { - padding: 16px 12px; - gap: 0; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + overflow-x: visible; + padding-bottom: 4px; } - .step-indicator-label { - font-size: 10px; + .step-indicator { + min-width: 0; + padding: 9px 4px; + border-radius: 16px; } .step-indicator-circle { - width: 36px; - height: 36px; - font-size: 14px; + width: 26px; + height: 26px; + font-size: 10px; } - .step-connector { - margin-bottom: 22px; + .step-indicator-label { + font-size: 9px; + letter-spacing: 0; } .two-up { grid-template-columns: 1fr; } + .recall-scale { + gap: 6px; + } + + .recall-btn { + min-height: 48px; + border-radius: 16px; + } + + .recall-btn-number { + font-size: 15px; + } + + .recall-scale-labels { + font-size: 10px; + letter-spacing: 0.02em; + } + .onboarding-panel, .onboarding-success-card { padding: 22px 18px; border-radius: 22px; } + .submission-section-card { + padding: 18px 16px; + border-radius: 20px; + } + + .submission-section-head { + gap: 12px; + margin-bottom: 16px; + } + + .submission-section-head h3 { + font-size: 19px; + } + + .submission-section-icon { + width: 38px; + height: 38px; + border-radius: 12px; + font-size: 15px; + } + + .submission-field-value { + font-size: 14px; + line-height: 1.6; + } + .onboarding-panel-head { gap: 14px; margin-bottom: 20px; } + .panel-subsection + .panel-subsection { + margin-top: 24px; + } + + .panel-subsection-soft { + margin-top: 24px; + padding: 18px 16px 2px; + border-radius: 20px; + } + + .panel-subsection-head { + gap: 12px; + margin-bottom: 16px; + } + + .panel-subsection-actions { + margin-bottom: 6px; + } + + .panel-subsection-head h3 { + font-size: 17px; + } + + .panel-subsection-head p { + font-size: 13px; + line-height: 1.5; + } + + .panel-subsection-icon { + width: 34px; + height: 34px; + border-radius: 12px; + font-size: 14px; + } + + .subsection-quick-action { + width: 100%; + justify-content: center; + } + .signature-header { align-items: start; flex-direction: column; diff --git a/src/lib/components/OnboardingPage.test.ts b/src/lib/components/OnboardingPage.test.ts new file mode 100644 index 0000000..143aa7f --- /dev/null +++ b/src/lib/components/OnboardingPage.test.ts @@ -0,0 +1,89 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import OnboardingPage from './OnboardingPage.svelte'; + +function clickService(label: string) { + const chip = screen.getByText(label).closest('label'); + if (!chip) throw new Error(`Could not find service chip: ${label}`); + return fireEvent.click(chip); +} + +async function chooseOption(groupName: RegExp | string, option: 'Yes' | 'No') { + const group = screen.getByRole('radiogroup', { name: groupName }); + await fireEvent.click(within(group).getByRole('radio', { name: option })); +} + +describe('OnboardingPage', () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + window.localStorage.setItem('gw_onboarding_session', 'test-token'); + window.scrollTo = vi.fn(); + }); + + it('progresses from behaviour to sign in the 5-step flow', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + + if (url === '/api/auth/verify') { + return Promise.resolve({ + ok: true, + json: vi.fn().mockResolvedValue({ + email: 'alex@example.com', + profile: {}, + draft: {} + }) + }); + } + + if (url === '/api/save-draft') { + return Promise.resolve({ + ok: true, + json: vi.fn().mockResolvedValue({}) + }); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }) + ); + + render(OnboardingPage); + + await waitFor(() => expect(screen.getByPlaceholderText('First name')).toBeInTheDocument()); + + await fireEvent.input(screen.getByPlaceholderText('First name'), { target: { value: 'Alex' } }); + await fireEvent.input(screen.getByPlaceholderText('Surname'), { target: { value: 'Walker' } }); + await fireEvent.input(screen.getByPlaceholderText('you@example.com'), { target: { value: 'alex@example.com' } }); + await fireEvent.input(screen.getByPlaceholderText('021 234 5678'), { target: { value: '0212345678' } }); + await fireEvent.input(screen.getByPlaceholderText('Street address'), { target: { value: '1 Test Street' } }); + await fireEvent.click(screen.getByRole('button', { name: /save and continue/i })); + + await waitFor(() => expect(screen.getByPlaceholderText('Dog name')).toBeInTheDocument()); + await fireEvent.input(screen.getByPlaceholderText('Dog name'), { target: { value: 'Milo' } }); + await fireEvent.input(screen.getByPlaceholderText('Breed'), { target: { value: 'Spoodle' } }); + await fireEvent.input(screen.getByLabelText(/date of birth/i), { target: { value: '2020-01-01' } }); + await clickService('Tiny Gang Pack Walks'); + await fireEvent.click(screen.getByRole('button', { name: /save and continue/i })); + + await waitFor(() => expect(screen.getByPlaceholderText('Vet clinic or vet name')).toBeInTheDocument()); + await fireEvent.input(screen.getByPlaceholderText('Vet clinic or vet name'), { target: { value: 'Grey Lynn Vets' } }); + await fireEvent.input(screen.getByPlaceholderText('Vet phone number'), { target: { value: '099999999' } }); + await fireEvent.input(screen.getByPlaceholderText('Vet address'), { target: { value: '2 Vet Street' } }); + await fireEvent.input(screen.getByPlaceholderText('Emergency contact name'), { target: { value: 'Jamie Walker' } }); + await fireEvent.input(screen.getByPlaceholderText('Emergency contact number'), { target: { value: '0211111111' } }); + await chooseOption(/is your dog vaccinated/i, 'Yes'); + await chooseOption(/does your dog have any food allergies/i, 'No'); + await chooseOption(/does your dog have any environmental allergies/i, 'No'); + await chooseOption(/is your dog on a special diet/i, 'No'); + await chooseOption(/is your dog taking any medication/i, 'No'); + await fireEvent.click(screen.getByRole('button', { name: /save and continue/i })); + + await waitFor(() => expect(screen.getByText(/registered with council/i)).toBeInTheDocument()); + await chooseOption(/registered with council/i, 'Yes'); + await fireEvent.click(screen.getByRole('button', { name: /save and continue/i })); + + await waitFor(() => expect(screen.getByText(/anything else you'd like us to know/i)).toBeInTheDocument()); + }); +}); diff --git a/src/lib/components/PricingPage.svelte b/src/lib/components/PricingPage.svelte index adb1dd9..6006bec 100644 --- a/src/lib/components/PricingPage.svelte +++ b/src/lib/components/PricingPage.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { onMount } from 'svelte'; import { reveal } from '$lib/actions/reveal'; - import BookingSection from '$lib/components/BookingSection.svelte'; + import BookingWizard from '$lib/components/BookingWizard.svelte'; import Icon from '$lib/components/Icon.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import PricingPlanCard from '$lib/components/PricingPlanCard.svelte'; @@ -189,7 +189,7 @@ testimonials={content.testimonials} seedKey="/our-pricing" /> - <BookingSection booking={pageContent.booking} variant="card-stepper" /> + <BookingWizard booking={pageContent.booking} pagePath="/our-pricing" /> </main> <style> diff --git a/src/lib/components/PricingPlanCard.svelte b/src/lib/components/PricingPlanCard.svelte index 9606990..7a842cc 100644 --- a/src/lib/components/PricingPlanCard.svelte +++ b/src/lib/components/PricingPlanCard.svelte @@ -319,25 +319,61 @@ @media (max-width: 768px) { .plan-card { order: var(--mobile-order, 0); - width: min(100%, 440px); + width: 100%; margin-inline: auto; - padding: 28px 22px 24px; + padding: 22px 18px 20px; + border-radius: 22px; } .plan-card--featured { - padding-top: 44px; + padding-top: 36px; } .plan-card--pricing.plan-card--featured { - padding: 44px 22px 24px; + padding: 36px 18px 20px; } .plan-card__price { - font-size: 44px; + font-size: 36px; } .plan-card--supporting .plan-card__price { - font-size: 42px; + font-size: 34px; + } + + .plan-card__title { + font-size: 17px; + } + + .plan-card__price-block { + margin-top: 14px; + } + + .plan-card__features { + margin-top: 18px; + padding-top: 16px; + } + + .plan-card__features li { + gap: 8px; + font-size: 13px; + line-height: 1.45; + } + + .plan-card__features li + li { + margin-top: 8px; + } + + .plan-card__cta { + margin-top: 20px; + padding-inline: 16px; + } + + .plan-card__ribbon { + top: 12px; + left: 16px; + padding: 4px 9px 4px 8px; + font-size: 10px; } .plan-card__cta { diff --git a/src/lib/components/ServiceAreaMap.svelte b/src/lib/components/ServiceAreaMap.svelte new file mode 100644 index 0000000..5234f80 --- /dev/null +++ b/src/lib/components/ServiceAreaMap.svelte @@ -0,0 +1,929 @@ +<script lang="ts"> + type LabelAnchor = 'top' | 'bottom' | 'left' | 'right'; + type LabelTone = 'brand' | 'accent'; + type Breakpoint = 'desktop' | 'tablet' | 'mobile'; + + type Placement = { + x: number; + y: number; + anchor: LabelAnchor; + dx?: number; + dy?: number; + visible?: boolean; + z?: number; + }; + + type Pin = { + slug: string; + suburb: string; + tone?: LabelTone; + desktop: Placement; + tablet?: Partial<Placement>; + mobile?: Partial<Placement>; + }; + + const mapWidth = 640; + const mapViewY = 100; + const mapHeight = 350; + const centre = { x: 320, y: 238 }; + const city = { x: 344, y: 176 }; + + const pins: Pin[] = [ + { + slug: 'pt-chevalier', + suburb: 'Pt Chevalier', + tone: 'accent', + desktop: { x: 124, y: 228, anchor: 'left', dy: -6, z: 4 }, + tablet: { x: 120, y: 230, anchor: 'left', dy: -4 }, + mobile: { x: 114, y: 230, anchor: 'right', dx: 8, dy: -2, z: 6 } + }, + { + slug: 'herne-bay', + suburb: 'Herne Bay', + tone: 'accent', + desktop: { x: 226, y: 118, anchor: 'top', dx: -10, z: 5 }, + tablet: { x: 224, y: 122, anchor: 'top', dx: -14, dy: -2 }, + mobile: { x: 214, y: 116, anchor: 'bottom', dx: -14, dy: 6, z: 6 } + }, + { + slug: 'freemans-bay', + suburb: 'Freemans Bay', + desktop: { x: 330, y: 124, anchor: 'top', dx: 14, dy: -4, z: 5 }, + tablet: { x: 326, y: 130, anchor: 'top', dx: 10, dy: -2 }, + mobile: { x: 322, y: 132, anchor: 'top', dx: 8, dy: -4, visible: false } + }, + { + slug: 'ponsonby', + suburb: 'Ponsonby', + desktop: { x: 250, y: 178, anchor: 'left', dx: -4, dy: -10, z: 5 }, + tablet: { x: 244, y: 184, anchor: 'left', dx: -6, dy: -10 }, + mobile: { x: 238, y: 172, anchor: 'top', dx: -6, dy: -10, z: 6 } + }, + { + slug: 'grey-lynn', + suburb: 'Grey Lynn', + desktop: { x: 214, y: 220, anchor: 'left', dx: -10, dy: -2, z: 5 }, + tablet: { x: 208, y: 222, anchor: 'left', dx: -10, dy: 2 }, + mobile: { x: 196, y: 222, anchor: 'left', dx: -6, dy: 0, z: 6 } + }, + { + slug: 'kingsland', + suburb: 'Kingsland', + desktop: { x: 248, y: 258, anchor: 'left', dx: -6, dy: -2 }, + tablet: { x: 236, y: 260, anchor: 'left', dx: -8, dy: -2 }, + mobile: { x: 232, y: 260, anchor: 'left', dx: -2, visible: false } + }, + { + slug: 'morningside', + suburb: 'Morningside', + desktop: { x: 196, y: 290, anchor: 'left', dx: -10, dy: 2 }, + tablet: { x: 186, y: 290, anchor: 'left', dx: -8, dy: 4 }, + mobile: { x: 184, y: 288, anchor: 'left', dx: -2, dy: 2, visible: false } + }, + { + slug: 'mt-albert', + suburb: 'Mt Albert', + tone: 'accent', + desktop: { x: 148, y: 314, anchor: 'left', dx: -6, dy: 2, z: 4 }, + tablet: { x: 148, y: 314, anchor: 'left', dx: -8, dy: 4 }, + mobile: { x: 144, y: 312, anchor: 'left', dx: -2, dy: 0, z: 5 } + }, + { + slug: 'sandringham', + suburb: 'Sandringham', + desktop: { x: 244, y: 344, anchor: 'bottom', dx: -10, dy: 0, z: 4 }, + tablet: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2 }, + mobile: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2, visible: false } + }, + { + slug: 'mt-eden', + suburb: 'Mt Eden', + desktop: { x: 344, y: 280, anchor: 'right', dx: 6, dy: -8, z: 5 }, + tablet: { x: 334, y: 282, anchor: 'right', dx: 6, dy: -6, z: 5 }, + mobile: { x: 332, y: 280, anchor: 'right', dx: 4, dy: -8, z: 6 } + }, + { + slug: 'balmoral', + suburb: 'Balmoral', + desktop: { x: 314, y: 334, anchor: 'right', dx: 8, dy: 4, z: 4 }, + tablet: { x: 306, y: 338, anchor: 'bottom', dx: 0, dy: 6, z: 4 }, + mobile: { x: 304, y: 338, anchor: 'bottom', dx: 0, dy: 6, visible: false } + }, + { + slug: 'remuera', + suburb: 'Remuera', + tone: 'accent', + desktop: { x: 470, y: 272, anchor: 'right', dx: 10, dy: -10, z: 4 }, + tablet: { x: 452, y: 280, anchor: 'right', dx: 8, dy: -8, z: 4 }, + mobile: { x: 438, y: 276, anchor: 'right', dx: 6, dy: -6, z: 5 } + }, + { + slug: 'greenlane', + suburb: 'Greenlane', + desktop: { x: 426, y: 348, anchor: 'right', dx: 10, dy: 6, z: 4 }, + tablet: { x: 410, y: 346, anchor: 'right', dx: 8, dy: 6, z: 4 }, + mobile: { x: 404, y: 340, anchor: 'right', dx: 4, dy: 6, z: 5 } + }, + { + slug: 'mt-roskill', + suburb: 'Mt Roskill', + desktop: { x: 182, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 }, + tablet: { x: 180, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 }, + mobile: { x: 178, y: 382, anchor: 'left', dx: 4, dy: 2, z: 5 } + }, + { + slug: 'three-kings', + suburb: 'Three Kings', + desktop: { x: 296, y: 390, anchor: 'bottom', dx: 8, dy: 0, z: 4 }, + tablet: { x: 292, y: 388, anchor: 'bottom', dx: 8, dy: 0, z: 4 }, + mobile: { x: 292, y: 390, anchor: 'bottom', dx: 6, dy: 0, z: 6 } + }, + { + slug: 'hillsborough', + suburb: 'Hillsborough', + tone: 'accent', + desktop: { x: 216, y: 426, anchor: 'bottom', dx: -6, dy: 0, z: 4 }, + tablet: { x: 214, y: 422, anchor: 'bottom', dx: -8, dy: 0, z: 4 }, + mobile: { x: 218, y: 418, anchor: 'top', dx: -2, dy: -8, z: 6 } + }, + { + slug: 'onehunga', + suburb: 'Onehunga', + desktop: { x: 364, y: 426, anchor: 'bottom', dx: 8, dy: 0, z: 3 }, + tablet: { x: 354, y: 424, anchor: 'bottom', dx: 8, dy: 0, z: 3 }, + mobile: { x: 352, y: 420, anchor: 'top', dx: 10, dy: -8, visible: false } + } + ]; + + function percentX(x: number) { + return `${(x / mapWidth) * 100}%`; + } + + function percentY(y: number) { + return `${((y - mapViewY) / mapHeight) * 100}%`; + } + + function resolvePlacement(pin: Pin, breakpoint: Breakpoint): Placement { + const desktop = pin.desktop; + const override = + breakpoint === 'desktop' ? {} : breakpoint === 'tablet' ? pin.tablet ?? {} : pin.mobile ?? {}; + + return { + ...desktop, + ...override + }; + } + + function placementTokens(prefix: string, placement: Placement, gap: number, stem: number) { + const dx = placement.dx ?? 0; + const dy = placement.dy ?? 0; + + if (placement.anchor === 'left') { + return [ + `--${prefix}-direction: row-reverse`, + `--${prefix}-transform: translate(calc(-100% - ${gap}px + ${dx}px), calc(-50% + ${dy}px))`, + `--${prefix}-stem-w: ${stem}px`, + `--${prefix}-stem-h: 1.5px` + ]; + } + + if (placement.anchor === 'right') { + return [ + `--${prefix}-direction: row`, + `--${prefix}-transform: translate(calc(${gap}px + ${dx}px), calc(-50% + ${dy}px))`, + `--${prefix}-stem-w: ${stem}px`, + `--${prefix}-stem-h: 1.5px` + ]; + } + + if (placement.anchor === 'top') { + return [ + `--${prefix}-direction: column-reverse`, + `--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(-100% - ${gap}px + ${dy}px))`, + `--${prefix}-stem-w: 1.5px`, + `--${prefix}-stem-h: ${stem}px` + ]; + } + + return [ + `--${prefix}-direction: column`, + `--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(${gap}px + ${dy}px))`, + `--${prefix}-stem-w: 1.5px`, + `--${prefix}-stem-h: ${stem}px` + ]; + } + + function displayToken(placement: Placement | undefined) { + return placement?.visible === false ? 'none' : 'inline-flex'; + } + + function pinStyle(pin: Pin, index: number) { + const desktop = resolvePlacement(pin, 'desktop'); + const tablet = resolvePlacement(pin, 'tablet'); + const mobile = resolvePlacement(pin, 'mobile'); + + return [ + `--desktop-left: ${percentX(desktop.x)}`, + `--desktop-top: ${percentY(desktop.y)}`, + `--desktop-z: ${desktop.z ?? 3}`, + `--desktop-display: ${displayToken(desktop)}`, + ...placementTokens('desktop', desktop, 14, 16), + `--tablet-left: ${percentX(tablet.x)}`, + `--tablet-top: ${percentY(tablet.y)}`, + `--tablet-z: ${tablet.z ?? desktop.z ?? 3}`, + `--tablet-display: ${displayToken(tablet)}`, + ...placementTokens('tablet', tablet, 12, 14), + `--mobile-left: ${percentX(mobile.x)}`, + `--mobile-top: ${percentY(mobile.y)}`, + `--mobile-z: ${mobile.z ?? tablet.z ?? desktop.z ?? 3}`, + `--mobile-display: ${displayToken(mobile)}`, + ...placementTokens('mobile', mobile, 10, 12), + `--pin-delay: ${((index * 0.11) % 1.6).toFixed(2)}s` + ].join('; '); + } + + function routePath(pin: Pin) { + const target = pin.desktop; + const controlX = (centre.x + target.x) / 2; + const controlY = (centre.y + target.y) / 2 + (target.y >= centre.y ? 14 : -14); + + return `M ${centre.x} ${centre.y} Q ${controlX} ${controlY} ${target.x} ${target.y}`; + } +</script> + +<figure class="area-map" aria-labelledby="area-map-caption"> + <div class="area-map-shell"> + <div class="area-map-stage"> + <svg + class="area-map-svg" + viewBox={`0 ${mapViewY} ${mapWidth} ${mapHeight}`} + role="presentation" + aria-hidden="true" + preserveAspectRatio="xMidYMid meet" + > + <defs> + <linearGradient id="area-map-bg" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stop-color="rgba(var(--white-rgb), 0.98)" /> + <stop offset="58%" stop-color="rgba(var(--white-rgb), 0.94)" /> + <stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.1)" /> + </linearGradient> + <radialGradient id="area-map-glow" cx="50%" cy="46%" r="54%"> + <stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.14)" /> + <stop offset="48%" stop-color="rgba(var(--accent-rgb), 0.04)" /> + <stop offset="100%" stop-color="rgba(var(--accent-rgb), 0)" /> + </radialGradient> + <radialGradient id="area-map-core-glow" cx="50%" cy="50%" r="58%"> + <stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.12)" /> + <stop offset="70%" stop-color="rgba(var(--brand-rgb), 0.015)" /> + <stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" /> + </radialGradient> + <linearGradient id="area-map-district-main" x1="18%" y1="12%" x2="82%" y2="88%"> + <stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.16)" /> + <stop offset="100%" stop-color="rgba(var(--brand-rgb), 0.05)" /> + </linearGradient> + <linearGradient id="area-map-district-soft" x1="10%" y1="20%" x2="95%" y2="85%"> + <stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.1)" /> + <stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.02)" /> + </linearGradient> + <linearGradient id="area-map-route-flow" x1="0%" y1="0%" x2="100%" y2="0%"> + <stop offset="0%" stop-color="rgba(var(--brand-rgb), 0)" /> + <stop offset="40%" stop-color="rgba(var(--brand-rgb), 0.04)" /> + <stop offset="56%" stop-color="rgba(var(--accent-rgb), 0.44)" /> + <stop offset="72%" stop-color="rgba(var(--brand-rgb), 0.08)" /> + <stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" /> + </linearGradient> + <pattern id="area-map-grid" width="52" height="52" patternUnits="userSpaceOnUse"> + <path d="M 52 0 L 0 0 0 52" fill="none" stroke="rgba(var(--brand-rgb), 0.045)" stroke-width="1" /> + </pattern> + <filter id="area-map-shadow" x="-20%" y="-20%" width="140%" height="140%"> + <feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="rgba(var(--brand-rgb), 0.1)" /> + </filter> + </defs> + + <rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-bg)" /> + <rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-grid)" /> + <ellipse cx="324" cy="234" rx="206" ry="154" fill="url(#area-map-glow)" /> + + <g class="area-map-waterways"> + <path + d="M 26 108 C 96 74, 176 66, 244 84 C 302 100, 336 124, 396 122 C 470 120, 548 76, 620 90" + class="area-map-waterway" + /> + <path + d="M 78 454 C 140 430, 186 416, 240 420 C 292 424, 330 460, 392 462 C 472 466, 560 428, 616 384" + class="area-map-waterway area-map-waterway-soft" + /> + </g> + + <g class="area-map-districts" filter="url(#area-map-shadow)"> + <path + d="M 94 206 C 132 162, 198 132, 274 126 C 334 122, 396 134, 446 160 C 500 188, 532 240, 530 300 C 528 362, 492 412, 430 434 C 342 466, 224 458, 150 412 C 92 376, 70 282, 94 206 Z" + fill="url(#area-map-district-main)" + /> + <path + d="M 126 214 C 168 180, 222 164, 284 164 C 348 164, 402 180, 442 214 C 474 242, 490 280, 486 320 C 482 366, 448 402, 394 420 C 316 446, 214 438, 154 394 C 112 360, 98 274, 126 214 Z" + fill="rgba(var(--white-rgb), 0.32)" + stroke="rgba(var(--brand-rgb), 0.08)" + stroke-width="1" + /> + <path + d="M 300 158 C 360 158, 420 176, 462 210 C 500 240, 522 278, 522 322 C 522 370, 500 402, 462 414 C 420 426, 362 412, 332 378 C 304 346, 302 312, 338 282 C 366 256, 384 230, 378 202 C 372 178, 346 162, 300 158 Z" + fill="url(#area-map-district-soft)" + /> + <path + d="M 156 248 C 188 236, 224 244, 248 268 C 268 288, 278 320, 266 348 C 252 382, 218 402, 180 402 C 148 400, 120 384, 104 356 C 88 324, 92 286, 116 262 C 128 250, 142 244, 156 248 Z" + fill="rgba(var(--white-rgb), 0.16)" + /> + </g> + + <g class="area-map-roads" aria-hidden="true"> + <path d="M 148 248 C 204 250, 244 266, 286 292 C 322 316, 360 354, 406 390" class="area-map-road" /> + <path d="M 240 126 C 270 160, 292 194, 316 238 C 336 278, 356 340, 364 430" class="area-map-road area-map-road-strong" /> + <path d="M 122 300 C 182 304, 244 300, 306 290 C 356 282, 418 264, 468 238" class="area-map-road area-map-road-soft" /> + </g> + + <g class="area-map-routes"> + {#each pins as pin} + <path d={routePath(pin)} class="area-map-route-base" pathLength="1" /> + <path d={routePath(pin)} class="area-map-route-flow" pathLength="1" /> + {/each} + </g> + + <g class="area-map-core"> + <circle cx={centre.x} cy={centre.y} r="64" class="area-map-core-aura" /> + <circle cx={centre.x} cy={centre.y} r="48" class="area-map-core-halo" /> + <circle cx={centre.x} cy={centre.y} r="24" class="area-map-core-pulse" /> + <circle cx={centre.x} cy={centre.y} r="15" class="area-map-core-ring" /> + <circle cx={centre.x} cy={centre.y} r="10" class="area-map-core-dot" /> + </g> + + <g class="area-map-skyline" transform={`translate(${city.x - 46} ${city.y - 54}) scale(0.94)`}> + <ellipse cx="42" cy="74" rx="34" ry="8" class="area-map-tower-shadow" /> + <path d="M 16 72 C 24 58, 29 41, 32 21 C 34 10, 36 6, 38.5 2.5 C 39.4 1.2, 40.2 0.5, 40.8 0 C 41.5 0.5, 42.3 1.2, 43.2 2.5 C 45.8 6, 47.8 10, 49.8 21 C 52.8 41, 57.8 58, 65.5 72 L 59 72 C 54.8 63.5, 51.8 49.5, 48.4 31 L 47.2 31 L 47.2 20.5 C 47.2 17.6, 46.3 15.7, 44.8 13.8 L 43.9 12.4 L 45 12.4 L 44 8.6 L 42.5 8.6 L 42.8 4.8 L 41.4 4.8 L 40.2 4.8 L 38.8 4.8 L 39.1 8.6 L 37.6 8.6 L 36.6 12.4 L 37.7 12.4 L 36.8 13.8 C 35.3 15.7, 34.4 17.6, 34.4 20.5 L 34.4 31 L 33.2 31 C 29.8 49.5, 26.8 63.5, 22.6 72 Z" class="area-map-tower-body" /> + <path d="M 34 21.5 C 34 16.5, 36.8 13.6, 40.8 13.6 C 44.8 13.6, 47.6 16.5, 47.6 21.5 C 47.6 25.3, 45.8 28.2, 43 29.5 L 43 34.2 C 45.8 34.9, 48 36.5, 49.6 39.3 L 31.8 39.3 C 33.5 36.5, 35.6 34.9, 38.6 34.2 L 38.6 29.5 C 35.8 28.2, 34 25.3, 34 21.5 Z" class="area-map-tower-observation" /> + <path d="M 30.8 39.3 L 50.6 39.3 L 52 42.9 L 29.4 42.9 Z" class="area-map-tower-band" /> + <path d="M 29.4 42.9 C 32.8 46.4, 36.1 47.8, 40.8 47.8 C 45.5 47.8, 48.8 46.4, 52.2 42.9 L 51.1 49.4 C 47.8 51.5, 45 52.2, 40.8 52.2 C 36.6 52.2, 33.8 51.5, 30.5 49.4 Z" class="area-map-tower-ring" /> + <path d="M 33.2 52.2 L 48.4 52.2 L 50 72 L 31.6 72 Z" class="area-map-tower-stem" /> + <path d="M 37.4 55.6 L 39.4 55.6 L 39.4 68.8 L 37.4 68.8 Z M 42.2 55.6 L 44.2 55.6 L 44.2 68.8 L 42.2 68.8 Z" class="area-map-tower-slit" /> + <circle cx="40.8" cy="10.4" r="1.9" class="area-map-tower-beacon" /> + </g> + </svg> + + <div class="area-map-overlay"> + <span + class="area-map-label area-map-label-static area-map-label-city" + style={`left:${percentX(city.x)}; top:${percentY(city.y)};`} + aria-hidden="true" + > + <span class="area-map-label-stem"></span> + <span class="area-map-label-dot-wrap"> + <span class="area-map-label-dot area-map-label-dot-city"></span> + </span> + <span class="area-map-label-pill area-map-label-pill-city">City</span> + </span> + + {#each pins as pin, index} + <a + href={`/locations/${pin.slug}`} + class={`area-map-label ${pin.tone === 'accent' ? 'area-map-label-accent' : ''}`} + style={pinStyle(pin, index)} + aria-label={`View ${pin.suburb} location page`} + > + <span class="area-map-label-stem" aria-hidden="true"></span> + <span class="area-map-label-dot-wrap" aria-hidden="true"> + <span class="area-map-label-pulse"></span> + <span class="area-map-label-dot"></span> + </span> + <span class="area-map-label-pill"> + <span class="area-map-label-text-full">{pin.suburb}</span> + </span> + </a> + {/each} + </div> + </div> + </div> + + <figcaption id="area-map-caption" class="area-map-caption"> + Tap a suburb to open its local page with parks, routes, and service details. + </figcaption> +</figure> + +<style> + .area-map { + margin: 0; + } + + @media (max-width: 768px) { + .area-map { + display: none; + } + } + + .area-map-shell { + display: grid; + place-items: center; + padding: 0 clamp(12px, 1.8vw, 18px) clamp(14px, 2vw, 20px); + overflow: visible; + } + + .area-map-stage { + position: relative; + width: min(100%, 54rem); + overflow: hidden; + border-radius: clamp(26px, 2.8vw, 34px); + background: + radial-gradient(circle at 16% 12%, rgba(var(--accent-rgb), 0.1), transparent 30%), + linear-gradient(180deg, rgba(var(--white-rgb), 0.24), rgba(var(--white-rgb), 0)); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.08), + 0 18px 42px rgba(var(--ink-rgb), 0.08); + } + + .area-map-svg { + display: block; + width: 100%; + height: auto; + aspect-ratio: 640 / 340; + } + + .area-map-overlay { + position: absolute; + inset: clamp(14px, 2vw, 20px) clamp(28px, 4vw, 48px); + overflow: visible; + pointer-events: none; + } + + .area-map-waterway { + fill: none; + stroke: rgba(var(--white-rgb), 0.56); + stroke-width: 10; + stroke-linecap: round; + opacity: 0.56; + } + + .area-map-waterway-soft { + stroke-width: 7; + opacity: 0.28; + } + + .area-map-road { + fill: none; + stroke: rgba(var(--brand-rgb), 0.1); + stroke-width: 1.8; + stroke-linecap: round; + stroke-dasharray: 1 9; + opacity: 0.66; + } + + .area-map-road-soft { + opacity: 0.38; + stroke-dasharray: 1 11; + } + + .area-map-road-strong { + stroke: rgba(var(--brand-rgb), 0.15); + stroke-width: 2.2; + opacity: 0.72; + } + + .area-map-route-base { + fill: none; + stroke: rgba(var(--brand-rgb), 0.08); + stroke-width: 1.2; + stroke-linecap: round; + opacity: 0.72; + } + + .area-map-route-flow { + fill: none; + stroke: url(#area-map-route-flow); + stroke-width: 2.4; + stroke-linecap: round; + stroke-dasharray: 0.18 0.82; + animation: areaRouteFlow 7.2s linear infinite; + opacity: 0.76; + } + + .area-map-core-aura { + fill: url(#area-map-core-glow); + } + + .area-map-core-halo { + fill: rgba(var(--accent-rgb), 0.12); + } + + .area-map-core-pulse { + fill: rgba(var(--brand-rgb), 0.1); + transform-origin: 320px 238px; + animation: areaCorePulse 4.8s ease-out infinite; + } + + .area-map-core-ring { + fill: rgba(var(--white-rgb), 0.86); + stroke: rgba(var(--brand-rgb), 0.16); + stroke-width: 1.25; + } + + .area-map-core-dot { + fill: var(--gw-green); + stroke: rgba(var(--accent-rgb), 0.9); + stroke-width: 2.2; + } + + .area-map-skyline { + pointer-events: none; + opacity: 0.62; + filter: drop-shadow(0 6px 12px rgba(var(--ink-rgb), 0.12)); + } + + .area-map-tower-shadow { + fill: rgba(var(--ink-rgb), 0.08); + } + + .area-map-tower-body, + .area-map-tower-observation, + .area-map-tower-band, + .area-map-tower-ring, + .area-map-tower-stem, + .area-map-tower-slit { + animation: areaTowerFloat 8s ease-in-out infinite; + transform-origin: 40.8px 74px; + } + + .area-map-tower-body { + fill: rgba(var(--white-rgb), 0.92); + stroke: rgba(var(--ink-rgb), 0.24); + stroke-width: 1.05; + } + + .area-map-tower-observation { + fill: rgba(var(--white-rgb), 0.96); + stroke: rgba(var(--ink-rgb), 0.3); + stroke-width: 1; + } + + .area-map-tower-band { + fill: rgba(var(--ink-rgb), 0.72); + } + + .area-map-tower-ring { + fill: rgba(var(--white-rgb), 0.98); + stroke: rgba(var(--ink-rgb), 0.26); + stroke-width: 0.9; + } + + .area-map-tower-stem { + fill: rgba(var(--white-rgb), 0.84); + stroke: rgba(var(--ink-rgb), 0.2); + stroke-width: 0.85; + } + + .area-map-tower-slit { + fill: rgba(var(--ink-rgb), 0.76); + } + + .area-map-tower-beacon { + fill: var(--yellow); + opacity: 0.7; + animation: areaBeaconBlink 4.2s ease-in-out infinite; + } + + .area-map-label { + position: absolute; + left: var(--desktop-left); + top: var(--desktop-top); + z-index: var(--desktop-z); + display: var(--desktop-display); + flex-direction: var(--desktop-direction); + align-items: center; + gap: clamp(6px, 0.85vw, 9px); + transform: var(--desktop-transform); + color: inherit; + text-decoration: none; + pointer-events: auto; + outline: none; + } + + .area-map-label-static { + left: 53.75%; + top: 32.38%; + z-index: 2; + transform: translate(-50%, calc(-100% - 12px)); + pointer-events: none; + } + + .area-map-label-stem { + display: block; + flex: 0 0 auto; + width: var(--desktop-stem-w); + height: var(--desktop-stem-h); + border-radius: 999px; + background: rgba(var(--brand-rgb), 0.18); + transition: background var(--motion-fast); + } + + .area-map-label-dot-wrap { + position: relative; + flex: 0 0 auto; + width: clamp(11px, 1.4vw, 14px); + height: clamp(11px, 1.4vw, 14px); + } + + .area-map-label-dot { + position: absolute; + inset: 0; + margin: auto; + width: clamp(7px, 1vw, 9px); + height: clamp(7px, 1vw, 9px); + border-radius: 50%; + background: var(--gw-green); + border: 2px solid rgba(var(--white-rgb), 0.96); + box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.11); + transition: + transform var(--motion-fast), + background var(--motion-fast), + box-shadow var(--motion-fast); + } + + .area-map-label-pulse { + position: absolute; + inset: 0; + border-radius: 999px; + background: rgba(var(--brand-rgb), 0.14); + transform: scale(0.5); + opacity: 0; + animation: areaPinPulse 4.6s ease-out infinite; + animation-delay: var(--pin-delay); + } + + .area-map-label-pill { + display: inline-flex; + align-items: center; + min-height: clamp(30px, 3vw, 34px); + padding: clamp(6px, 0.95vw, 8px) clamp(10px, 1.35vw, 14px); + border: 1px solid rgba(var(--brand-rgb), 0.12); + border-radius: 999px; + background: rgba(var(--white-rgb), 0.95); + color: var(--text-brand); + font-family: var(--font-head); + font-size: clamp(0.61rem, 0.52rem + 0.24vw, 0.78rem); + font-weight: 700; + letter-spacing: -0.015em; + line-height: 1; + white-space: nowrap; + box-shadow: + inset 0 1px 0 rgba(var(--white-rgb), 0.82), + 0 8px 18px rgba(var(--ink-rgb), 0.06); + transition: + transform var(--motion-fast), + background var(--motion-fast), + border-color var(--motion-fast), + box-shadow var(--motion-fast), + color var(--motion-fast); + backdrop-filter: blur(8px); + } + + .area-map-label-accent .area-map-label-pill { + background: rgba(var(--accent-rgb), 0.12); + border-color: rgba(var(--accent-rgb), 0.2); + } + + .area-map-label-pill-city { + min-height: 28px; + padding: 6px 10px; + background: rgba(var(--brand-rgb), 0.1); + border-color: rgba(var(--brand-rgb), 0.16); + font-size: 0.66rem; + box-shadow: 0 6px 14px rgba(var(--ink-rgb), 0.05); + } + + .area-map-label-dot-city { + background: var(--yellow); + box-shadow: 0 0 0 5px rgba(var(--brand-rgb), 0.1); + } + + .area-map-label:hover .area-map-label-pill, + .area-map-label:focus-visible .area-map-label-pill { + background: var(--gw-green); + border-color: rgba(var(--brand-rgb), 0.18); + color: var(--text-inverse); + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(var(--brand-rgb), 0.14); + } + + .area-map-label:hover .area-map-label-stem, + .area-map-label:focus-visible .area-map-label-stem { + background: rgba(var(--brand-rgb), 0.32); + } + + .area-map-label:hover .area-map-label-dot, + .area-map-label:focus-visible .area-map-label-dot { + background: var(--yellow); + transform: scale(1.12); + box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.16); + } + + .area-map-caption { + margin: 12px auto 0; + max-width: 54rem; + color: var(--text-subtle); + font-size: 13px; + line-height: 1.55; + text-align: center; + } + + @keyframes areaCorePulse { + 0% { + transform: scale(0.8); + opacity: 0.42; + } + 72% { + transform: scale(1.2); + opacity: 0; + } + 100% { + transform: scale(1.2); + opacity: 0; + } + } + + @keyframes areaPinPulse { + 0% { + transform: scale(0.52); + opacity: 0.32; + } + 78% { + transform: scale(2.25); + opacity: 0; + } + 100% { + transform: scale(2.25); + opacity: 0; + } + } + + @keyframes areaRouteFlow { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: -1.3; + } + } + + @keyframes areaTowerFloat { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-1.5px); + } + } + + @keyframes areaBeaconBlink { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.92); + } + 50% { + opacity: 0.74; + transform: scale(1.04); + } + } + + @media (max-width: 1120px) { + .area-map-shell { + padding-block: clamp(14px, 2vw, 20px); + } + + .area-map-stage { + width: min(100%, 48rem); + } + + .area-map-overlay { + inset: 16px 18px 18px; + } + + .area-map-label { + left: var(--tablet-left); + top: var(--tablet-top); + z-index: var(--tablet-z); + display: var(--tablet-display); + flex-direction: var(--tablet-direction); + transform: var(--tablet-transform); + } + + .area-map-label-stem { + width: var(--tablet-stem-w); + height: var(--tablet-stem-h); + } + } + + @media (max-width: 768px) { + .area-map-stage { + width: 100%; + border-radius: 26px; + } + + .area-map-overlay { + inset: 14px 16px 18px; + } + + .area-map-label-pill { + box-shadow: + inset 0 1px 0 rgba(var(--white-rgb), 0.84), + 0 6px 14px rgba(var(--ink-rgb), 0.06); + } + } + + @media (max-width: 640px) { + .area-map-shell { + padding-inline: 4px; + padding-bottom: 14px; + } + + .area-map-stage { + border-radius: 22px; + } + + .area-map-overlay { + inset: 16px 14px 22px; + } + + .area-map-label { + left: var(--mobile-left); + top: var(--mobile-top); + z-index: var(--mobile-z); + display: var(--mobile-display); + flex-direction: var(--mobile-direction); + gap: 5px; + transform: var(--mobile-transform); + } + + .area-map-label-stem { + width: var(--mobile-stem-w); + height: var(--mobile-stem-h); + } + + .area-map-label-pill { + min-height: 28px; + padding: 6px 9px; + font-size: clamp(0.56rem, 0.52rem + 0.18vw, 0.64rem); + } + + .area-map-label-pill-city { + min-height: 26px; + padding-inline: 8px; + font-size: 0.6rem; + } + + .area-map-label-dot-wrap { + width: 11px; + height: 11px; + } + + .area-map-label-dot { + width: 7px; + height: 7px; + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1); + } + } + + @media (max-width: 430px) { + .area-map-shell { + padding-inline: 0; + } + + .area-map-overlay { + inset: 18px 12px 24px; + } + + .area-map-label-pill { + min-height: 27px; + padding: 5px 8px; + font-size: 0.55rem; + letter-spacing: -0.012em; + } + + .area-map-caption { + font-size: 12px; + } + } + + @media (prefers-reduced-motion: reduce) { + .area-map-route-flow, + .area-map-core-pulse, + .area-map-label-pulse, + .area-map-tower-body, + .area-map-tower-observation, + .area-map-tower-band, + .area-map-tower-ring, + .area-map-tower-stem, + .area-map-tower-slit, + .area-map-tower-beacon { + animation: none; + } + } +</style> diff --git a/src/lib/components/ServiceHero.svelte b/src/lib/components/ServiceHero.svelte index d209350..904826e 100644 --- a/src/lib/components/ServiceHero.svelte +++ b/src/lib/components/ServiceHero.svelte @@ -52,6 +52,10 @@ 30+ five-star Google reviews </a> </div> + + <p class="sh-credentials"> + Walked by Alessandra · Pet first aid certified · Public liability insured + </p> </div> <!-- Right: full-height photo, no card, no shadow, bleeds to viewport edge --> @@ -200,6 +204,15 @@ flex: 0 0 auto; } + .sh-credentials { + margin: 18px 0 0; + color: rgba(255, 255, 255, 0.62); + font-size: 12px; + font-weight: 500; + line-height: 1.4; + letter-spacing: 0.02em; + } + /* ── Photo column — fills full height, bleeds to right viewport edge ── */ .sh-media { position: relative; diff --git a/src/lib/components/ServiceLandingPage.svelte b/src/lib/components/ServiceLandingPage.svelte index 017053d..057aac5 100644 --- a/src/lib/components/ServiceLandingPage.svelte +++ b/src/lib/components/ServiceLandingPage.svelte @@ -2,13 +2,15 @@ import { onMount, tick } from 'svelte'; import Icon from '$lib/components/Icon.svelte'; import { reveal } from '$lib/actions/reveal'; - import BookingSection from '$lib/components/BookingSection.svelte'; + import BookingWizard from '$lib/components/BookingWizard.svelte'; import PricingPlanCard from '$lib/components/PricingPlanCard.svelte'; import ServiceHero from '$lib/components/ServiceHero.svelte'; import TestimonialsSection from '$lib/components/TestimonialsSection.svelte'; import FaqSection from '$lib/components/FaqSection.svelte'; + import ServiceAreaMap from '$lib/components/ServiceAreaMap.svelte'; import { getEnhancedImage } from '$lib/enhanced-images'; import { decoratePlans } from '$lib/utils/pricing'; + import { locationPages } from '$lib/content/locations'; import type { ServicePageContent, SiteSharedContent } from '$lib/types'; export let content: SiteSharedContent; @@ -28,15 +30,28 @@ $: pricingPlans = decoratePlans(pageContent.pricing.plans); $: introLeadParagraph = pageContent.hero.paragraphs?.[0] ?? ''; $: introBodyParagraphs = pageContent.hero.paragraphs?.slice(1) ?? []; + $: heroDetailHighlights = pageContent.hero.detailHighlights ?? []; $: benefitCards = pageContent.benefits.items.map((benefit, index) => ({ ...benefit, tintClass: `service-benefit-tint-${(index % 3) + 1}`, featured: index === 0 })); + $: isPackWalks = currentPath === '/pack-walks'; + $: isDogWalking = currentPath === '/dog-walking'; + $: isPuppyVisits = currentPath === '/puppy-visits'; + $: usesWideServiceShell = isPackWalks || isDogWalking || isPuppyVisits; + $: decisionEyebrow = isPackWalks ? 'Fit check' : isDogWalking ? 'Solo fit' : 'Fit check'; + $: decisionIntro = isPackWalks + ? 'A quick way to see whether Tiny Gang is likely to suit your dog before you enquire.' + : isDogWalking + ? 'A calmer, more personal option for dogs who need space, steadier handling, or one familiar walker.' + : 'A quick way to see whether this service is likely to suit your dog before you enquire.'; + $: decisionFitKicker = isPackWalks ? 'Good fit' : isDogWalking ? 'Best on solo' : 'Good fit'; + $: decisionNotKicker = isPackWalks ? 'Better on solo' : isDogWalking ? 'Better in Tiny Gang' : 'Alternative fit'; $: useSimpleBenefitSwipe = - currentPath === '/pack-walks' || - currentPath === '/dog-walking' || - currentPath === '/puppy-visits'; + isPackWalks || + isDogWalking || + isPuppyVisits; $: showRelatedServices = relatedServices.length > 0 && currentPath !== '/pack-walks'; $: relatedCards = [ @@ -147,7 +162,12 @@ }); </script> -<main class="service-page"> +<main + class:service-page-pack={isPackWalks} + class:service-page-dog={isDogWalking} + class:service-page-wide={usesWideServiceShell} + class="service-page" +> <ServiceHero eyebrow={pageContent.hero.eyebrow} title={pageContent.hero.title} @@ -282,7 +302,7 @@ </section> {#if pageContent.hero.paragraphs?.length} - <section use:reveal class="service-intro reveal-block"> + <section class:service-intro-pack={isPackWalks} use:reveal class="service-intro reveal-block"> <div class="page-inner"> <article class="service-intro-card"> <div class="service-intro-header"> @@ -300,6 +320,16 @@ </div> <div class="service-intro-body"> <p class="service-intro-lead">{introLeadParagraph}</p> + {#if heroDetailHighlights.length} + <div class="service-intro-highlights" aria-label="Service highlights"> + {#each heroDetailHighlights as highlight} + <div class="service-intro-highlight"> + <strong>{highlight.value}</strong> + <span>{highlight.label}</span> + </div> + {/each} + </div> + {/if} {#if introBodyParagraphs.length} <div class="service-intro-prose"> {#each introBodyParagraphs as paragraph} @@ -313,9 +343,61 @@ </section> {/if} + {#if pageContent.decision} + <section class:service-decision-pack={isPackWalks} class:service-decision-dog={isDogWalking} use:reveal class="service-decision reveal-block" aria-labelledby="service-decision-heading"> + <div class="page-inner"> + <div class="service-decision-card"> + <div class="service-decision-header"> + <span class="service-decision-eyebrow">{decisionEyebrow}</span> + <h2 id="service-decision-heading" class="service-decision-heading"> + {pageContent.decision.title ?? 'Is this right for your dog?'} + </h2> + <p class="service-decision-intro"> + {decisionIntro} + </p> + </div> + <div class="service-decision-grid"> + <div class="service-decision-col service-decision-col-fit"> + <span class="service-decision-col-kicker service-decision-col-kicker-fit">{decisionFitKicker}</span> + <h3>{pageContent.decision.fitTitle ?? 'A good fit if your dog:'}</h3> + <ul> + {#each pageContent.decision.fitItems as item} + <li> + <span class="service-decision-icon service-decision-icon-yes" aria-hidden="true"> + <Icon name="fas fa-check" /> + </span> + <span>{item}</span> + </li> + {/each} + </ul> + </div> + <div class="service-decision-col service-decision-col-not"> + <span class="service-decision-col-kicker service-decision-col-kicker-not">{decisionNotKicker}</span> + <h3>{pageContent.decision.notFitTitle ?? 'Probably not a fit if:'}</h3> + <ul> + {#each pageContent.decision.notFitItems as item} + <li> + <span class="service-decision-icon service-decision-icon-no" aria-hidden="true"> + <Icon name="fas fa-xmark" /> + </span> + <span>{item}</span> + </li> + {/each} + </ul> + </div> + </div> + {#if pageContent.decision.footnote} + <p class="service-decision-footnote">{pageContent.decision.footnote}</p> + {/if} + </div> + </div> + </section> + {/if} + <section use:reveal class:service-pricing-immediate-mobile={useSimpleBenefitSwipe} + class:service-pricing-pack={isPackWalks} class="service-pricing reveal-block" > <div class="page-inner"> @@ -347,24 +429,77 @@ <a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta" href="#newlead">Book a Meet & Greet</a> {#if pageContent.pricing.extras?.length} - <div class="service-extras"> - <div class="service-extras-heading">Extras</div> - {#each pageContent.pricing.extras as extra} - <div class="service-extra-row"> - <span class="service-extra-label"> - {extra.label} - {#if extra.note} - <span class="service-extra-pill">{extra.note}</span> - {/if} - </span> - <span class="service-extra-price">{extra.price}</span> - </div> - {/each} + <div class="service-extras" aria-labelledby="service-extras-heading"> + <div class="service-extras-header"> + <span class="service-extras-eyebrow"> + <Icon name="fas fa-plus" /> + Add-ons + </span> + <h3 id="service-extras-heading" class="service-extras-title">Optional extras</h3> + </div> + <div class="service-extras-grid"> + {#each pageContent.pricing.extras as extra} + <article class="service-extra-card"> + <div class="service-extra-card-body"> + <span class="service-extra-card-label">{extra.label}</span> + {#if extra.note} + <span class="service-extra-card-note">{extra.note}</span> + {/if} + </div> + <span class="service-extra-card-price">{extra.price}</span> + </article> + {/each} + </div> </div> {/if} </div> </section> + <section class:service-areas-pack={isPackWalks} use:reveal class="service-areas reveal-block" aria-labelledby="service-areas-heading"> + <div class="page-inner"> + <div class="service-areas-card"> + <div class="service-areas-header"> + <div class="service-areas-copy"> + <div class="service-areas-badge"> + <div class="service-areas-mark" aria-hidden="true"> + <Icon name="fas fa-map-location-dot" /> + </div> + <span class="service-areas-eyebrow">Coverage map</span> + </div> + <h2 id="service-areas-heading" class="service-areas-heading"> + Where we walk across Auckland Central + </h2> + <p class="service-areas-lead"> + Free pickup and drop-off across Auckland Central and 17+ nearby suburbs. Selected suburbs link to local pages. + </p> + </div> + <ul class="service-areas-stats" aria-label="Service area summary"> + <li> + <strong>17+</strong> + <span>suburbs covered</span> + </li> + <li> + <strong>Free</strong> + <span>pickup and drop-off</span> + </li> + <li> + <strong>Local</strong> + <span>parks and route pages</span> + </li> + </ul> + </div> + <ServiceAreaMap /> + <ul class="service-areas-chips" aria-label="Selected suburbs we cover"> + {#each locationPages as location} + <li> + <a class="service-areas-chip" href={`/locations/${location.slug}`}>{location.suburb}</a> + </li> + {/each} + </ul> + </div> + </div> + </section> + {#if pageContent.faq?.items?.length} <section use:reveal class="service-faq reveal-block"> <div class="page-inner"> @@ -417,10 +552,18 @@ testimonials={content.testimonials} seedKey={currentPath} /> - <BookingSection booking={pageContent.booking} variant="card-stepper" /> + <BookingWizard booking={pageContent.booking} pagePath={currentPath} /> </main> <style> + .service-page { + --service-shell-max: 960px; + --service-copy-max: 680px; + --service-mobile-shell-pad-x: 18px; + --service-mobile-shell-pad-y: 24px; + --service-mobile-shell-pad-y-tight: 20px; + } + .service-page { background: var(--off-white); } @@ -433,7 +576,7 @@ display: grid; grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.35fr); gap: 44px; - max-width: 980px; + max-width: var(--service-shell-max); margin: 0 auto; padding: 38px 40px 40px; border-radius: 30px; @@ -478,7 +621,7 @@ display: flex; flex-direction: column; gap: 18px; - justify-content: space-between; + justify-content: flex-start; padding-right: 12px; } @@ -511,7 +654,13 @@ 0 10px 22px rgba(33, 48, 33, 0.18); } - .service-intro-eyebrow { + .service-intro-eyebrow, + .service-decision-eyebrow, + .service-areas-eyebrow, + .service-extras-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; margin: 0; color: var(--gw-green); font-family: var(--font-head); @@ -537,6 +686,37 @@ align-content: start; } + .service-intro-highlights { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + + .service-intro-highlight { + display: grid; + gap: 4px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(var(--white-rgb), 0.72); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.08), + 0 10px 20px rgba(var(--ink-rgb), 0.04); + } + + .service-intro-highlight strong { + color: var(--text-brand); + font-family: var(--font-head); + font-size: 18px; + font-weight: 700; + letter-spacing: -0.025em; + } + + .service-intro-highlight span { + color: var(--text-subtle); + font-size: 12px; + line-height: 1.35; + } + .service-intro-prose p { margin: 0 0 15px; color: #454a50; @@ -571,7 +751,7 @@ .service-intro-card { grid-template-columns: 1fr; gap: 20px; - padding: 26px 20px 24px; + padding: var(--service-mobile-shell-pad-y) var(--service-mobile-shell-pad-x) var(--service-mobile-shell-pad-y-tight); border-radius: 24px; } @@ -611,6 +791,590 @@ font-size: 16px; line-height: 1.65; } + + .service-intro-highlights { + grid-template-columns: 1fr; + gap: 10px; + } + } + + /* ── Decision block ── */ + .service-decision { + padding: 12px 0 var(--space-section-featured-y); + } + + .service-decision-card { + max-width: var(--service-shell-max); + margin: 0 auto; + padding: 38px 40px 32px; + border-radius: 30px; + background: + radial-gradient(circle at top right, rgba(255, 209, 0, 0.08), transparent 38%), + linear-gradient(180deg, #fff 0%, #faf7ef 100%); + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.06), + 0 18px 40px rgba(17, 20, 24, 0.06); + } + + .service-decision-header { + display: grid; + justify-items: center; + gap: 10px; + max-width: 40rem; + margin: 0 auto 26px; + text-align: center; + } + + .service-decision-heading { + margin: 0; + font-family: var(--font-head); + font-size: clamp(26px, 2.5vw, 32px); + line-height: 1.15; + color: var(--text-heading); + text-align: center; + text-wrap: balance; + } + + .service-decision-intro { + margin: 0; + max-width: 34rem; + color: var(--text-muted); + font-size: 15px; + line-height: 1.6; + } + + .service-decision-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 26px; + } + + .service-decision-col { + background: #fff; + border-radius: 22px; + padding: 22px 22px 18px; + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.06), + 0 8px 20px rgba(17, 20, 24, 0.04); + } + + .service-decision-col-kicker { + display: inline-flex; + align-items: center; + min-height: 28px; + margin: 0 0 12px; + padding: 5px 10px; + border-radius: 999px; + font-family: var(--font-head); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .service-decision-col-kicker-fit { + background: rgba(33, 48, 33, 0.1); + color: var(--gw-green); + } + + .service-decision-col-kicker-not { + background: rgba(255, 209, 0, 0.14); + color: #5a4500; + } + + .service-decision-col h3 { + margin: 0 0 14px; + font-family: var(--font-head); + font-size: 18px; + line-height: 1.25; + color: var(--text-heading); + max-width: 18ch; + } + + .service-decision-col ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 10px; + } + + .service-decision-col li { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + align-items: start; + gap: 10px; + color: var(--text-heading-soft); + font-size: 15px; + line-height: 1.55; + } + + .service-decision-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-top: 2px; + border-radius: 50%; + font-size: 10px; + } + + .service-decision-icon-yes { + background: rgba(33, 48, 33, 0.1); + color: var(--gw-green); + } + + .service-decision-icon-no { + background: rgba(190, 60, 60, 0.1); + color: #be3c3c; + } + + .service-decision-footnote { + margin: 22px 0 0; + padding-top: 18px; + border-top: 1px solid rgba(var(--brand-rgb), 0.08); + color: var(--text-muted); + font-size: 14px; + line-height: 1.5; + text-align: center; + } + + @media (max-width: 768px) { + .service-decision-card { + padding: var(--service-mobile-shell-pad-y) var(--service-mobile-shell-pad-x) var(--service-mobile-shell-pad-y-tight); + border-radius: 24px; + } + + .service-decision-header { + gap: 8px; + margin-bottom: 20px; + } + + .service-decision-grid { + grid-template-columns: 1fr; + gap: 16px; + } + } + + /* ── Areas-we-cover (linked suburbs) ── */ + .service-areas { + padding: 0 0 var(--space-section-featured-y); + } + + .service-areas-card { + max-width: var(--service-shell-max); + margin: 0 auto; + padding: 34px 36px 30px; + border-radius: 32px; + background: + radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.13), transparent 24%), + linear-gradient(180deg, rgba(var(--white-rgb), 0.98) 0%, rgba(251, 248, 242, 0.98) 100%); + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.05), + 0 18px 44px rgba(17, 20, 24, 0.06); + } + + .service-areas-header { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.9fr); + gap: 22px; + align-items: end; + margin-bottom: 6px; + } + + .service-areas-copy { + max-width: 34rem; + } + + .service-areas-badge { + display: inline-flex; + align-items: center; + gap: 12px; + width: fit-content; + margin-bottom: 10px; + padding: 10px 16px 10px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.62); + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.07), + 0 12px 24px rgba(17, 20, 24, 0.04); + backdrop-filter: blur(10px); + } + + .service-areas-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: linear-gradient(180deg, var(--gw-green) 0%, var(--green-mid) 100%); + color: var(--yellow); + font-size: 17px; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.06), + 0 10px 22px rgba(33, 48, 33, 0.18); + } + + .service-areas-eyebrow { + margin-bottom: 0; + } + + .service-areas-stats { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + + .service-areas-stats li { + display: grid; + gap: 4px; + padding: 14px 14px 16px; + border: 1px solid rgba(var(--brand-rgb), 0.09); + border-radius: 18px; + background: rgba(var(--white-rgb), 0.7); + box-shadow: inset 0 1px 0 rgba(var(--white-rgb), 0.4); + text-align: center; + } + + .service-areas-stats strong { + font-family: var(--font-head); + font-size: 16px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-brand); + } + + .service-areas-stats span { + color: var(--text-subtle); + font-size: 12px; + line-height: 1.35; + } + + .service-areas-heading { + margin: 0 0 10px; + font-family: var(--font-head); + font-size: clamp(22px, 2.2vw, 28px); + line-height: 1.2; + color: var(--text-heading); + } + + .service-areas-lead { + margin: 0; + color: var(--text-muted); + font-size: 15px; + line-height: 1.6; + } + + .service-areas-chips { + list-style: none; + margin: 18px 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .service-areas-chip { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid rgba(var(--brand-rgb), 0.08); + background: rgba(var(--white-rgb), 0.72); + color: var(--gw-green); + font-family: var(--font-head); + font-size: 13px; + font-weight: 600; + text-decoration: none; + box-shadow: 0 6px 16px rgba(var(--ink-rgb), 0.04); + transition: + background 0.18s ease, + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease; + } + + .service-areas-chip:hover { + background: rgba(var(--brand-rgb), 0.92); + border-color: rgba(var(--brand-rgb), 0.2); + color: var(--text-inverse); + transform: translateY(-1px); + box-shadow: 0 12px 22px rgba(var(--brand-rgb), 0.16); + } + + @media (max-width: 768px) { + .service-areas-header { + grid-template-columns: 1fr; + gap: 16px; + margin-bottom: 18px; + } + + .service-areas-stats { + gap: 10px; + } + + .service-areas-stats li { + padding: 12px 10px 14px; + border-radius: 16px; + } + + .service-areas-card { + padding: var(--service-mobile-shell-pad-y) var(--service-mobile-shell-pad-x) var(--service-mobile-shell-pad-y-tight); + border-radius: 24px; + } + + .service-areas-badge { + gap: 10px; + padding: 8px 14px 8px 8px; + } + + .service-areas-mark { + width: 38px; + height: 38px; + border-radius: 12px; + font-size: 16px; + } + } + + .service-page-pack .service-section-heading { + margin-bottom: 30px; + text-align: center; + } + + .service-page-pack { + --service-shell-max: 1120px; + } + + .service-page-wide { + --service-shell-max: 1120px; + } + + .service-page-pack .service-section-heading h2 { + max-width: 14ch; + color: var(--text-heading); + margin-left: auto; + margin-right: auto; + } + + .service-page-pack .service-intro-card { + background: + radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.1), transparent 28%), + linear-gradient(135deg, rgba(var(--white-rgb), 0.99) 0%, rgba(248, 247, 242, 0.98) 56%, rgba(244, 241, 234, 0.96) 100%); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.08), + 0 28px 58px rgba(var(--ink-rgb), 0.08); + } + + .service-page-pack .service-intro-badge { + background: rgba(var(--white-rgb), 0.76); + } + + .service-page-pack .service-intro-lead { + padding: 20px 22px; + border-left: none; + background: + linear-gradient(180deg, rgba(var(--white-rgb), 0.92) 0%, rgba(248, 247, 242, 0.96) 100%); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.08), + 0 14px 28px rgba(var(--ink-rgb), 0.05); + color: var(--text-heading); + } + + .service-page-pack .service-intro-lead::before { + content: ''; + display: block; + width: 56px; + height: 3px; + margin-bottom: 14px; + border-radius: 999px; + background: linear-gradient(90deg, var(--yellow) 0%, rgba(var(--accent-rgb), 0.2) 100%); + } + + .service-page-pack .service-intro-highlight { + background: rgba(var(--white-rgb), 0.9); + } + + .service-page-pack .service-decision-card { + background: + radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.09), transparent 30%), + linear-gradient(180deg, rgba(var(--white-rgb), 0.99) 0%, rgba(248, 246, 240, 0.97) 100%); + } + + .service-page-pack .service-decision-heading { + text-align: center; + max-width: 16ch; + margin-left: auto; + margin-right: auto; + } + + .service-page-pack .service-decision-col-fit { + background: + radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.14), transparent 34%), + linear-gradient(180deg, var(--green-mid) 0%, var(--gw-green) 100%); + box-shadow: + inset 0 0 0 1px rgba(var(--white-rgb), 0.08), + 0 12px 26px rgba(var(--ink-rgb), 0.12); + } + + .service-page-pack .service-decision-col-fit h3, + .service-page-pack .service-decision-col-fit li { + color: var(--text-inverse); + } + + .service-page-pack .service-decision-col-fit .service-decision-icon-yes { + background: rgba(var(--white-rgb), 0.12); + color: var(--text-inverse); + } + + .service-page-pack .service-decision-col-fit .service-decision-col-kicker-fit { + background: rgba(var(--white-rgb), 0.12); + color: rgba(var(--white-rgb), 0.92); + } + + .service-page-pack .service-decision-col-not { + background: + linear-gradient(180deg, rgba(var(--white-rgb), 0.98) 0%, rgba(248, 246, 240, 0.96) 100%); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.07), + 0 10px 24px rgba(var(--ink-rgb), 0.04); + } + + .service-page-pack .service-decision-col-not .service-decision-col-kicker-not { + background: rgba(var(--accent-rgb), 0.16); + color: #5a4500; + } + + .service-page-pack .service-pricing .service-section-heading { + display: block; + } + + .service-page-pack .service-plan-scarcity, + .service-page-pack .service-plan-reassurance { + margin-left: auto; + margin-right: auto; + } + + .service-page-pack .service-areas-card { + background: + radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.1), transparent 22%), + linear-gradient(180deg, rgba(var(--white-rgb), 0.99) 0%, rgba(247, 245, 239, 0.97) 100%); + } + + .service-page-pack .service-areas-header { + align-items: end; + } + + .service-page-pack .service-areas-stats li { + background: rgba(var(--white-rgb), 0.9); + } + + .service-page.service-page-dog .service-decision-card { + background: + radial-gradient(circle at top left, rgba(var(--brand-rgb), 0.08), transparent 26%), + radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08), transparent 22%), + linear-gradient(180deg, rgba(var(--white-rgb), 0.99) 0%, rgba(246, 248, 245, 0.98) 100%); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.08), + 0 22px 48px rgba(var(--ink-rgb), 0.07); + } + + .service-page.service-page-dog .service-decision-header { + gap: 12px; + margin-bottom: 30px; + } + + .service-page.service-page-dog .service-decision-eyebrow { + color: var(--text-brand); + background: rgba(var(--brand-rgb), 0.09); + } + + .service-page.service-page-dog .service-decision-intro { + max-width: 36rem; + } + + .service-page.service-page-dog .service-decision-grid { + gap: 18px; + } + + .service-page.service-page-dog .service-decision-col { + position: relative; + border-radius: 24px; + padding: 24px 24px 20px; + overflow: hidden; + } + + .service-page.service-page-dog .service-decision-col::before { + content: ''; + position: absolute; + top: 0; + left: 24px; + right: 24px; + height: 3px; + border-radius: 999px; + background: rgba(var(--brand-rgb), 0.14); + } + + .service-page.service-page-dog .service-decision-col-fit { + background: + radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.12), transparent 30%), + linear-gradient(180deg, rgba(var(--brand-rgb), 0.94) 0%, rgba(45, 66, 48, 0.98) 100%); + box-shadow: + inset 0 0 0 1px rgba(var(--white-rgb), 0.08), + 0 14px 28px rgba(var(--brand-rgb), 0.18); + } + + .service-page.service-page-dog .service-decision-col-fit::before { + background: rgba(var(--accent-rgb), 0.72); + } + + .service-page.service-page-dog .service-decision-col-fit h3, + .service-page.service-page-dog .service-decision-col-fit li { + color: rgba(var(--white-rgb), 0.94); + } + + .service-page.service-page-dog .service-decision-col-fit .service-decision-icon-yes { + background: rgba(var(--white-rgb), 0.12); + 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%); + box-shadow: + inset 0 0 0 1px rgba(var(--brand-rgb), 0.07), + 0 10px 24px rgba(var(--ink-rgb), 0.04); + } + + @media (max-width: 768px) { + .service-page-pack .service-section-heading h2, + .service-page-pack .service-decision-heading { + max-width: none; + } + + .service-page-pack .service-pricing .service-section-heading { + display: block; + } + + .service-page-pack .service-intro-lead { + padding: 18px; + } + + .service-page.service-page-dog .service-decision-col { + padding: 22px 18px 18px; + border-radius: 22px; + } + + .service-page.service-page-dog .service-decision-col::before { + left: 18px; + right: 18px; + } } .service-faq { @@ -618,7 +1382,7 @@ } .service-faq-card { - max-width: 880px; + max-width: var(--service-shell-max); margin: 0 auto; padding: 38px 40px 32px; border-radius: 30px; @@ -630,7 +1394,7 @@ @media (max-width: 768px) { .service-faq-card { - padding: 26px 20px 22px; + padding: var(--service-mobile-shell-pad-y) var(--service-mobile-shell-pad-x) var(--service-mobile-shell-pad-y-tight); border-radius: 24px; } } @@ -764,7 +1528,7 @@ .service-section-heading p, .service-benefit-card p { margin: 20px 0 0; - max-width: 680px; + max-width: var(--service-copy-max); color: #34363a; font-size: 17px; line-height: 1.7; @@ -838,6 +1602,8 @@ grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); gap: 40px; align-items: center; + max-width: var(--service-shell-max); + margin: 0 auto; } .service-highlight-layout-points { @@ -897,6 +1663,8 @@ .service-benefit-shell { position: relative; overflow: visible; + max-width: var(--service-shell-max); + margin: 0 auto; } .service-benefit-mobile-controls, @@ -915,6 +1683,8 @@ } .service-plan-grid { + max-width: var(--service-shell-max); + margin: 0 auto; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 22px; @@ -991,63 +1761,111 @@ } .service-extras { - margin-top: 30px; + max-width: var(--service-shell-max); + margin: 40px auto 0; + padding: 32px 36px 30px; border-radius: 28px; - background: #fff; - box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05); - overflow: hidden; + background: + radial-gradient(circle at top right, rgba(255, 209, 0, 0.08), transparent 40%), + linear-gradient(180deg, #fff 0%, #faf7ef 100%); + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.06), + 0 18px 40px rgba(17, 20, 24, 0.06); } - .service-extras-heading { - padding: 18px 28px 14px; - font-family: var(--font-head); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #9ca3af; - border-bottom: 1px solid #ece9e3; - } - - .service-extra-row { + .service-extras-header { display: flex; - justify-content: space-between; - align-items: center; - gap: 20px; - padding: 18px 28px; - color: #34363a; - font-size: 16px; - line-height: 1.4; - } - - .service-extra-row + .service-extra-row { - border-top: 1px solid #ece9e3; - } - - .service-extra-label { - display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; gap: 10px; - flex-wrap: wrap; + margin-bottom: 22px; } - .service-extra-pill { - display: inline-block; - padding: 3px 10px; - border-radius: 999px; - background: #f3f4f6; - color: #6b7280; - font-size: 12px; - font-weight: 500; + .service-extras-eyebrow :global(.icon) { + font-size: 9px; + opacity: 0.82; } - .service-extra-price { + .service-extras-title { + margin: 0; font-family: var(--font-head); + font-size: clamp(22px, 2vw, 26px); + line-height: 1.2; + color: var(--text-heading); + } + + .service-extras-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + } + + .service-extra-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 18px 20px; + border-radius: 18px; + background: #fff; + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.06), + 0 8px 18px rgba(17, 20, 24, 0.04); + transition: transform 0.18s ease, box-shadow 0.22s ease; + } + + @media (hover: hover) { + .service-extra-card:hover { + transform: translateY(-2px); + box-shadow: + inset 0 0 0 1px rgba(33, 48, 33, 0.1), + 0 12px 24px rgba(17, 20, 24, 0.06); + } + } + + .service-extra-card-body { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .service-extra-card-label { + color: var(--text-heading); + font-family: var(--font-head); + font-size: 15px; + font-weight: 600; + line-height: 1.3; + } + + .service-extra-card-note { + color: var(--text-muted); + font-size: 12px; + line-height: 1.3; + } + + .service-extra-card-price { + flex: 0 0 auto; + font-family: var(--font-head); + font-size: 18px; font-weight: 700; color: var(--gw-green); white-space: nowrap; } + @media (max-width: 768px) { + .service-extras { + padding: var(--service-mobile-shell-pad-y) var(--service-mobile-shell-pad-x) var(--service-mobile-shell-pad-y-tight); + border-radius: 22px; + margin-top: 32px; + } + + .service-extras-grid { + grid-template-columns: 1fr; + gap: 10px; + } + } + .service-benefit-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1255,12 +2073,16 @@ } @media (max-width: 768px) { + .service-page { + --service-shell-max: 100%; + --service-copy-max: 34rem; + } .service-plan-grid, .service-plan-grid-three, .service-related-grid { grid-template-columns: 1fr; - gap: 24px; + gap: 16px; } .service-benefit-grid { diff --git a/src/lib/components/ServicesSection.svelte b/src/lib/components/ServicesSection.svelte index a487b66..8544918 100644 --- a/src/lib/components/ServicesSection.svelte +++ b/src/lib/components/ServicesSection.svelte @@ -36,7 +36,7 @@ lead: 'The Tiny Gang is built for dogs who love company, big adventures, and coming home happily worn out!', cues: ['4-8 dogs', 'Pickup & drop-off', 'Tiny Gang matching'] }, - '1:1 Walks': { + 'Solo Walks': { eyebrow: 'Tailored support', imageUrl: '/images/goodwalk-brown-curly-dog-one-on-one-walk-auckland.webp', imageAlt: 'Dog enjoying a one-on-one walk', diff --git a/src/lib/components/TestimonialsPage.svelte b/src/lib/components/TestimonialsPage.svelte index 8597581..5133d88 100644 --- a/src/lib/components/TestimonialsPage.svelte +++ b/src/lib/components/TestimonialsPage.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import BookingSection from '$lib/components/BookingSection.svelte'; + import BookingWizard from '$lib/components/BookingWizard.svelte'; import Icon from '$lib/components/Icon.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import { getEnhancedImage } from '$lib/enhanced-images'; @@ -147,7 +147,7 @@ </div> </section> - <BookingSection booking={content.booking} variant="card-stepper" /> + <BookingWizard booking={content.booking} pagePath="/testimonials" /> </main> <style> diff --git a/src/lib/components/ValuesSection.svelte b/src/lib/components/ValuesSection.svelte index 7708a98..f713571 100644 --- a/src/lib/components/ValuesSection.svelte +++ b/src/lib/components/ValuesSection.svelte @@ -53,8 +53,8 @@ { imageUrl: '/images/goodwalk-dogs-group-outing-auckland.webp', alt: 'Otis enjoying his Goodwalk routine in Auckland', - name: 'Otis', - detail: 'regular weekly walks' + name: 'Digby teaching tricks!', + detail: '' }, { imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp', diff --git a/src/lib/components/admin-dashboard/AdminDashboard.svelte b/src/lib/components/admin-dashboard/AdminDashboard.svelte new file mode 100644 index 0000000..45d9d21 --- /dev/null +++ b/src/lib/components/admin-dashboard/AdminDashboard.svelte @@ -0,0 +1,2776 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import Icon from '$lib/components/Icon.svelte'; + import OnboardingAuth from '$lib/components/OnboardingAuth.svelte'; + import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced'; + import type { Picture } from '@sveltejs/enhanced-img'; + + const desktop = logoDesktop as Picture; + const ownerEmail = 'info@goodwalk.co.nz'; + const allClientsPageSize = 12; + const birthdaysPageSize = 12; + + type PendingClient = { + email: string; + fullName: string; + phone: string; + dogName: string; + dogBreed: string; + services: string[]; + lastEnquiryAt: string; + welcomePackSentAt: string; + welcomePackOffer: { + serviceType?: string; + priceDetails?: string; + startDate?: string; + sentAt?: string; + }; + }; + + type AllClient = { + email: string; + fullName: string; + phone: string; + dogName: string; + dogBreed: string; + status: 'pending' | 'completed'; + lastActivityAt: string; + welcomePackSentAt: string; + }; + + type BirthdayClient = { + email: string; + fullName: string; + dogName: string; + dogBreed: string; + dogAge: string; + birthdayLabel: string; + daysUntil: number; + birthdayAutoSend: boolean; + birthdayEmailLastSentAt: string; + }; + + let authChecking = true; + let isAuthenticated = false; + let userEmail = ''; + let accessDenied = false; + + let activeTab: 'home' | 'clients' | 'birthdays' | 'messaging' = 'home'; + + let loadingHome = false; + let homeError = ''; + let pendingClients: PendingClient[] = []; + + let loadingAllClients = false; + let allClientsError = ''; + let allClients: AllClient[] = []; + let allClientsPage = 1; + let allClientsTotal = 0; + let allClientsTotalPages = 1; + + let loadingBirthdays = false; + let birthdaysError = ''; + let birthdayClients: BirthdayClient[] = []; + let birthdaysPage = 1; + let birthdaysTotal = 0; + let birthdaysTotalPages = 1; + + let activeEmail = ''; + let serviceType = ''; + let priceDetails = ''; + let startDate = ''; + let sendError = ''; + let sending = false; + let birthdayBusyEmail = ''; + let birthdayActionError = ''; + + type Enquiry = { + submittedAt: string; + enquiryType: string; + fullName: string; + email: string; + phone: string; + petName: string; + location: string; + services: string[]; + message: string; + referrer: string; + page: string; + }; + + let enquiry: Enquiry | null = null; + let enquiryLoading = false; + let enquiryError = ''; + let enquiryOpen = false; + + type ConfirmContext = + | { kind: 'welcome'; client: PendingClient; serviceType: string; priceDetails: string; startDate: string } + | { kind: 'birthday'; client: BirthdayClient } + | { kind: 'message'; recipientCount: number }; + + let confirmCtx: ConfirmContext | null = null; + let confirmPreview = false; + let confirmBusy = false; + let confirmError = ''; + + function getToken() { + return window.localStorage.getItem('gw_onboarding_session') ?? ''; + } + + function resetComposer() { + activeEmail = ''; + serviceType = ''; + priceDetails = ''; + startDate = ''; + sendError = ''; + sending = false; + } + + function formatDateLabel(value: string): string { + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('en-NZ', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + + function formatBirthday(value: string): string { + if (!value) return 'No birthday'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleDateString('en-NZ', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + } + + function birthdayRelativeLabel(daysUntil: number): string { + if (daysUntil === 0) return 'Today'; + if (daysUntil === 1) return 'Tomorrow'; + return `In ${daysUntil} days`; + } + + function openComposer(client: PendingClient) { + activeEmail = client.email; + serviceType = client.welcomePackOffer?.serviceType || client.services?.[0] || ''; + priceDetails = client.welcomePackOffer?.priceDetails || ''; + startDate = client.welcomePackOffer?.startDate || ''; + sendError = ''; + } + + async function fetchHome() { + const token = getToken(); + if (!token) return; + + loadingHome = true; + homeError = ''; + try { + const res = await fetch('/api/owner/pending-onboarding', { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null); + if (res.status === 403) { + accessDenied = true; + pendingClients = []; + return; + } + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load home view.'); + pendingClients = Array.isArray(data?.clients) ? data.clients : []; + } catch (error) { + homeError = error instanceof Error ? error.message : 'Could not load home view.'; + } finally { + loadingHome = false; + } + } + + async function fetchAllClients(page = 1) { + const token = getToken(); + if (!token) return; + + loadingAllClients = true; + allClientsError = ''; + try { + const res = await fetch(`/api/owner/all-clients?page=${page}&page_size=${allClientsPageSize}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null); + if (res.status === 403) { + accessDenied = true; + allClients = []; + return; + } + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load clients.'); + allClients = Array.isArray(data?.clients) ? data.clients : []; + allClientsPage = Number(data?.pagination?.page) || 1; + allClientsTotal = Number(data?.pagination?.total) || 0; + allClientsTotalPages = Number(data?.pagination?.totalPages) || 1; + } catch (error) { + allClientsError = error instanceof Error ? error.message : 'Could not load clients.'; + } finally { + loadingAllClients = false; + } + } + + async function fetchBirthdays(page = 1) { + const token = getToken(); + if (!token) return; + + loadingBirthdays = true; + birthdaysError = ''; + try { + const res = await fetch(`/api/owner/birthdays?page=${page}&page_size=${birthdaysPageSize}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null); + if (res.status === 403) { + accessDenied = true; + birthdayClients = []; + return; + } + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load birthdays.'); + birthdayClients = Array.isArray(data?.clients) ? data.clients : []; + birthdaysPage = Number(data?.pagination?.page) || 1; + birthdaysTotal = Number(data?.pagination?.total) || 0; + birthdaysTotalPages = Number(data?.pagination?.totalPages) || 1; + } catch (error) { + birthdaysError = error instanceof Error ? error.message : 'Could not load birthdays.'; + } finally { + loadingBirthdays = false; + } + } + + async function checkAuth() { + try { + const token = getToken(); + if (token) { + const res = await fetch('/api/auth/verify', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + isAuthenticated = true; + userEmail = data.email; + accessDenied = data.email !== ownerEmail; + if (!accessDenied) { + await Promise.all([fetchHome(), fetchAllClients(1), fetchBirthdays(1)]); + } + } else { + window.localStorage.removeItem('gw_onboarding_session'); + } + } + } catch { + /* ignore */ + } + authChecking = false; + } + + function handleAuthenticated(event: CustomEvent<{ email: string }>) { + isAuthenticated = true; + userEmail = event.detail.email; + accessDenied = event.detail.email !== ownerEmail; + if (!accessDenied) { + void Promise.all([fetchHome(), fetchAllClients(1), fetchBirthdays(1)]); + } + } + + function handleLogout() { + isAuthenticated = false; + userEmail = ''; + accessDenied = false; + pendingClients = []; + allClients = []; + birthdayClients = []; + resetComposer(); + } + + function requestSendWelcome(client: PendingClient) { + if (!serviceType.trim()) { + sendError = 'Please enter the service.'; + return; + } + if (!priceDetails.trim()) { + sendError = 'Please enter the price details.'; + return; + } + if (!startDate.trim()) { + sendError = 'Please enter a start date.'; + return; + } + sendError = ''; + confirmCtx = { + kind: 'welcome', + client, + serviceType: serviceType.trim(), + priceDetails: priceDetails.trim(), + startDate: startDate.trim(), + }; + confirmPreview = false; + confirmError = ''; + } + + function requestSendBirthday(client: BirthdayClient) { + confirmCtx = { kind: 'birthday', client }; + confirmPreview = false; + confirmError = ''; + } + + function closeConfirm() { + if (confirmBusy) return; + confirmCtx = null; + confirmPreview = false; + confirmError = ''; + } + + async function performConfirm() { + if (!confirmCtx) return; + const token = getToken(); + if (!token) { + confirmError = 'Your session has expired. Please sign in again.'; + return; + } + confirmBusy = true; + confirmError = ''; + try { + if (confirmCtx.kind === 'welcome') { + const { client, serviceType: st, priceDetails: pd, startDate: sd } = confirmCtx; + const res = await fetch('/api/owner/send-welcome-pack', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + email: client.email, + serviceType: st, + priceDetails: pd, + startDate: sd, + preview: confirmPreview, + }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not send welcome email.'); + + if (!confirmPreview) { + pendingClients = pendingClients.map((item) => + item.email === client.email + ? { + ...item, + welcomePackSentAt: data.sentAt, + welcomePackOffer: { serviceType: st, priceDetails: pd, startDate: sd, sentAt: data.sentAt }, + } + : item + ); + await fetchAllClients(allClientsPage); + resetComposer(); + } + } else if (confirmCtx.kind === 'message') { + const res = await fetch('/api/owner/send-message', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + templateId: selectedTemplateId, + subject: msgSubject, + heading: msgHeading, + subHeading: msgSubHeading, + body: msgBody, + highlightText: msgHighlight, + ctaLabel: msgCtaLabel, + ctaUrl: msgCtaUrl, + signOff: msgSignOff, + footerNote: msgFooterNote, + fontId: selectedFontId, + recipients: confirmPreview ? [] : resolvedRecipients, + preview: confirmPreview, + }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not send message.'); + } else { + const { client } = confirmCtx; + birthdayBusyEmail = client.email; + const res = await fetch('/api/owner/send-birthday-email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ email: client.email, preview: confirmPreview }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not send birthday email.'); + if (!confirmPreview) { + await fetchBirthdays(birthdaysPage); + } + birthdayBusyEmail = ''; + } + confirmCtx = null; + confirmPreview = false; + } catch (error) { + confirmError = error instanceof Error ? error.message : 'Could not send email.'; + birthdayBusyEmail = ''; + } finally { + confirmBusy = false; + } + } + + function downloadBirthdayInvite(email: string) { + const token = getToken(); + if (!token) return; + const url = `/api/owner/birthday-ics?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`; + void window.open(url, '_blank', 'noopener,noreferrer'); + } + + async function openEnquiry(email: string) { + enquiryOpen = true; + enquiryLoading = true; + enquiryError = ''; + enquiry = null; + const token = getToken(); + if (!token) { + enquiryError = 'Your session has expired. Please sign in again.'; + enquiryLoading = false; + return; + } + try { + const res = await fetch(`/api/owner/client-enquiry?email=${encodeURIComponent(email)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load enquiry.'); + enquiry = data?.enquiry ?? null; + } catch (error) { + enquiryError = error instanceof Error ? error.message : 'Could not load enquiry.'; + } finally { + enquiryLoading = false; + } + } + + function closeEnquiry() { + enquiryOpen = false; + enquiry = null; + enquiryError = ''; + } + + async function toggleBirthdayAutoSend(email: string, enabled: boolean) { + const token = getToken(); + if (!token) return; + + birthdayBusyEmail = email; + birthdayActionError = ''; + try { + const res = await fetch('/api/owner/birthday-auto-send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ email, enabled }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not update automatic birthday emails.'); + birthdayClients = birthdayClients.map((item) => + item.email === email ? { ...item, birthdayAutoSend: enabled } : item + ); + } catch (error) { + birthdayActionError = error instanceof Error ? error.message : 'Could not update automatic birthday emails.'; + } finally { + birthdayBusyEmail = ''; + } + } + + function setTab(tab: 'home' | 'clients' | 'birthdays' | 'messaging') { + activeTab = tab; + resetComposer(); + birthdayActionError = ''; + if (tab === 'messaging' && !messageTemplates.length) { + void fetchMessageTemplates(); + } + } + + type MessageTemplate = { + id: string; + name: string; + description: string; + accent: string; + bannerEmoji: string; + defaultSubject: string; + defaultHeading: string; + defaultSubHeading: string; + defaultBody: string; + defaultHighlight: string; + defaultSignOff: string; + defaultFooterNote: string; + }; + + type MessageFont = { id: string; name: string; link: string; stack: string }; + + let messageTemplates: MessageTemplate[] = []; + let messageFonts: MessageFont[] = []; + let templatesError = ''; + let selectedTemplateId = ''; + let selectedFontId = 'system'; + let msgSubject = ''; + let msgHeading = ''; + let msgSubHeading = ''; + let msgBody = ''; + let msgHighlight = ''; + let msgCtaLabel = ''; + let msgCtaUrl = ''; + let msgSignOff = ''; + let msgFooterNote = ''; + + let bodyEditor: HTMLDivElement | undefined; + + function syncBodyToEditor() { + if (bodyEditor && bodyEditor.innerHTML !== msgBody) { + bodyEditor.innerHTML = msgBody; + } + } + + function handleBodyInput() { + if (!bodyEditor) return; + msgBody = bodyEditor.innerHTML; + schedulePreview(); + } + + function exec(cmd: string, value?: string) { + if (!bodyEditor) return; + bodyEditor.focus(); + document.execCommand(cmd, false, value); + handleBodyInput(); + } + + function execLink() { + const url = window.prompt('Link URL', 'https://'); + if (!url) return; + exec('createLink', url); + } + + function openPreviewInNewTab() { + if (!msgPreviewHtml) return; + const blob = new Blob([msgPreviewHtml], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener,noreferrer'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } + let msgPreviewHtml = ''; + let msgPreviewBusy = false; + let msgRecipientMode: 'all' | 'pending' | 'completed' | 'custom' = 'pending'; + let msgCustomRecipients = new Set<string>(); + let msgSendError = ''; + + async function fetchMessageTemplates() { + const token = getToken(); + if (!token) return; + templatesError = ''; + try { + const res = await fetch('/api/owner/message-templates', { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error(data?.detail?.message ?? data?.detail ?? 'Could not load templates.'); + messageTemplates = Array.isArray(data?.templates) ? data.templates : []; + messageFonts = Array.isArray(data?.fonts) ? data.fonts : []; + ensureFontsLoaded(); + if (messageTemplates.length && !selectedTemplateId) { + selectTemplate(messageTemplates[0].id); + } + } catch (error) { + templatesError = error instanceof Error ? error.message : 'Could not load templates.'; + } + } + + const loadedFontHrefs = new Set<string>(); + function ensureFontsLoaded() { + if (typeof document === 'undefined') return; + for (const font of messageFonts) { + if (!font.link || loadedFontHrefs.has(font.link)) continue; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = font.link; + document.head.appendChild(link); + loadedFontHrefs.add(font.link); + } + } + + $: selectedFontStack = (() => { + const f = messageFonts.find((x) => x.id === selectedFontId); + return f?.stack ?? "-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"; + })(); + + $: if (bodyEditor && msgBody !== undefined && bodyEditor.innerHTML !== msgBody) { + bodyEditor.innerHTML = msgBody; + } + + function plainToHtml(text: string): string { + if (!text) return ''; + return text + .split(/\n\n+/) + .map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`) + .join(''); + } + + function selectTemplate(id: string) { + const tmpl = messageTemplates.find((t) => t.id === id); + if (!tmpl) return; + const previousId = selectedTemplateId; + selectedTemplateId = id; + const prev = messageTemplates.find((t) => t.id === previousId); + const matchesPrev = (val: string, def: string) => !val.trim() || (prev && val.trim() === def.trim()); + const defaultBodyHtml = plainToHtml(tmpl.defaultBody); + const prevBodyHtml = prev ? plainToHtml(prev.defaultBody) : ''; + if (!previousId) { + msgSubject = tmpl.defaultSubject; + msgHeading = tmpl.defaultHeading; + msgSubHeading = tmpl.defaultSubHeading; + msgBody = defaultBodyHtml; + msgHighlight = tmpl.defaultHighlight; + msgSignOff = tmpl.defaultSignOff; + msgFooterNote = tmpl.defaultFooterNote; + } else { + if (matchesPrev(msgSubject, prev?.defaultSubject ?? '')) msgSubject = tmpl.defaultSubject; + if (matchesPrev(msgHeading, prev?.defaultHeading ?? '')) msgHeading = tmpl.defaultHeading; + if (matchesPrev(msgSubHeading, prev?.defaultSubHeading ?? '')) msgSubHeading = tmpl.defaultSubHeading; + if (!msgBody.trim() || msgBody.trim() === prevBodyHtml.trim()) msgBody = defaultBodyHtml; + if (matchesPrev(msgHighlight, prev?.defaultHighlight ?? '')) msgHighlight = tmpl.defaultHighlight; + if (matchesPrev(msgSignOff, prev?.defaultSignOff ?? '')) msgSignOff = tmpl.defaultSignOff; + if (matchesPrev(msgFooterNote, prev?.defaultFooterNote ?? '')) msgFooterNote = tmpl.defaultFooterNote; + } + syncBodyToEditor(); + void refreshPreview(); + } + + let previewTimer: ReturnType<typeof setTimeout> | null = null; + function schedulePreview() { + if (previewTimer) clearTimeout(previewTimer); + previewTimer = setTimeout(() => void refreshPreview(), 400); + } + + async function refreshPreview() { + if (!selectedTemplateId) return; + const token = getToken(); + if (!token) return; + msgPreviewBusy = true; + try { + const res = await fetch('/api/owner/render-message', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + templateId: selectedTemplateId, + heading: msgHeading, + subHeading: msgSubHeading, + body: msgBody, + highlightText: msgHighlight, + ctaLabel: msgCtaLabel, + ctaUrl: msgCtaUrl, + signOff: msgSignOff, + footerNote: msgFooterNote, + fontId: selectedFontId, + }), + }); + const data = await res.json().catch(() => null); + if (res.ok && data?.html) msgPreviewHtml = data.html; + } finally { + msgPreviewBusy = false; + } + } + + $: resolvedRecipients = (() => { + if (msgRecipientMode === 'all') return allClients.map((c) => c.email); + if (msgRecipientMode === 'pending') return allClients.filter((c) => c.status === 'pending').map((c) => c.email); + if (msgRecipientMode === 'completed') return allClients.filter((c) => c.status === 'completed').map((c) => c.email); + return Array.from(msgCustomRecipients); + })(); + + function toggleCustomRecipient(email: string) { + const next = new Set(msgCustomRecipients); + if (next.has(email)) next.delete(email); else next.add(email); + msgCustomRecipients = next; + } + + function requestSendMessage() { + if (!msgSubject.trim()) { + msgSendError = 'Please enter a subject.'; + return; + } + if (!resolvedRecipients.length) { + msgSendError = 'Please choose at least one recipient.'; + return; + } + msgSendError = ''; + confirmCtx = { kind: 'message', recipientCount: resolvedRecipients.length }; + confirmPreview = false; + confirmError = ''; + } + + let loggingOut = false; + async function topbarLogout() { + loggingOut = true; + try { + const token = getToken(); + if (token) { + await fetch('/api/auth/logout', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => {}); + } + } finally { + try { window.localStorage.removeItem('gw_onboarding_session'); } catch { /* ignore */ } + loggingOut = false; + handleLogout(); + } + } + + $: pendingCount = pendingClients.length; + $: completedCount = allClients.filter((client) => client.status === 'completed').length; + $: birthdaysUpcomingCount = birthdayClients.length; + + const tabHeaders = { + home: { + title: 'Onboarding', + description: 'Clients who have enquired but have not yet finished onboarding.', + }, + clients: { + title: 'All Clients', + description: 'Everyone currently on file, with their onboarding status.', + }, + birthdays: { + title: 'Birthdays', + description: 'Manual sends, automatic toggles, and birthday invite downloads.', + }, + messaging: { + title: 'Messaging', + description: 'Send themed bulk or individual emails to clients via BCC.', + }, + } as const; + + $: header = tabHeaders[activeTab]; + + onMount(async () => { + await checkAuth(); + }); +</script> + +<svelte:head> + <title>{header.title} | Goodwalk Admin + + + +
+
+
+ + + {#if isAuthenticated && !accessDenied} + + {/if} + +
+ Dashboard + {#if isAuthenticated} + + {/if} +
+
+
+ + {#if authChecking} +
+ {:else if !isAuthenticated} + + {:else} +
+
+ {#if accessDenied} +
+

Owner access only.

+

This screen is restricted to {ownerEmail}.

+
+ {:else} + {#if activeTab === 'home'} +
+
+ +
+ {pendingCount} + Pending +
+
+
+ +
+ {completedCount} + Completed +
+
+
+ +
+ {birthdaysUpcomingCount} + Birthdays +
+
+
+ {/if} + + {#if activeTab === 'home'} + {#if homeError} +
+

Could not load home.

+

{homeError}

+
+ {:else if loadingHome} +
+

Loading…

+
+ {:else if !pendingClients.length} +
+

No one is waiting on onboarding.

+

There are no pending clients right now.

+
+ {:else} +
+ + + {#each pendingClients as client} +
+
+ Client +
+

{client.fullName || client.email}

+

{client.dogName || 'No dog name saved'}{#if client.dogBreed} · {client.dogBreed}{/if}

+
+
+ +
+ Contact +
+
+ {client.email} + {#if client.phone}{client.phone}{/if} +
+ {#if client.lastEnquiryAt}Last enquiry {formatDateLabel(client.lastEnquiryAt)}{/if} +
+
+ +
+ Status +
+ + {client.welcomePackSentAt ? 'Welcome sent' : 'Needs welcome'} + +
+
+ +
+ Action +
+ + +
+
+ + {#if activeEmail === client.email} +
+ + + + + + + {#if sendError} +
{sendError}
+ {/if} + +
+ + +
+
+ {/if} +
+ {/each} +
+ {/if} + {:else if activeTab === 'clients'} +
+
+ Page {allClientsPage} of {allClientsTotalPages} +
+
+ + {#if allClientsError} +
+

Could not load clients.

+

{allClientsError}

+
+ {:else if loadingAllClients} +
+

Loading…

+
+ {:else if !allClients.length} +
+

No clients found.

+
+ {:else} +
+ + + {#each allClients as client} +
+
+ Client +
+

{client.fullName || client.email}

+

{client.dogName || 'No dog name saved'}{#if client.dogBreed} · {client.dogBreed}{/if}

+
+ {client.email} + {#if client.phone}{client.phone}{/if} +
+
+
+ +
+ Status + + {client.status === 'completed' ? 'Completed' : 'Pending'} + +
+ +
+ Last Activity +
+ {formatDateLabel(client.lastActivityAt) || 'Unknown'} +
+
+ +
+ Action + +
+
+ {/each} +
+ +
+ +
{allClientsPage} / {allClientsTotalPages}
+ +
+ {/if} + {:else if activeTab === 'birthdays'} +
+
+ Page {birthdaysPage} of {birthdaysTotalPages} +
+
+ + {#if birthdayActionError} +
{birthdayActionError}
+ {/if} + + {#if birthdaysError} +
+

Could not load birthdays.

+

{birthdaysError}

+
+ {:else if loadingBirthdays} +
+

Loading…

+
+ {:else if !birthdayClients.length} +
+

No birthdays available.

+

Completed onboarding clients with valid dog birth dates will appear here.

+
+ {:else} +
+ + + {#each birthdayClients as client} +
+
+ Dog +
+

{client.dogName || 'Unknown dog'}

+

{client.fullName || client.email}{#if client.dogBreed} · {client.dogBreed}{/if}

+
+
+ +
+ Birthday +
+ {formatBirthday(client.dogAge)} + {birthdayRelativeLabel(client.daysUntil)} + {#if client.birthdayEmailLastSentAt} + Last sent {formatDateLabel(client.birthdayEmailLastSentAt)} + {/if} +
+
+ +
+ Auto Send + +
+ +
+ Actions + + +
+
+ {/each} +
+ +
+ +
{birthdaysPage} / {birthdaysTotalPages}
+ +
+ {/if} + {:else if activeTab === 'messaging'} + {#if templatesError} +
+

Could not load templates.

+

{templatesError}

+
+ {:else if !messageTemplates.length} +
+

Loading templates…

+
+ {:else} +
+
+
+ + Template + + +
+
+ {#each messageTemplates as tmpl} + + {/each} +
+
+
+ + {#if messageFonts.length} +
+ + Body font + + +
+
+ {#each messageFonts as font} + + {/each} +
+
+
+ {/if} + +
+ + Content + + +
+ + + +
+ Body +
+
+ + + + + + + + + +
+
+
+
+ +
+ + +
+
+ + +
+
+
+ +
+ + Recipients ({resolvedRecipients.length}) + + +
+
+ + + + +
+ {#if msgRecipientMode === 'custom'} +
+ {#each allClients as client} + + {/each} +
+ {/if} +
+
+ + {#if msgSendError} +
{msgSendError}
+ {/if} + +
+ + +
+
+ + +
+ {/if} + {/if} + + + {/if} +
+
+ + {#if confirmCtx} + + {/if} + + {#if enquiryOpen} + + {/if} + + {/if} +
+ + diff --git a/src/lib/components/contact-form-variants/VariantApplicationFields.svelte b/src/lib/components/contact-form-variants/VariantApplicationFields.svelte index 9479707..d19f35f 100644 --- a/src/lib/components/contact-form-variants/VariantApplicationFields.svelte +++ b/src/lib/components/contact-form-variants/VariantApplicationFields.svelte @@ -109,7 +109,7 @@