This commit is contained in:
2026-05-26 08:30:08 +12:00
parent 005aab8139
commit 135a5a3b83
75 changed files with 22417 additions and 4288 deletions
+34
View File
@@ -0,0 +1,34 @@
# Goodwalk docs
Reference, audit, and planning documents for the Goodwalk site. Project-level rules live in [`/CLAUDE.md`](../CLAUDE.md); everything else lives here.
## Product & brand
- [product.md](product.md) — users, purpose, brand personality, design principles
- [marketing-principles.md](marketing-principles.md) — Chris Do / Debbie Millman principles, applied to the rebuild
- [marketing-voice.md](marketing-voice.md) — copy/voice style guide (calm, certain, warm, specific)
## Design
- [design-language.md](design-language.md) — canonical visual system: colour, typography, spacing, motion, surface, photography direction
- [design-audit.md](design-audit.md) — codebase-wide design drift audit + token/refactor changelog
- [homepage-flow-audit.md](homepage-flow-audit.md) — section-by-section IA review of the homepage
- [mobile-polish.md](mobile-polish.md) — mobile conversion & comfort tracker
- [ux-polish.md](ux-polish.md) — conversion audit tracker
## SEO & growth
- [competitors.md](competitors.md) — Fetch / Sticks & Bones analysis + what to borrow
- [comparison-page-plan.md](comparison-page-plan.md) — brief for the dog-walker-vs-daycare-vs-sitter page
- [nz-citations.md](nz-citations.md) — canonical NAP block + directory submission list
## Ops
- [deployment.md](deployment.md) — production deploy flow, server layout, nginx cutover
- [webp-conversion.md](webp-conversion.md) — one-time WebP setup for hero images
- [onboarding.md](onboarding.md) — client onboarding flow, lifecycle status, legacy CSV migration, Postgres target schema
## Archive
- [archive/v3-design-audit.md](archive/v3-design-audit.md) — superseded by [design-audit.md](design-audit.md)
- [archive/marketing-voice-v2-proposal.md](archive/marketing-voice-v2-proposal.md) — one-shot copy-change proposal against [marketing-voice.md](marketing-voice.md)
+89
View File
@@ -0,0 +1,89 @@
# Competitor analysis — Auckland dog walking
A look at what two of our closest competitors do well, and what's worth borrowing for Goodwalk. Captured 2026-05-20.
---
## Fetch Dog Walking — https://fetchdogwalking.co.nz
### What they do well
- **Founder-led narrative.** Andy Evans is named on the site and the philosophy ("freedom to explore in nature is an essential investment in our dog's health") is front and centre. Feels like a person, not a faceless service.
- **Content depth.** They go beyond "book a walk" and publish:
- Auckland **Dog Parks** guide
- Basic **Training** tips
- **FAQs** page
- "Our Dogs" and "Our People" pages
- **"Dog's Highlight of the Week"** — a recurring content hook that gives existing clients a reason to keep visiting the site and gives new visitors social proof.
- **Diversified offering.** The 100-acre Waitakere Ranges farm-stay / boarding service is a real differentiator — they're not just selling walks, they're selling a destination.
- **Positions on authority.** Tagline "Auckland's Leading Dog Walking Company" is bold and clear.
- **Strong primary CTA.** Homepage contact form is short (company, dog name, phone, email, suburb, message) — low friction.
### What we could implement
| Idea | Effort | Why |
|---|---|---|
| **Founder bio / "About Matt" page** with photo and philosophy | S | Personality is a moat. Easy SEO win for branded searches. |
| **"Dog of the Week" / Instagram-style spotlight on the homepage** | M | Gives the site an "alive" feel; reuses content we likely already post to social. Could pull from the existing client_profiles table. |
| **Dog parks of Auckland** content page | M | Pure SEO play — high-intent local search ("best dog parks Auckland"). Drives top-of-funnel traffic. |
| **Training tips / FAQ content hub** | M | Same SEO logic. Also reduces support load. |
| **"Our pack" page showing all dogs we walk** (with owner consent) | S | Trust + community. We already collect dog photos in onboarding. |
---
## Sticks & Bones — https://sticksandbones.co.nz
### What they do well
- **Process is the product.** They sell their *enrollment process* as a trust signal:
1. Free Meet & Greet at home
2. **Minimum two Assessment Walks** before enrollment
3. Handler matching
4. App-based booking
This makes the service feel professional and screened — they're not taking everyone.
- **Real-time walk reports via app.** Clients get photos and a report after every walk. This is the single biggest differentiator and the most-mentioned feature in their testimonials.
- **Concrete trust signals.**
- Police-checked handlers
- Secure key handling process documented
- Handlers named individually ("Amanda walks our Cooper")
- Maximum 4 dogs per Urban Adventure Walk (explicit cap = safety)
- **Personalised client app** for scheduling, history, and reports — clients self-serve.
- **In-home dog sitting**, but only for enrolled walking clients (creates retention + upsell).
- **Tagline is benefit-led**, not feature-led: "dog walking made easy" + "active dogs behave better."
### What we could implement
| Idea | Effort | Why |
|---|---|---|
| **Post-walk reports** (photo + short note delivered by email or in the client portal) | L | This is the killer feature. Our admin dashboard already touches client_profiles — extending to walk records is a natural next step. Single biggest perceived-value lift available. |
| **Document our enrollment process** as a 3-step visual on the homepage (Enquire → Meet & Greet → First Walk) | S | We already do a Meet & Greet — we're just not selling it. Pure copy/design change. |
| **Cap group size publicly** (e.g. "max 4 dogs per pack") | S | Free trust signal if it matches reality. |
| **Name the team** with photos on About page | S | Sticks & Bones gets specific mentions of handlers by name in reviews — that only happens when you make handlers visible. |
| **Police-check / insurance / first-aid badges** in the footer or About page | S | Cheap credibility. |
| **Client portal extensions** — walk history, upcoming bookings, ability to reschedule | L | We already have auth + onboarding + admin dashboard. The data model is most of the way there. |
| **"Enrolled clients only" services** as a retention hook (e.g. boarding, sitting) | M | Mirrors S&B's sitting model. Existing clients get priority — gives a reason to stay engaged after onboarding. |
---
## Cross-cutting themes
Both competitors do these things; we should too:
1. **Named humans on the site.** Founder, team, dogs. The word "we" without faces is worth less.
2. **Process as proof.** Both make their intake process visible — it doubles as marketing.
3. **Service ladder, not a service.** Walks → assessments → boarding/sitting → premium options. Gives clients somewhere to grow into.
4. **Locations as content.** Specific Auckland park / suburb mentions throughout — local SEO and "they know my area" trust.
## What Goodwalk has that they don't (lean into this)
- **Multi-step onboarding form** with signature, vet, emergency contacts, behavioural profile — already more thorough than what's visible on either competitor.
- **Database-backed client profiles** — foundation for a portal richer than S&B's (their app is the obvious next ceiling; we have data they don't seem to capture).
- **Brand-coloured design system** already in place (Goodwalk Green / Yellow) — distinct visual identity vs. the generic blue/green dog-walker templates.
## Suggested priority order
1. **Founder/team page + named handlers** (1 day, big trust lift)
2. **Visualise the enrollment process on the homepage** (1 day, sells the Meet & Greet we already do)
3. **Dog Parks of Auckland content page** (2-3 days, SEO compounding)
4. **Post-walk reports** in the client portal (1-2 weeks, table-stakes vs. S&B)
5. **Walk history / portal extensions** (2-3 weeks, retention moat)
+252
View File
@@ -0,0 +1,252 @@
# Onboarding
How a Goodwalk client comes into the system, how their record is shaped, and how
that record evolves over time.
## Flow at a glance
1. **Enquiry / email claim** — the user signs in to `clients.goodwalk.co.nz`
with a one-time code. A `client_profiles` entry is created keyed by the
lower-cased email.
2. **Onboarding form** (`src/lib/components/OnboardingPage.svelte`) — a 5-step
form: Owner Details → Dog Details → Health → Behaviour → Sign. Every
question must be answered; there is no skip / mark-complete shortcut.
3. **Submit** — the form posts to `POST /api/onboarding-submit`. The backend
validates, stores a `submissionSnapshot` on the profile, sends the owner an
email + PDF, and flips `onboardingCompleted: true`.
4. **Admin dashboard** (`src/lib/components/admin-dashboard/AdminDashboard.svelte`)
surfaces pending and completed clients, and now lets the owner change a
client's lifecycle status.
## Where data lives today
| Thing | Storage | Source of truth |
| --- | --- | --- |
| Client profiles | `mail-api/data/client_profiles.json` (mirrored into `admin_db` KV) | `_client_profiles` dict in `main.py` |
| Onboarding drafts (in-progress, per email) | `mail-api/data/drafts.json` | `_drafts` dict |
| Submitted onboarding snapshot | Inside the client profile as `onboardingSubmission` / `submissionSnapshot` | same |
| Activity feed (admin actions) | SQLite via `admin_db.record_event` | same |
| Legacy historical data (Gravity Forms exports) | `data/legacy-onboarding.json`, `data/legacy-contracts.json`, `data/legacy-clients.json` | regenerated from the CSVs in repo root |
The plan is to move the JSON file to Postgres later; the JSON shape is already
relational-friendly (see *Postgres target* below).
## Client profile shape
Keyed by lower-cased email. Fields are merged over time as the client moves
through the funnel.
```jsonc
{
"fullName": "Shelley McCabe",
"phone": "+6421328907",
"address": "...",
"dogName": "Maisie",
"dogBreed": "...",
"dogAge": "2015-04-28",
"services": ["Tiny Gang Pack Walks"],
"onboardingCompleted": true,
"onboardingSubmittedAt": "2026-03-14T16:14:20",
"onboardingSubmission": { /* full 5-step snapshot */ },
"welcomePackSentAt": "...",
"welcomePackOffer": { "serviceType": "...", "priceDetails": "...", "startDate": "..." },
"birthdayAutoSend": false,
"birthdayEmailLastSentAt": "...",
"lastEnquiryAt": "...",
"lifecycle": {
"status": "active", // active | paused | cancelled | archived
"reason": "",
"changedAt": "2026-05-20T10:11:00",
"changedBy": "info@goodwalk.co.nz"
},
"lifecycleHistory": [
{ "status": "active", "reason": "", "changedAt": "...", "changedBy": "..." }
]
}
```
## Lifecycle status
Soft-delete only. **No client is ever removed**, so we keep the full history
for retention / newsletter work.
| Status | Meaning | Outreach included? |
| --- | --- | --- |
| `active` | Currently walking with us. | Yes — pending + completed + birthdays |
| `paused` | Temporarily not walking (holiday, recovery, etc.). | Yes — still on outreach lists |
| `cancelled` | No longer a client. Kept on file. | **No** — excluded from outreach |
| `archived` | Long-term inactive. Hidden from default views. | **No** — excluded from outreach |
Defaults: every client starts as `active`. A missing `lifecycle` block is
treated as `active` by the API and the dashboard.
### Filters that respect lifecycle
`_client_is_reachable(profile)` in `mail-api/main.py` is the single gate.
`cancelled` and `archived` are filtered out of:
- `GET /owner/pending-onboarding`
- `GET /owner/completed-onboarding`
- `GET /owner/birthdays`
`GET /owner/all-clients` returns **everyone**, regardless of status, so the
admin can always find a former client. The status pill on each row makes the
state obvious.
### Changing a client's status
Admin dashboard → Clients tab → row action **"Change status"**. Opens a modal
with the four status options and an optional 500-char reason. Saving calls:
```
POST /api/owner/client-status
Authorization: Bearer <admin session token>
Content-Type: application/json
{
"email": "owner@example.com",
"status": "paused",
"reason": "Moving house, back in June"
}
```
The backend writes the new `lifecycle` block, appends to `lifecycleHistory`
(capped at the last 50 entries), and records an
`owner_client_status_changed` event in the activity feed.
## Legacy data migration
Two CSV exports from the old Gravity Forms install:
- `dog-enrolment-form-2026-05-20.csv` — 72 onboarding submissions (20222026).
- `goodwalk-contract-2026-05-20.csv` — 39 contract signings (2022-06 → 2024-02).
Two cleansing scripts produce relational-friendly JSON:
```
node scripts/clean-legacy-onboarding.mjs # → data/legacy-onboarding.json
node scripts/clean-legacy-contracts.mjs # → data/legacy-contracts.json
# also enriches legacy-onboarding.json
# and writes data/legacy-clients.json
```
### Importing legacy clients into production
The deploy pipeline ships these 36 clients into the live system on every
deploy. The flow:
1. `deploy.ps1` runs `scripts/build-legacy-seed.mjs`, which folds the three
legacy JSON files into one email-keyed dict shaped like the live
`_client_profiles` entries. Output: `mail-api/legacy-clients-seed.json`.
2. The mail-api `Dockerfile` `COPY`s that file to `/app/legacy-clients-seed.json`
so it lives **outside** the persistent `mail_api_data` volume — every
deploy carries a fresh copy.
3. On boot, `_merge_legacy_seed_if_present()` adds every email that is not
already in `_client_profiles`, persists the result to JSON + admin_kv,
and never overwrites a live entry. Re-running is idempotent.
Each imported client is created with `lifecycle: { status: 'archived' }` so
they don't pollute outreach lists. The admin can promote any of them back to
`active` from the dashboard. Provenance (entry IDs, signature URL, PDF URLs)
is preserved on the `legacy` field of every profile.
To skip the rebuild on a hotfix deploy use `deploy.ps1 -SkipLegacySeed`; the
previous seed file is reused as-is. To regenerate locally:
```
node scripts/build-legacy-seed.mjs
```
The contracts script backfills owner email + postal address onto onboarding
records where a contract row matches (by owner name, then dog name, then a
unique surname). At present: **38 of 72** onboarding rows now have an email
attached; the rest were submitted after the contract form was retired in
2024-02 and will need an email-claim flow before they can sign in.
### Known gaps in the legacy data
- 34 onboarding rows have no email (mostly post-2024-02).
- Dog **breed** was never collected by the old forms — `null` everywhere.
- Owner **address** was only collected on the contract form, not on
onboarding directly.
- One vet phone number (`6307168`) couldn't be normalised to E.164 — kept
as `phoneRaw` only.
## Postgres target
The JSON shapes already map cleanly onto a relational schema. Suggested tables
once we migrate off the JSON file:
```sql
clients (
email TEXT PRIMARY KEY,
first_name, last_name, phone, phone_raw,
address, address_street, address_line2, address_suburb,
address_city, address_postal_code, address_country,
-- lifecycle
lifecycle_status TEXT NOT NULL DEFAULT 'active', -- enum: active|paused|cancelled|archived
lifecycle_reason TEXT,
lifecycle_changed_at TIMESTAMPTZ,
lifecycle_changed_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
client_status_history (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
status TEXT NOT NULL,
reason TEXT,
changed_at TIMESTAMPTZ NOT NULL,
changed_by TEXT NOT NULL
);
dogs (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
name TEXT, surname TEXT, date_of_birth DATE, breed TEXT
);
onboarding_submissions (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
dog_id INT REFERENCES dogs(id),
legacy_entry_id TEXT,
legacy_pdf_url TEXT,
signature_url TEXT,
vet_name TEXT, vet_address TEXT, vet_phone TEXT, vet_phone_raw TEXT,
emergency_contact_name TEXT, emergency_contact_phone TEXT,
health JSONB, -- vaccinated / allergies / diet / medication
behaviour JSONB, -- recall / reactivity / car / commands
misc JSONB, -- additional notes / referrals / socials
signed_on DATE,
submitted_at TIMESTAMPTZ
);
contracts (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
legacy_entry_id TEXT,
signature_url TEXT,
pdf_url TEXT,
consent_text TEXT,
signed_on DATE
);
```
`health` / `behaviour` / `misc` stay as `JSONB` rather than exploding every
nullable yes/no into a column — it matches the existing
`submissionSnapshot: dict[str, Any]` shape in
`mail-api/mail_api/models.py` and makes the form easier to evolve.
## Things that intentionally don't exist yet
- **Hard delete** of a client. Use `archived` instead. Hard delete would also
break the activity feed and the legacy import provenance.
- **A "former client" notes log.** If we need anything richer than a 500-char
reason, the next step is to add a `client_notes` table keyed by email — but
hold off until the JSON → Postgres migration is done.
- **Re-engagement / win-back emails.** The data is there (lifecycle history +
email), so this is unblocked whenever marketing wants to send a newsletter
to past clients. `cancelled` and `archived` rows are still in
`/owner/all-clients`.