Compare commits
6
Commits
135a5a3b83
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018024510b | ||
|
|
8eb20813c8 | ||
|
|
bfd96c677f | ||
|
|
0e88e7bdac | ||
|
|
7d5b72e1d3 | ||
|
|
91b22c6d60 |
+160
@@ -0,0 +1,160 @@
|
|||||||
|
# GEO Analysis — Goodwalk (goodwalk.co.nz)
|
||||||
|
|
||||||
|
_Generative Engine Optimization audit · February 2026 criteria · analyzed 2026-06-23_
|
||||||
|
|
||||||
|
Scope: SvelteKit codebase at `C:\Users\mattc\gw-svelte\gw-svelte` (SSR via `@sveltejs/adapter-node`). This is a single-location Auckland dog-walking service — a local SAB (service-area business), so GEO leverage skews toward local AI answers ("dog walker in Mt Eden", "Auckland pack walks", "puppy visit cost Auckland").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. GEO Readiness Score: 73 / 100
|
||||||
|
|
||||||
|
A strong technical foundation already in place. The on-site work is close to best-practice; the gap is **off-site brand presence** and **passage-level citability** of the marketing copy.
|
||||||
|
|
||||||
|
| Criterion | Weight | Score | Notes |
|
||||||
|
|-----------|--------|-------|-------|
|
||||||
|
| Citability | 25% | 16/25 | Excellent `llms.txt` facts; on-page copy is punchy brand fragments, not extractable answer blocks |
|
||||||
|
| Structural Readability | 20% | 15/20 | Clean H1→H2, FAQ `<details>`; few tables/comparison blocks |
|
||||||
|
| Multi-Modal | 15% | 9/15 | Rich images + alt text everywhere; no video, infographics, or tools |
|
||||||
|
| Authority & Brand | 20% | 14/20 | Strong on-site E-E-A-T (Person/founder schema, reviews); thin off-site (no Wikipedia/Reddit/YouTube) |
|
||||||
|
| Technical Accessibility | 20% | 19/20 | SSR, AI crawlers explicitly allowed, `llms.txt` present — near perfect |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Platform Breakdown
|
||||||
|
|
||||||
|
| Platform | Est. Score | Why |
|
||||||
|
|----------|-----------|-----|
|
||||||
|
| **Google AI Overviews** | 80 | Best-aligned — SSR + rich LocalBusiness/Person/FAQ schema + local entity signals. Citations come from top-ranking pages, so this rides your existing SEO. |
|
||||||
|
| **Bing Copilot** | 68 | Schema and SSR help; needs Bing index coverage + IndexNow. |
|
||||||
|
| **ChatGPT (web search)** | 61 | Leans on Wikipedia (47.9%) and Reddit (11.3%). No entity presence on either → low. |
|
||||||
|
| **Perplexity** | 57 | Reddit-driven (46.7%). No community validation present → weakest channel. |
|
||||||
|
|
||||||
|
**Implication:** you are well-positioned for Google AIO and should focus net-new effort on the off-site signals that feed ChatGPT/Perplexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AI Crawler Access Status ✅
|
||||||
|
|
||||||
|
`src/routes/robots.txt/+server.ts` explicitly allows the key search crawlers — this is correctly done:
|
||||||
|
|
||||||
|
| Crawler | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| GPTBot (OpenAI) | ✅ Allowed |
|
||||||
|
| OAI-SearchBot (OpenAI) | ✅ Allowed |
|
||||||
|
| ClaudeBot (Anthropic) | ✅ Allowed |
|
||||||
|
| PerplexityBot | ✅ Allowed |
|
||||||
|
| Bingbot / others (`*`) | ✅ Allowed (with sensible `/api/`, `/variants/`, `/contract` disallows) |
|
||||||
|
|
||||||
|
No action required. One optional addition: `ChatGPT-User` (live browsing fetches) is not named but is covered by `User-agent: *`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. llms.txt Status ✅
|
||||||
|
|
||||||
|
Present at `static/llms.txt` and well-structured — better than most sites. It already includes founder, contact, hours, pricing summary, service URLs, suburb list, and location-page index.
|
||||||
|
|
||||||
|
Minor improvements:
|
||||||
|
- **Update `Last updated`** (currently `2026-05-18`) on each meaningful content change.
|
||||||
|
- Consider adding a one-line **"What is Goodwalk"** definitional sentence at the very top under `> ` (the quotable blockquote) — AI models lift this verbatim.
|
||||||
|
- Add the **Meet & Greet is free** fact and **free pickup/drop-off** as explicit bullet facts (they're a differentiator AI answers love to surface).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Brand Mention Analysis — biggest opportunity
|
||||||
|
|
||||||
|
Per Ahrefs (Dec 2025), **brand mentions correlate ~3× more strongly with AI visibility than backlinks**. This is your largest lever and it is entirely off-site.
|
||||||
|
|
||||||
|
| Platform | Presence | Priority |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Instagram | ✅ `@goodwalk.nz` (linked, in `sameAs`) | Maintained |
|
||||||
|
| Facebook | ✅ Linked in footer | Maintained |
|
||||||
|
| Google Business Profile | ✅ `g.page` link + 30× 5-star reviews in schema | Strong |
|
||||||
|
| **Reddit** | ❌ None | **High** — drives Perplexity (46.7%) + ChatGPT (11.3%) |
|
||||||
|
| **YouTube** | ❌ None | **High** — strongest single citation correlation (~0.737) |
|
||||||
|
| **Wikipedia/Wikidata** | ❌ None | Medium (hard for a small local business; skip unless notable) |
|
||||||
|
| LinkedIn (Alessandra) | ❌ Not in Person `sameAs` | Medium — easy entity win |
|
||||||
|
|
||||||
|
**Realistic actions for a local business:**
|
||||||
|
1. Get genuine mentions in Auckland subreddits (r/auckland, r/newzealand) — answer dog-care questions helpfully; don't spam.
|
||||||
|
2. Post a few short YouTube clips (pack walk footage, "what is a Meet & Greet") — even low view counts create the entity association.
|
||||||
|
3. List on NZ pet directories and local-business aggregators (consistent NAP).
|
||||||
|
4. Add Alessandra's LinkedIn (if any) to the `Person` schema `sameAs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Passage-Level Citability
|
||||||
|
|
||||||
|
Optimal AI-citation block length is **134–167 words, self-contained**. Your on-page copy is deliberately fragmented brand voice — great for humans, weak for extraction:
|
||||||
|
|
||||||
|
> "Built for dogs who do better one-on-one. Their pace. Their walk. Same walker every time." _(dog-walking.ts)_
|
||||||
|
|
||||||
|
These single-line fragments rarely get quoted because they don't answer a question standalone. **Recommendation: keep the brand hero copy, but add one extractable answer block per service page**, e.g.:
|
||||||
|
|
||||||
|
> **What is a solo dog walk?** A solo dog walk is a one-on-one outing where a single dog is walked by the same walker every time, at the dog's own pace. Goodwalk offers solo walks across Auckland Central for dogs who do better without a group — reactive dogs, seniors, post-surgery recovery, and anxious rescues. Walks run 30 minutes (seniors/lower energy), 45 minutes (most dogs), or 60 minutes (longer outings), with free door-to-door pickup and drop-off and a photo update after every walk. Solo walks start from $45 per walk. _(≈70 words — expand toward ~140 with a sentence on when to choose solo vs pack.)_
|
||||||
|
|
||||||
|
Your **`llms.txt` already does this well** — the "Key facts" and "Pricing summary" blocks are exactly the quotable format. Mirror that density into visible page HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Server-Side Rendering Check ✅
|
||||||
|
|
||||||
|
- SSR is **on** (adapter-node, no `export const ssr = false` anywhere). AI crawlers, which do not execute JS, will see full content.
|
||||||
|
- All structured data is emitted server-side in `<svelte:head>` (SeoHead.svelte, FaqSection.svelte) — good.
|
||||||
|
- ⚠️ One caveat: the `<noscript>` overlay says "Please enable JavaScript to use this site." The rendered HTML is fine for crawlers, but make sure the **primary content is in the SSR payload and not gated behind hydration** — the booking/contact form needing JS is fine; the service descriptions and FAQs must be in source HTML (they are, via the content modules — confirmed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Top 5 Highest-Impact Changes
|
||||||
|
|
||||||
|
1. **Build off-site brand mentions (Reddit + YouTube + NZ directories).** Highest correlation with AI citation; the single biggest unlock for ChatGPT/Perplexity. _(High effort, high impact)_
|
||||||
|
2. **Add a 134–167 word "What is [service]?" answer block** to each service page (pack-walks, dog-walking, puppy-visits) and an "How much does dog walking cost in Auckland?" block to `/our-pricing`. _(Medium effort, high impact)_
|
||||||
|
3. **Add price + priceCurrency to the `Offer` nodes** in the homepage `hasOfferCatalog` (currently name/url only). Lets AI quote exact prices with confidence. _(Low effort, medium impact)_
|
||||||
|
4. **Create an Auckland dog-care FAQ / short guide page** to capture informational AI queries ("how often should I walk my dog", "pack walk vs solo walk"). You have zero informational content today — purely commercial. _(Medium effort, high impact)_
|
||||||
|
5. **Add `sameAs` to the Person (Alessandra) schema** and a visible author/founder byline with credentials (pet first-aid cert, insured) near service content. Strengthens E-E-A-T entity. _(Low effort, medium impact)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Schema Recommendations
|
||||||
|
|
||||||
|
Already excellent: `WebSite`, `LocalBusiness`+`PetCareService` (with `aggregateRating`, `review`, `openingHoursSpecification`, `areaServed`, `hasOfferCatalog`), `Person` (founder, `knowsAbout`), `FAQPage`, `BreadcrumbList`, `Service` (location pages). Gaps to close:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// In hasOfferCatalog → each Offer, add price:
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"itemOffered": { "@type": "Service", "name": "Tiny Gang Pack Walks", "url": "https://www.goodwalk.co.nz/pack-walks" },
|
||||||
|
"price": "49.50",
|
||||||
|
"priceCurrency": "NZD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Person (Alessandra) — add credentials + external entity links:
|
||||||
|
"hasCredential": [
|
||||||
|
{ "@type": "EducationalOccupationalCredential", "credentialCategory": "certificate", "name": "Pet First Aid Certificate" }
|
||||||
|
],
|
||||||
|
"sameAs": ["https://www.linkedin.com/in/..."] // if available
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add **`priceRange`** (e.g. `"$$"`) and a **`geo`** node (`latitude`/`longitude`) to the LocalBusiness — both are standard LocalBusiness AI signals currently absent.
|
||||||
|
- Optionally model each service page as its own `Service` with an `OfferCatalog` so service pages stand alone as cited entities (location pages already do this well).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Content Reformatting Suggestions
|
||||||
|
|
||||||
|
| Page | Current | Suggested GEO addition |
|
||||||
|
|------|---------|------------------------|
|
||||||
|
| `/dog-walking` (solo) | Brand fragments, no definition | Add "What is a solo dog walk?" 140-word block (see §6) |
|
||||||
|
| `/pack-walks` | — | Add "What is a Tiny Gang pack walk?" + a **pack vs solo comparison table** (dogs per group, best for, price) |
|
||||||
|
| `/puppy-visits` | — | Add "What happens in a puppy visit?" + duration/price block |
|
||||||
|
| `/our-pricing` | Pricing copy | Add "How much does a dog walker cost in Auckland?" answer block with a clean price table (service / duration / price NZD) |
|
||||||
|
| New: `/guide` or FAQ hub | none | Informational content to win non-commercial AI queries and earn citations |
|
||||||
|
|
||||||
|
**Tables are under-used.** AI Overviews and Perplexity disproportionately cite comparative tables. A single "Pack vs Solo vs Puppy Visit" comparison table would be high-value and on-brand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bottom line
|
||||||
|
|
||||||
|
Technically this is a **top-decile small-business GEO setup** — SSR, AI crawler allow-list, `llms.txt`, and comprehensive schema are already done and done well. The remaining wins are **content shape** (add extractable answer blocks + tables, prices in schema) and, most of all, **off-site brand presence** (Reddit, YouTube, directories), which the data says matters more than anything on the page itself.
|
||||||
@@ -19,6 +19,13 @@ ENABLE_GENERAL_ENQUIRIES=false
|
|||||||
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
|
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
|
||||||
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false
|
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES=false
|
||||||
|
|
||||||
|
# Server-side GA4 (ad-block-resistant fallback). See docs/server-side-analytics.md.
|
||||||
|
# GA4_MEASUREMENT_ID matches the ID in src/app.html.
|
||||||
|
# GA4_API_SECRET: GA4 admin → Data Streams → web stream → Measurement Protocol API secrets → Create.
|
||||||
|
# Leave blank to disable the forwarder (endpoint still accepts requests but skips the GA4 call).
|
||||||
|
GA4_MEASUREMENT_ID=G-K7TLSFJVP1
|
||||||
|
GA4_API_SECRET=
|
||||||
|
|
||||||
FORM_MIN_SECONDS=4
|
FORM_MIN_SECONDS=4
|
||||||
FORM_MAX_SECONDS=7200
|
FORM_MAX_SECONDS=7200
|
||||||
RATE_LIMIT_WINDOW_SECONDS=900
|
RATE_LIMIT_WINDOW_SECONDS=900
|
||||||
@@ -33,6 +40,17 @@ EMAIL_SEND_TIMEOUT_SECONDS=20
|
|||||||
# Rotate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# Rotate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
DEPLOY_SMOKE_SECRET=ed7261d3d7a5ac0a51e0cfb2bf4e2bd4009503605d2963d3ee766b7e885e76eb
|
DEPLOY_SMOKE_SECRET=ed7261d3d7a5ac0a51e0cfb2bf4e2bd4009503605d2963d3ee766b7e885e76eb
|
||||||
|
|
||||||
|
# MYOB integration — optional. When all of MYOB_API_KEY / MYOB_API_SECRET /
|
||||||
|
# MYOB_REFRESH_TOKEN / MYOB_COMPANY_FILE_URI are set, the "Also create this
|
||||||
|
# client in MYOB" checkbox in the control panel's Add-client modal creates the
|
||||||
|
# client as a customer contact in MYOB AccountRight. Leave blank to disable.
|
||||||
|
# MYOB_API_KEY=
|
||||||
|
# MYOB_API_SECRET=
|
||||||
|
# MYOB_REFRESH_TOKEN=
|
||||||
|
# MYOB_COMPANY_FILE_URI=https://api.myob.com/accountright/<company-file-guid>
|
||||||
|
# MYOB_CF_USERNAME=Administrator
|
||||||
|
# MYOB_CF_PASSWORD=
|
||||||
|
|
||||||
# Security hardening — sensible defaults are in mail_api/config.py.
|
# Security hardening — sensible defaults are in mail_api/config.py.
|
||||||
# Override only if the public domains change or you need to allow extra origins.
|
# Override only if the public domains change or you need to allow extra origins.
|
||||||
# CORS_ALLOWED_ORIGINS=https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz
|
# CORS_ALLOWED_ORIGINS=https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ function Invoke-AllFormSmokeTests {
|
|||||||
dogName = 'Smoke'
|
dogName = 'Smoke'
|
||||||
dogBreed = 'Test'
|
dogBreed = 'Test'
|
||||||
vetName = 'Dr Smoke'
|
vetName = 'Dr Smoke'
|
||||||
|
vetAddress = '2 Vet Rd, Auckland'
|
||||||
vetPhone = '+64-00-000-0000'
|
vetPhone = '+64-00-000-0000'
|
||||||
emergencyContactName = 'Smoke Contact'
|
emergencyContactName = 'Smoke Contact'
|
||||||
emergencyContactPhone = '+64-00-000-0000'
|
emergencyContactPhone = '+64-00-000-0000'
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
||||||
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
|
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
|
||||||
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES: ${PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES:-false}
|
PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES: ${PUBLIC_ENABLE_ENHANCED_CONTENT_IMAGES:-false}
|
||||||
|
GA4_MEASUREMENT_ID: ${GA4_MEASUREMENT_ID:-}
|
||||||
|
GA4_API_SECRET: ${GA4_API_SECRET:-}
|
||||||
TZ: ${TZ:-Pacific/Auckland}
|
TZ: ${TZ:-Pacific/Auckland}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
@@ -57,6 +59,13 @@ services:
|
|||||||
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-}
|
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-}
|
||||||
MAX_REQUEST_BODY_BYTES: ${MAX_REQUEST_BODY_BYTES:-}
|
MAX_REQUEST_BODY_BYTES: ${MAX_REQUEST_BODY_BYTES:-}
|
||||||
DEPLOY_SMOKE_SECRET: ${DEPLOY_SMOKE_SECRET:-}
|
DEPLOY_SMOKE_SECRET: ${DEPLOY_SMOKE_SECRET:-}
|
||||||
|
# MYOB integration (optional — see deploy.env.template).
|
||||||
|
MYOB_API_KEY: ${MYOB_API_KEY:-}
|
||||||
|
MYOB_API_SECRET: ${MYOB_API_SECRET:-}
|
||||||
|
MYOB_REFRESH_TOKEN: ${MYOB_REFRESH_TOKEN:-}
|
||||||
|
MYOB_COMPANY_FILE_URI: ${MYOB_COMPANY_FILE_URI:-}
|
||||||
|
MYOB_CF_USERNAME: ${MYOB_CF_USERNAME:-Administrator}
|
||||||
|
MYOB_CF_PASSWORD: ${MYOB_CF_PASSWORD:-}
|
||||||
PYTHONUNBUFFERED: '1'
|
PYTHONUNBUFFERED: '1'
|
||||||
TZ: ${TZ:-Pacific/Auckland}
|
TZ: ${TZ:-Pacific/Auckland}
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ services:
|
|||||||
REPLY_TO: ${REPLY_TO:-info@goodwalk.co.nz}
|
REPLY_TO: ${REPLY_TO:-info@goodwalk.co.nz}
|
||||||
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
||||||
DATA_DIR: ${MAIL_API_DATA_DIR:-/app/data}
|
DATA_DIR: ${MAIL_API_DATA_DIR:-/app/data}
|
||||||
|
# MYOB integration (optional — see deploy.env.template).
|
||||||
|
MYOB_API_KEY: ${MYOB_API_KEY:-}
|
||||||
|
MYOB_API_SECRET: ${MYOB_API_SECRET:-}
|
||||||
|
MYOB_REFRESH_TOKEN: ${MYOB_REFRESH_TOKEN:-}
|
||||||
|
MYOB_COMPANY_FILE_URI: ${MYOB_COMPANY_FILE_URI:-}
|
||||||
|
MYOB_CF_USERNAME: ${MYOB_CF_USERNAME:-Administrator}
|
||||||
|
MYOB_CF_PASSWORD: ${MYOB_CF_PASSWORD:-}
|
||||||
TZ: ${TZ:-Pacific/Auckland}
|
TZ: ${TZ:-Pacific/Auckland}
|
||||||
volumes:
|
volumes:
|
||||||
- mail_api_data:${MAIL_API_DATA_DIR:-/app/data}
|
- mail_api_data:${MAIL_API_DATA_DIR:-/app/data}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Visitor journey reconstruction. Two tables, two retention policies.
|
||||||
|
--
|
||||||
|
-- session_events: every analytics event the SvelteKit /api/track endpoint
|
||||||
|
-- receives, keyed by the anon_id cookie set in hooks.server.ts. This buffer
|
||||||
|
-- is the source for journey reconstruction when a visitor submits the
|
||||||
|
-- booking form. It is pruned aggressively (24h TTL) so we don't hoard
|
||||||
|
-- behavioural data for visitors who never enquire.
|
||||||
|
--
|
||||||
|
-- submission_journeys: when a visitor submits the booking/contact form,
|
||||||
|
-- /api/track/promote copies their recent session_events into this table
|
||||||
|
-- linked to the submission email. These rows are kept so the owner can
|
||||||
|
-- review the journey from the CP dashboard.
|
||||||
|
--
|
||||||
|
-- The promotion step is the only place where an anonymous browsing record
|
||||||
|
-- becomes linked to a named person, and it only happens because that
|
||||||
|
-- person chose to submit a form. The privacy policy covers this.
|
||||||
|
|
||||||
|
create table if not exists session_events (
|
||||||
|
id bigserial primary key,
|
||||||
|
anon_id text not null,
|
||||||
|
event_name text not null,
|
||||||
|
page_path text,
|
||||||
|
params jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists session_events_anon_idx
|
||||||
|
on session_events (anon_id, created_at);
|
||||||
|
|
||||||
|
-- Used by the probabilistic prune in /api/track to find rows past TTL.
|
||||||
|
create index if not exists session_events_created_idx
|
||||||
|
on session_events (created_at);
|
||||||
|
|
||||||
|
create table if not exists submission_journeys (
|
||||||
|
id bigserial primary key,
|
||||||
|
email text not null,
|
||||||
|
anon_id text,
|
||||||
|
-- Snapshot of session_events rows at promotion time (server-captured).
|
||||||
|
events jsonb not null default '[]'::jsonb,
|
||||||
|
-- Client-side sessionStorage buffer sent with the promote request, as a
|
||||||
|
-- fallback for events that never reached /api/track (e.g. blocked at the
|
||||||
|
-- network layer alongside gtag).
|
||||||
|
client_events jsonb not null default '[]'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists submission_journeys_email_idx
|
||||||
|
on submission_journeys (email, created_at desc);
|
||||||
@@ -27,6 +27,7 @@ Reference, audit, and planning documents for the Goodwalk site. Project-level ru
|
|||||||
- [deployment.md](deployment.md) — production deploy flow, server layout, nginx cutover
|
- [deployment.md](deployment.md) — production deploy flow, server layout, nginx cutover
|
||||||
- [webp-conversion.md](webp-conversion.md) — one-time WebP setup for hero images
|
- [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
|
- [onboarding.md](onboarding.md) — client onboarding flow, lifecycle status, legacy CSV migration, Postgres target schema
|
||||||
|
- [server-side-analytics.md](server-side-analytics.md) — first-party `/api/track` endpoint that forwards to GA4 when ad blockers kill client-side gtag
|
||||||
|
|
||||||
## Archive
|
## Archive
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Server-side analytics + visitor journey
|
||||||
|
|
||||||
|
Two things in one pipeline:
|
||||||
|
|
||||||
|
1. **Ad-block-resistant GA4** — forward events server-to-server when client-side `gtag.js` is blocked.
|
||||||
|
2. **Visitor journey reconstruction** — record every event into our own DB, and when a visitor submits the booking form, link their journey to that submission so the owner can review it in the CP dashboard.
|
||||||
|
|
||||||
|
## Why each piece exists
|
||||||
|
|
||||||
|
### Ad-block fallback
|
||||||
|
Browser ad blockers (uBlock, Brave, Safari ITP, AdGuard, Pi-hole) block requests to `googletagmanager.com` and `google-analytics.com`. For NZ consumer traffic that's roughly **20–40% of visits silently lost**. The fix is a first-party endpoint on our own domain that blocklists don't match, which forwards to GA4 via the Measurement Protocol.
|
||||||
|
|
||||||
|
### Journey reconstruction
|
||||||
|
GA4 is aggregate. The owner can see "200 hero CTA clicks this week" but not "this specific submission's journey was /pricing → /about → hero CTA → form." For a small services business, knowing what a *specific lead* engaged with before submitting is more useful than another aggregate dashboard.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ── trackEvent() ──┬─► gtag (when not blocked) ─► GA4
|
||||||
|
│
|
||||||
|
└─► /api/track (always) ─┬─► session_events table
|
||||||
|
│
|
||||||
|
└─► GA4 (only when gtag missing)
|
||||||
|
|
||||||
|
Browser also keeps a rolling sessionStorage buffer of the last 30 events
|
||||||
|
as a fallback (in case /api/track is itself blocked at the network layer).
|
||||||
|
|
||||||
|
On booking form submit success:
|
||||||
|
Browser ── promoteJourney(email) ──► /api/track/promote
|
||||||
|
│
|
||||||
|
├─► reads session_events for this anon_id
|
||||||
|
├─► reads sessionStorage buffer from request body
|
||||||
|
└─► writes one row to submission_journeys
|
||||||
|
|
||||||
|
Owner opens enquiry in CP dashboard:
|
||||||
|
AdminDashboard ── /api/owner/client-enquiry?email=... ──► mail-api
|
||||||
|
│
|
||||||
|
├─► returns enquiry record
|
||||||
|
└─► returns submission_journeys row
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tables (`docker/postgres/init/004-session-events.sql`)
|
||||||
|
|
||||||
|
### `session_events`
|
||||||
|
Every analytics event, keyed by the `anonId` cookie set in `src/hooks.server.ts`. **Pruned after 24h** by a probabilistic cleanup inside `/api/track` (~1 in 200 inserts triggers a `DELETE WHERE created_at < now() - 24h`). No cron container needed — cleanup runs naturally with traffic.
|
||||||
|
|
||||||
|
### `submission_journeys`
|
||||||
|
Promoted journeys keyed by email. **Not auto-pruned.** Owner-facing data. Contains:
|
||||||
|
- `events` — snapshot of `session_events` rows at promotion time (server-captured)
|
||||||
|
- `client_events` — the sessionStorage buffer the client posted (fallback)
|
||||||
|
|
||||||
|
The merge happens in the CP UI (`AdminDashboard.mergedJourneyEvents`), de-duped by `name|page_path|ts`.
|
||||||
|
|
||||||
|
## De-duplication
|
||||||
|
|
||||||
|
- **GA4** receives each event exactly once: client when gtag is loaded, server when it isn't. The `forward_ga4` flag in the `/api/track` body controls this.
|
||||||
|
- **session_events** receives every event once (always written server-side).
|
||||||
|
- **Journey display** merges server + client events with key `name|page_path|ts`.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Disclosed in `src/lib/content/privacy-policy.ts` under the **Analytics** section. The key promises:
|
||||||
|
|
||||||
|
- Browsing record contains pages, clicks, timestamps, and a random browser ID — never name/email/phone or form contents.
|
||||||
|
- Unsubmitted journeys are deleted within 24h.
|
||||||
|
- Submitted journeys are linked to the enquiry email, visible only to the Goodwalk team, never shared or used for advertising.
|
||||||
|
- Users can request deletion at info@goodwalk.co.nz.
|
||||||
|
|
||||||
|
**Update the policy in the same PR** if you ever change what's stored or how long.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GA4_MEASUREMENT_ID=G-K7TLSFJVP1 # already in deploy.env.template
|
||||||
|
GA4_API_SECRET=<from GA4 admin> # required for the GA4 forward
|
||||||
|
```
|
||||||
|
|
||||||
|
To get the API secret: GA4 admin → Data Streams → web stream → Measurement Protocol API secrets → Create. Without it, `/api/track` still records to `session_events` (journey works) — only the GA4 forward is off.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `src/routes/api/track/+server.ts` — main ingest, persists + forwards
|
||||||
|
- `src/routes/api/track/promote/+server.ts` — links journey to submission email
|
||||||
|
- `src/lib/analytics.ts` — client `trackEvent`, sessionStorage buffer, `promoteJourney(email)`
|
||||||
|
- `src/lib/components/BookingWizard.svelte` — calls `promoteJourney(email)` on submit success
|
||||||
|
- `mail-api/db.py` — `get_submission_journey(email)` reader
|
||||||
|
- `mail-api/main.py` — `/owner/client-enquiry` returns `{enquiry, journey}`
|
||||||
|
- `src/lib/components/admin-dashboard/AdminDashboard.svelte` — renders the **Visitor journey** section in the enquiry modal
|
||||||
|
- `src/lib/content/privacy-policy.ts` — disclosure
|
||||||
|
- `docker/postgres/init/004-session-events.sql` — table definitions
|
||||||
|
|
||||||
|
## Testing locally
|
||||||
|
|
||||||
|
Without env vars set (no GA4 forwarding):
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5173/api/track \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'user-agent: Mozilla/5.0' \
|
||||||
|
-d '{"name":"test_event","params":{"label":"manual","page_path":"/"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check the row landed:
|
||||||
|
```bash
|
||||||
|
docker exec -it goodwalk_svelte_db psql -U goodwalk -d goodwalk \
|
||||||
|
-c "select event_name, page_path, created_at from session_events order by id desc limit 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
To test the full journey flow locally, submit a booking through the wizard with a test email, then open `cp.goodwalk.local` (or use `?preview=cp` on localhost), open the enquiry for that email, and the **Visitor journey** panel should list every page view and click that led to the submission.
|
||||||
|
|
||||||
|
## What this does NOT do
|
||||||
|
|
||||||
|
- **Meta Pixel / Facebook Ads** — same blocker problem, different fix (Conversions API). Not built.
|
||||||
|
- **Real-time owner notifications** — journey is visible only after submission, not as a live feed of who's on the site.
|
||||||
|
- **Cross-device journey** — anon_id is per-browser. A visitor who researches on phone then submits on laptop produces two separate (mostly empty) journeys.
|
||||||
|
- **Consent banner** — NZ has no explicit cookie law today. If we ever serve EU/UK traffic, we need Consent Mode v2 before this pipeline is legal there for the GA4 forward.
|
||||||
|
- **Pruning of `submission_journeys`** — these are kept indefinitely. If you want a max retention (e.g. delete journeys older than 12 months), add a cron or extend the probabilistic cleanup in `/api/track`.
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
# v5: Simplify the message on service pages
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Every service page should say one clear message, once, in a fixed order, using the
|
||||||
|
homepage's single visual language. No restated sections, no card-grid sprawl. Lead
|
||||||
|
with trust, show the emotional outcome, let the page breathe (PRODUCT.md principles).
|
||||||
|
|
||||||
|
The Tiny Gang page is the finished reference. The rest of this task is applying the
|
||||||
|
same treatment to the other two service pages.
|
||||||
|
|
||||||
|
## The problem we are fixing
|
||||||
|
|
||||||
|
The current `ServiceLandingPage.svelte` stacks ~10 sections that repeat each other:
|
||||||
|
hero, highlight collage, a 6-card benefit pager, an intro badge block, a decision
|
||||||
|
card, a pricing block with an extras grid, an areas card, a FAQ card, and a related
|
||||||
|
services grid. The same promise (matched groups, safe, free pickup) is said three or
|
||||||
|
four times in three or four different card styles. It reads as pages of text and
|
||||||
|
boxes, not one calm argument.
|
||||||
|
|
||||||
|
## The v5 rule
|
||||||
|
|
||||||
|
**Say it once, in order. One design system.**
|
||||||
|
|
||||||
|
Canonical section spine for every service page (this is the order):
|
||||||
|
|
||||||
|
1. **Hero** - what it is (shared `ServiceHero`: green copy left, photo right).
|
||||||
|
2. **What's different** - the 3-4 things that actually set this service apart.
|
||||||
|
3. **How it works** - the steps to get started, made explicit.
|
||||||
|
4. **Honest fit check** - who it suits, who it does not (two columns, one card).
|
||||||
|
5. **Proof** - `TestimonialsSection`.
|
||||||
|
6. **One deliberate image** - a real photo that breaks the text and carries meaning.
|
||||||
|
7. **Price** - even plan cards, plus a short row of promise notes.
|
||||||
|
8. **FAQ** - a calm ruled list, not stacked cards.
|
||||||
|
9. **Service area** - `ServiceAreaCoverage`: an Auckland hub with all suburbs as
|
||||||
|
tappable links. Robust on every screen (no pin map). Per-page stats, so puppy-visits
|
||||||
|
reads "in-home", not "pickup & drop-off".
|
||||||
|
10. **Book** - `BookingWizard`.
|
||||||
|
|
||||||
|
If a piece of content does not fit one of those ten slots, it is probably a repeat.
|
||||||
|
Cut it.
|
||||||
|
|
||||||
|
## Design rules (non-negotiable, taken from the Tiny Gang build)
|
||||||
|
|
||||||
|
- **Lists, not cards.** "What's different" is an icon + text list. "How it works" is
|
||||||
|
numbered items. Reserve real cards for pricing only. Never nest cards.
|
||||||
|
- **One heading grammar.** Every section opens with the same centered eyebrow + H2
|
||||||
|
(`.tg-eyebrow` + `.tg-h2`). No per-section badges, marks, or collages.
|
||||||
|
- **One token set.** Use the shared CSS variables (colours, radius, shadow, spacing).
|
||||||
|
No new gradients or per-section colour systems. Goodwalk green `#213021`, yellow
|
||||||
|
`#FFD100` for buttons only.
|
||||||
|
- **Pricing cards stay even.** Four equal cards in a row on desktop, 2-up at 980px,
|
||||||
|
1-up on mobile. Do not put images inside or beside the cards.
|
||||||
|
- **One image, deliberate.** A single full-width photo band between sections, with a
|
||||||
|
short keyword-bearing caption. It must mean something (the pack, the pickup, the
|
||||||
|
outcome), not decorate. See SEO rules below.
|
||||||
|
- **FAQ is a ruled list.** Hairline dividers between questions, no card chrome.
|
||||||
|
- **Copy: half it, then half it again.** Active voice. No em dashes. No heading that
|
||||||
|
restates the one above it.
|
||||||
|
|
||||||
|
## Image and SEO rules
|
||||||
|
|
||||||
|
- Use real Goodwalk photos from `static/images/` (keyword-rich filenames already).
|
||||||
|
- Register width + height in `src/lib/image-metadata.ts` so there is zero layout shift.
|
||||||
|
- Set explicit `width`, `height`, `loading="lazy"`, `decoding="async"` on every `<img>`.
|
||||||
|
- Alt text is a full, natural, location-aware sentence (e.g. "Four small Tiny Gang dogs
|
||||||
|
looking up together in the long grass at an Auckland park"), not "dogs".
|
||||||
|
- Keep the `FaqSection` schema (`emitSchema`) and the `Service` + `AggregateOffer`
|
||||||
|
structured data in `src/routes/[slug]/+page.svelte` intact.
|
||||||
|
|
||||||
|
## Reference implementation (copy this)
|
||||||
|
|
||||||
|
- Component: `src/lib/components/pages/ServiceClarityPage.svelte` (shared by all three
|
||||||
|
service pages, content-driven)
|
||||||
|
- Pricing variant: `src/lib/components/pages/PricingClarityPage.svelte`
|
||||||
|
- Coverage section: `src/lib/components/sections/ServiceAreaCoverage.svelte`
|
||||||
|
- Preview routes (noindex): `/variants/tiny-gang`, `/variants/solo-walks`,
|
||||||
|
`/variants/puppy-visits`, `/variants/our-pricing`
|
||||||
|
- Content: the `clarity` block in `pack-walks.ts`, `dog-walking.ts`, `puppy-visits.ts`
|
||||||
|
- Variant switcher (preview only): `src/lib/components/ui/VariantNav.svelte`, mounted in
|
||||||
|
`src/routes/+layout.svelte`
|
||||||
|
|
||||||
|
Read the header comment in `ServiceClarityPage.svelte` first. It states the philosophy
|
||||||
|
and the section order in one place.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Done (built, svelte-check clean, server-rendered):
|
||||||
|
|
||||||
|
- `ServiceClarityPage` generalised and content-driven; `ServicePageContent.clarity`
|
||||||
|
added in `src/lib/types.ts`. Old `TinyGangPage.svelte` deleted.
|
||||||
|
- `clarity` content written for pack-walks, dog-walking, puppy-visits. Tiny Gang renders
|
||||||
|
identically to before.
|
||||||
|
- Coverage spine slot added (`ServiceAreaCoverage`), with per-page stats so puppy-visits
|
||||||
|
reads "in-home" not "pickup & drop-off".
|
||||||
|
- Preview routes for all four variants, plus a global `VariantNav` to compare them.
|
||||||
|
|
||||||
|
## Live now
|
||||||
|
|
||||||
|
The clarity pages are the live pages. `src/routes/[slug]/+page.svelte` renders
|
||||||
|
`ServiceClarityPage` for pack-walks / dog-walking / puppy-visits and `PricingClarityPage`
|
||||||
|
for our-pricing. The old `ServiceLandingPage.svelte`, `PricingPage.svelte`, and
|
||||||
|
`ServiceAreaMap.svelte` have been deleted.
|
||||||
|
|
||||||
|
The `/variants/*` routes still exist as a sandbox (the minimal-footer test lives there),
|
||||||
|
but are **dev/localhost only**: `src/hooks.server.ts` returns 500 for the `/variants`
|
||||||
|
namespace off-localhost in production, and `VariantNav` is hidden the same way in
|
||||||
|
`+layout.svelte`. Verified on a real `node build`: public host → 500 for any
|
||||||
|
`/variants/*` and no `VariantNav`; localhost → variants load.
|
||||||
|
|
||||||
|
Footer note: the live pages keep the default footer. The `minimal` footer is still only
|
||||||
|
on the `/variants/*` pages, a separate decision from the page-design swap.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
- [x] One `ServiceClarityPage` component drives all three service pages.
|
||||||
|
- [x] Page-specific copy lives in content files, not the component.
|
||||||
|
- [x] All three pages follow the spine, say each point once, share one heading grammar
|
||||||
|
and one token set.
|
||||||
|
- [x] Pricing cards are even on every page. No image in the pricing block.
|
||||||
|
- [x] Exactly one deliberate, captioned, fully-SEO'd image band per service page.
|
||||||
|
- [x] FAQ is a ruled list. Schema preserved.
|
||||||
|
- [x] `npx svelte-check` clean (no new errors).
|
||||||
|
- [x] Live `[slug]` branches swapped to the clarity pages.
|
||||||
|
- [x] Old `ServiceLandingPage.svelte` / `PricingPage.svelte` / `ServiceAreaMap.svelte` deleted.
|
||||||
|
- [x] `/variants/*` blocked in production (500) and `VariantNav` hidden there.
|
||||||
|
- [ ] Decide whether to promote the `minimal` footer to the live pages.
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- Do not redesign the brand or introduce new colours, fonts, or gradients.
|
||||||
|
- Do not add card grids, badges, collages, or mobile pagers back in.
|
||||||
|
- Do not change wording on a page unless it is a repeat being removed or copy being
|
||||||
|
shortened. Keep the meaning.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Mail API local dev
|
||||||
|
|
||||||
|
Use the project virtualenv, not the global Python install. The local environment already resolves compatible `pydantic` and `pydantic-core` versions together.
|
||||||
|
|
||||||
|
## Start on Windows PowerShell
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\mattc\gw-svelte\gw-svelte\mail-api
|
||||||
|
.\start-dev.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\mattc\gw-svelte\gw-svelte\mail-api
|
||||||
|
py -3.14 -m venv .venv
|
||||||
|
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## If you see a `pydantic-core` mismatch
|
||||||
|
|
||||||
|
You are almost certainly running against a global interpreter or a stale environment. Reinstall into the project virtualenv and start the app with `.\start-dev.ps1`.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -9,7 +9,37 @@
|
|||||||
"priceDetails": "45",
|
"priceDetails": "45",
|
||||||
"startDate": "2026-05-18",
|
"startDate": "2026-05-18",
|
||||||
"sentAt": "2026-05-19T22:59:53"
|
"sentAt": "2026-05-19T22:59:53"
|
||||||
}
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"status": "active",
|
||||||
|
"reason": "",
|
||||||
|
"changedAt": "2026-06-29T20:39:59",
|
||||||
|
"changedBy": "info@goodwalk.co.nz"
|
||||||
|
},
|
||||||
|
"lifecycleHistory": [
|
||||||
|
{
|
||||||
|
"status": "active",
|
||||||
|
"reason": "",
|
||||||
|
"changedAt": "2026-06-29T20:39:59",
|
||||||
|
"changedBy": "info@goodwalk.co.nz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dogs": [
|
||||||
|
{
|
||||||
|
"id": "primary",
|
||||||
|
"name": "Geoffrey",
|
||||||
|
"breed": "",
|
||||||
|
"birthDate": "",
|
||||||
|
"birthdayAutoSend": false,
|
||||||
|
"birthdayEmailLastSentAt": "",
|
||||||
|
"birthdayEmailLastSentYear": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dogBreed": "",
|
||||||
|
"dogAge": "",
|
||||||
|
"birthdayAutoSend": false,
|
||||||
|
"birthdayEmailLastSentAt": "",
|
||||||
|
"birthdayEmailLastSentYear": ""
|
||||||
},
|
},
|
||||||
"natalie@desseinparke.com": {
|
"natalie@desseinparke.com": {
|
||||||
"fullName": "Natalie Parke",
|
"fullName": "Natalie Parke",
|
||||||
|
|||||||
@@ -303,6 +303,57 @@ async def set_kv(key: str, value: Any) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_submission_journey(email: str) -> dict | None:
|
||||||
|
"""Return the most recent submission_journeys row for the given email, or
|
||||||
|
None when no journey was promoted (or DB is off). The table is owned by
|
||||||
|
the SvelteKit app — see src/routes/api/track/promote — but the mail-api
|
||||||
|
reads it here so the owner dashboard's enquiry view can render it
|
||||||
|
alongside the booking record."""
|
||||||
|
pool = await get_pool()
|
||||||
|
if pool is None:
|
||||||
|
return None
|
||||||
|
normalized = (email or "").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
select id, anon_id, events, client_events, created_at
|
||||||
|
from submission_journeys
|
||||||
|
where email = $1
|
||||||
|
order by created_at desc
|
||||||
|
limit 1
|
||||||
|
""",
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Table may not exist yet in environments where the new init script
|
||||||
|
# hasn't run. Don't break the enquiry view over it.
|
||||||
|
logger.warning("submission_journeys read failed: %s", exc)
|
||||||
|
return None
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse(value: Any) -> Any:
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
value = value.decode("utf-8")
|
||||||
|
try:
|
||||||
|
return json.loads(value) if value else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"anonId": row["anon_id"],
|
||||||
|
"events": _parse(row["events"]),
|
||||||
|
"clientEvents": _parse(row["client_events"]),
|
||||||
|
"createdAt": row["created_at"].isoformat() if row["created_at"] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def has_any_value() -> bool:
|
async def has_any_value() -> bool:
|
||||||
"""Return True if admin_kv already has any rows. Used to decide whether
|
"""Return True if admin_kv already has any rows. Used to decide whether
|
||||||
to seed from JSON files on first boot."""
|
to seed from JSON files on first boot."""
|
||||||
|
|||||||
@@ -207,12 +207,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"27"
|
"27"
|
||||||
],
|
],
|
||||||
@@ -432,12 +432,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"45"
|
"45"
|
||||||
],
|
],
|
||||||
@@ -657,12 +657,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"95"
|
"95"
|
||||||
],
|
],
|
||||||
@@ -882,12 +882,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"103"
|
"103"
|
||||||
],
|
],
|
||||||
@@ -1107,12 +1107,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"39"
|
"39"
|
||||||
],
|
],
|
||||||
@@ -1332,12 +1332,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"41"
|
"41"
|
||||||
],
|
],
|
||||||
@@ -1557,12 +1557,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"36"
|
"36"
|
||||||
],
|
],
|
||||||
@@ -1782,12 +1782,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"83"
|
"83"
|
||||||
],
|
],
|
||||||
@@ -2007,12 +2007,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"76",
|
"76",
|
||||||
"49"
|
"49"
|
||||||
@@ -2233,12 +2233,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"99"
|
"99"
|
||||||
],
|
],
|
||||||
@@ -2458,12 +2458,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"84"
|
"84"
|
||||||
],
|
],
|
||||||
@@ -2683,12 +2683,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"51"
|
"51"
|
||||||
],
|
],
|
||||||
@@ -2908,12 +2908,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"87"
|
"87"
|
||||||
],
|
],
|
||||||
@@ -3134,12 +3134,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"88"
|
"88"
|
||||||
],
|
],
|
||||||
@@ -3359,12 +3359,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"34"
|
"34"
|
||||||
],
|
],
|
||||||
@@ -3584,12 +3584,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"61",
|
"61",
|
||||||
"47"
|
"47"
|
||||||
@@ -3811,12 +3811,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"57"
|
"57"
|
||||||
],
|
],
|
||||||
@@ -4036,12 +4036,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"32"
|
"32"
|
||||||
],
|
],
|
||||||
@@ -4261,12 +4261,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"22"
|
"22"
|
||||||
],
|
],
|
||||||
@@ -4486,12 +4486,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"64"
|
"64"
|
||||||
],
|
],
|
||||||
@@ -4711,12 +4711,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"74"
|
"74"
|
||||||
],
|
],
|
||||||
@@ -4936,12 +4936,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"56"
|
"56"
|
||||||
],
|
],
|
||||||
@@ -5161,12 +5161,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"116",
|
"116",
|
||||||
"93"
|
"93"
|
||||||
@@ -5387,12 +5387,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"59"
|
"59"
|
||||||
],
|
],
|
||||||
@@ -5612,12 +5612,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"30"
|
"30"
|
||||||
],
|
],
|
||||||
@@ -5837,12 +5837,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"53"
|
"53"
|
||||||
],
|
],
|
||||||
@@ -6062,12 +6062,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"107"
|
"107"
|
||||||
],
|
],
|
||||||
@@ -6287,12 +6287,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"24"
|
"24"
|
||||||
],
|
],
|
||||||
@@ -6512,12 +6512,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"43"
|
"43"
|
||||||
],
|
],
|
||||||
@@ -6737,12 +6737,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"97"
|
"97"
|
||||||
],
|
],
|
||||||
@@ -6962,12 +6962,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"90"
|
"90"
|
||||||
],
|
],
|
||||||
@@ -7188,12 +7188,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"105"
|
"105"
|
||||||
],
|
],
|
||||||
@@ -7413,12 +7413,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"101"
|
"101"
|
||||||
],
|
],
|
||||||
@@ -7638,12 +7638,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"78"
|
"78"
|
||||||
],
|
],
|
||||||
@@ -7664,12 +7664,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [],
|
"onboardingEntryIds": [],
|
||||||
"contractEntryIds": [
|
"contractEntryIds": [
|
||||||
"29"
|
"29"
|
||||||
@@ -7887,12 +7887,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-05-24T05:45:33.160Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-05-24T05:45:33.160Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"80"
|
"80"
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -48,7 +48,7 @@ def setup_logging() -> logging.Logger:
|
|||||||
root.addHandler(rotating)
|
root.addHandler(rotating)
|
||||||
|
|
||||||
log = logging.getLogger("mail-api")
|
log = logging.getLogger("mail-api")
|
||||||
log.info("Logging initialised → console=INFO, file=%s (DEBUG, rotating)", log_file)
|
log.info("Logging initialised -> console=INFO, file=%s (DEBUG, rotating)", log_file)
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +185,10 @@ 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_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")))
|
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))))
|
BIRTHDAY_CHECK_INTERVAL_SECONDS = max(3600, int(os.environ.get("BIRTHDAY_CHECK_INTERVAL_SECONDS", str(12 * 3600))))
|
||||||
|
# How often the scheduled-email worker wakes to dispatch any sends that have
|
||||||
|
# come due. Scheduled welcome emails need finer granularity than the birthday
|
||||||
|
# sweep, so this defaults to one minute (floored at 15s).
|
||||||
|
SCHEDULED_CHECK_INTERVAL_SECONDS = max(15, int(os.environ.get("SCHEDULED_CHECK_INTERVAL_SECONDS", "60")))
|
||||||
|
|
||||||
|
|
||||||
def _split_csv_env(name: str, default: str) -> tuple[str, ...]:
|
def _split_csv_env(name: str, default: str) -> tuple[str, ...]:
|
||||||
@@ -211,6 +215,10 @@ _DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
|
|||||||
ALLOWED_EMAILS_FILE = _DATA_DIR / "allowed_emails.json"
|
ALLOWED_EMAILS_FILE = _DATA_DIR / "allowed_emails.json"
|
||||||
CLIENT_PROFILES_FILE = _DATA_DIR / "client_profiles.json"
|
CLIENT_PROFILES_FILE = _DATA_DIR / "client_profiles.json"
|
||||||
DRAFTS_FILE = _DATA_DIR / "drafts.json"
|
DRAFTS_FILE = _DATA_DIR / "drafts.json"
|
||||||
|
# Durable queue of owner-scheduled emails (welcome packs queued for later send).
|
||||||
|
# Mirrors the client_profiles persistence model: postgres admin_kv is canonical
|
||||||
|
# in production, with this JSON file as the dev/local fallback and seed source.
|
||||||
|
SCHEDULED_EMAILS_FILE = _DATA_DIR / "scheduled_emails.json"
|
||||||
# Legacy seed lives OUTSIDE the data volume — it's shipped in the image so a
|
# Legacy seed lives OUTSIDE the data volume — it's shipped in the image so a
|
||||||
# fresh deploy always carries the same baked-in copy. On boot the mail-api
|
# fresh deploy always carries the same baked-in copy. On boot the mail-api
|
||||||
# merges this dict into _client_profiles, adding any emails that aren't
|
# merges this dict into _client_profiles, adding any emails that aren't
|
||||||
@@ -221,6 +229,27 @@ LEGACY_SEED_FILE = Path(
|
|||||||
|
|
||||||
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
|
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
|
||||||
|
|
||||||
|
# ── MYOB integration ───────────────────────────────────────────────────────────
|
||||||
|
# Optional: create new clients as customer contacts in MYOB AccountRight Live.
|
||||||
|
# The integration stays disabled until every required value is present, so an
|
||||||
|
# unconfigured environment behaves exactly as before.
|
||||||
|
#
|
||||||
|
# MYOB_API_KEY / MYOB_API_SECRET the developer app's OAuth client id/secret
|
||||||
|
# MYOB_REFRESH_TOKEN long-lived token used to mint access tokens
|
||||||
|
# MYOB_COMPANY_FILE_URI base API URI of the target company file,
|
||||||
|
# e.g. https://api.myob.com/accountright/<guid>
|
||||||
|
# MYOB_CF_USERNAME / MYOB_CF_PASSWORD company-file login (password may be blank)
|
||||||
|
# MYOB_TOKEN_URL OAuth token endpoint (override only for tests)
|
||||||
|
MYOB_API_KEY = (os.environ.get("MYOB_API_KEY") or "").strip()
|
||||||
|
MYOB_API_SECRET = (os.environ.get("MYOB_API_SECRET") or "").strip()
|
||||||
|
MYOB_REFRESH_TOKEN = (os.environ.get("MYOB_REFRESH_TOKEN") or "").strip()
|
||||||
|
MYOB_COMPANY_FILE_URI = (os.environ.get("MYOB_COMPANY_FILE_URI") or "").strip().rstrip("/")
|
||||||
|
MYOB_CF_USERNAME = (os.environ.get("MYOB_CF_USERNAME") or "Administrator").strip()
|
||||||
|
MYOB_CF_PASSWORD = os.environ.get("MYOB_CF_PASSWORD", "")
|
||||||
|
MYOB_TOKEN_URL = (os.environ.get("MYOB_TOKEN_URL") or "https://secure.myob.com/oauth2/v1/authorize").strip()
|
||||||
|
MYOB_HTTP_TIMEOUT_SECONDS = max(5, int(os.environ.get("MYOB_HTTP_TIMEOUT_SECONDS", "20")))
|
||||||
|
MYOB_ENABLED = bool(MYOB_API_KEY and MYOB_API_SECRET and MYOB_REFRESH_TOKEN and MYOB_COMPANY_FILE_URI)
|
||||||
|
|
||||||
# ── Legacy module constants (kept for compatibility with the existing main.py) ──
|
# ── Legacy module constants (kept for compatibility with the existing main.py) ──
|
||||||
|
|
||||||
OWNER_EMAIL = settings.owner_email
|
OWNER_EMAIL = settings.owner_email
|
||||||
@@ -268,3 +297,4 @@ logger.info(
|
|||||||
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
||||||
EMAIL_SEND_TIMEOUT_SECONDS,
|
EMAIL_SEND_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
logger.info("MYOB integration: %s", "enabled" if MYOB_ENABLED else "disabled (set MYOB_* env vars to enable)")
|
||||||
|
|||||||
+114
-3
@@ -41,9 +41,13 @@ class OnboardingSubmission(BaseSubmission):
|
|||||||
medicalNotes: str = ""
|
medicalNotes: str = ""
|
||||||
accessInstructions: str = ""
|
accessInstructions: str = ""
|
||||||
vetName: str
|
vetName: str
|
||||||
|
vetAddress: str
|
||||||
vetPhone: str
|
vetPhone: str
|
||||||
emergencyContactName: str
|
emergencyContactName: str
|
||||||
emergencyContactPhone: str
|
emergencyContactPhone: str
|
||||||
|
regularFleaTickTreatment: str = ""
|
||||||
|
petInsurance: str = ""
|
||||||
|
petInsuranceOwnerExpenseAccepted: bool = False
|
||||||
councilRegistrationConfirmed: bool = False
|
councilRegistrationConfirmed: bool = False
|
||||||
vaccinationsConfirmed: bool = False
|
vaccinationsConfirmed: bool = False
|
||||||
emergencyVetConsent: bool = False
|
emergencyVetConsent: bool = False
|
||||||
@@ -54,19 +58,68 @@ class OnboardingSubmission(BaseSubmission):
|
|||||||
|
|
||||||
class WelcomePackEmailRequest(BaseModel):
|
class WelcomePackEmailRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
serviceType: str
|
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
|
||||||
priceDetails: str
|
# blank (resolved server-side so scheduled sends keep a sensible subject too).
|
||||||
startDate: str
|
subject: str = ""
|
||||||
|
# Structured offer details rendered in the details table. Now fully optional —
|
||||||
|
# the owner can hide the table entirely (showDetails=False) and write a free
|
||||||
|
# email instead. Left blank, a row is simply omitted from the table.
|
||||||
|
serviceType: str = ""
|
||||||
|
priceDetails: str = ""
|
||||||
|
startDate: str = ""
|
||||||
|
# Free-text customisation. All blank => the email falls back to the original
|
||||||
|
# default copy, so existing callers keep working unchanged. `{first_name}`
|
||||||
|
# and `{dog_name}` tokens are substituted server-side.
|
||||||
|
heading: str = ""
|
||||||
|
intro: str = ""
|
||||||
|
outro: str = ""
|
||||||
|
tagLabel: str = ""
|
||||||
|
# Customisable button block. Defaults to "Complete onboarding" pointing at the
|
||||||
|
# client onboarding portal. Clearing either field hides the button.
|
||||||
|
ctaLabel: str = ""
|
||||||
|
ctaUrl: str = ""
|
||||||
|
# Whether to render the Service / Price / Start date table at all.
|
||||||
|
showDetails: bool = True
|
||||||
|
# Rich-text (WYSIWYG) message body. When present it replaces the
|
||||||
|
# heading/intro/outro copy above and is sanitised to an email-safe subset of
|
||||||
|
# HTML server-side. `includeButton` toggles the onboarding button beneath it.
|
||||||
|
bodyHtml: str = ""
|
||||||
|
includeButton: bool = True
|
||||||
preview: bool = False
|
preview: bool = False
|
||||||
|
previewRecipients: list[EmailStr] = []
|
||||||
|
# When sending for real (not a preview), also copy the owner so they keep a
|
||||||
|
# record of exactly what the client received. CC is visible to the client;
|
||||||
|
# BCC is hidden. Independent. Ignored on previews and scheduled sends.
|
||||||
|
ccOwner: bool = False
|
||||||
|
bccOwner: bool = False
|
||||||
|
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
|
||||||
|
# request, the welcome email is queued for reliable delivery at that time
|
||||||
|
# instead of being sent immediately.
|
||||||
|
scheduledFor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledEmailCancelRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledEmailRescheduleRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
scheduledFor: str
|
||||||
|
|
||||||
|
|
||||||
class BirthdayEmailRequest(BaseModel):
|
class BirthdayEmailRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
dogId: str | None = None
|
||||||
|
# Owner-editable subject line. Blank falls back to the default
|
||||||
|
# "Happy birthday <dog>" subject.
|
||||||
|
subject: str = ""
|
||||||
preview: bool = False
|
preview: bool = False
|
||||||
|
previewRecipients: list[EmailStr] = []
|
||||||
|
|
||||||
|
|
||||||
class BirthdayAutoSendRequest(BaseModel):
|
class BirthdayAutoSendRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
dogId: str | None = None
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +134,63 @@ class ClientStatusUpdate(BaseModel):
|
|||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfileUpdate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
nextEmail: EmailStr
|
||||||
|
fullName: str
|
||||||
|
phone: str = ""
|
||||||
|
address: str = ""
|
||||||
|
dogName: str
|
||||||
|
dogBreed: str = ""
|
||||||
|
dogAge: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ResetOnboardingRequest(BaseModel):
|
||||||
|
"""Owner-initiated reset: keep the client's saved details but mark their
|
||||||
|
onboarding incomplete so they can sign in and complete the new form
|
||||||
|
(used for clients imported from the legacy Gravity Forms data)."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteClientRequest(BaseModel):
|
||||||
|
"""Owner-initiated permanent deletion of a client and their access."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class NewClientRequest(BaseModel):
|
||||||
|
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
|
||||||
|
that never came through the public enquiry form). Registers the email so
|
||||||
|
the client can access the onboarding form, and seeds a profile."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
fullName: str
|
||||||
|
phone: str = ""
|
||||||
|
address: str = ""
|
||||||
|
dogName: str = ""
|
||||||
|
dogBreed: str = ""
|
||||||
|
dogAge: str = ""
|
||||||
|
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
|
||||||
|
source: str = ""
|
||||||
|
# The date the client joined Goodwalk (ISO yyyy-mm-dd). Owner-supplied, since
|
||||||
|
# only the owner knows it. Used to surface joining anniversaries.
|
||||||
|
joiningDate: str = ""
|
||||||
|
# When true (and the MYOB integration is configured), also create the client
|
||||||
|
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
|
||||||
|
# local client from being created.
|
||||||
|
createInMyob: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ClientDogUpsertRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
dogId: str | None = None
|
||||||
|
dogName: str
|
||||||
|
dogBreed: str = ""
|
||||||
|
dogAge: str = ""
|
||||||
|
birthdayAutoSend: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ContractSubmission(BaseSubmission):
|
class ContractSubmission(BaseSubmission):
|
||||||
address: str
|
address: str
|
||||||
dogName: str
|
dogName: str
|
||||||
@@ -126,3 +236,4 @@ class SendMessageRequest(BaseModel):
|
|||||||
fontId: str = "system"
|
fontId: str = "system"
|
||||||
recipients: list[EmailStr] = []
|
recipients: list[EmailStr] = []
|
||||||
preview: bool = False
|
preview: bool = False
|
||||||
|
previewRecipients: list[EmailStr] = []
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""MYOB AccountRight Live integration — create customer contacts.
|
||||||
|
|
||||||
|
Used when the owner ticks "Also create in MYOB" while adding a new client in the
|
||||||
|
control panel. The integration is optional: when the required environment
|
||||||
|
variables are not set, :func:`is_configured` returns ``False`` and the caller
|
||||||
|
skips MYOB entirely (the local client is still created).
|
||||||
|
|
||||||
|
Auth model (MYOB OAuth 2.0):
|
||||||
|
* A developer key/secret (``client_id`` / ``client_secret``) identifies this
|
||||||
|
app to MYOB.
|
||||||
|
* A long-lived refresh token mints short-lived bearer access tokens. We cache
|
||||||
|
the access token in memory and refresh it shortly before it expires.
|
||||||
|
* Each AccountRight company file lives at its own base URI and is gated by a
|
||||||
|
company-file token: ``base64("username:password")`` sent as the
|
||||||
|
``x-myobapi-cftoken`` header (password may be blank).
|
||||||
|
|
||||||
|
All HTTP calls are synchronous (``requests``); the async app invokes the public
|
||||||
|
helpers via ``asyncio.to_thread`` so the event loop is never blocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from mail_api.config import (
|
||||||
|
MYOB_API_KEY,
|
||||||
|
MYOB_API_SECRET,
|
||||||
|
MYOB_CF_PASSWORD,
|
||||||
|
MYOB_CF_USERNAME,
|
||||||
|
MYOB_COMPANY_FILE_URI,
|
||||||
|
MYOB_ENABLED,
|
||||||
|
MYOB_HTTP_TIMEOUT_SECONDS,
|
||||||
|
MYOB_REFRESH_TOKEN,
|
||||||
|
MYOB_TOKEN_URL,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MyobError(Exception):
|
||||||
|
"""Raised when a MYOB API call cannot be completed."""
|
||||||
|
|
||||||
|
|
||||||
|
# Access tokens are short-lived (MYOB returns ~20 min). Cache the current token
|
||||||
|
# and its expiry so back-to-back client creations reuse a single refresh.
|
||||||
|
_token_lock = threading.Lock()
|
||||||
|
_cached_token: str = ""
|
||||||
|
_cached_token_expires_at: float = 0.0
|
||||||
|
# Refresh a little early so a token never expires mid-request.
|
||||||
|
_TOKEN_EXPIRY_SKEW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
"""True when every credential needed to talk to MYOB is present."""
|
||||||
|
return MYOB_ENABLED
|
||||||
|
|
||||||
|
|
||||||
|
def _cf_token() -> str:
|
||||||
|
raw = f"{MYOB_CF_USERNAME}:{MYOB_CF_PASSWORD}".encode("utf-8")
|
||||||
|
return base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_access_token() -> str:
|
||||||
|
"""Exchange the stored refresh token for a fresh access token.
|
||||||
|
|
||||||
|
Caches the result in module state. Raises :class:`MyobError` on failure.
|
||||||
|
"""
|
||||||
|
global _cached_token, _cached_token_expires_at
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
MYOB_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": MYOB_API_KEY,
|
||||||
|
"client_secret": MYOB_API_SECRET,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": MYOB_REFRESH_TOKEN,
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=MYOB_HTTP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise MyobError(f"Could not reach the MYOB token service: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise MyobError(
|
||||||
|
f"MYOB token refresh failed (HTTP {resp.status_code}). "
|
||||||
|
"Check MYOB_API_KEY / MYOB_API_SECRET / MYOB_REFRESH_TOKEN."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise MyobError("MYOB token service returned an unreadable response.") from exc
|
||||||
|
|
||||||
|
token = str(payload.get("access_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise MyobError("MYOB token service did not return an access token.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires_in = int(payload.get("expires_in", 1200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
expires_in = 1200
|
||||||
|
|
||||||
|
_cached_token = token
|
||||||
|
_cached_token_expires_at = time.monotonic() + max(0, expires_in - _TOKEN_EXPIRY_SKEW_SECONDS)
|
||||||
|
logger.info("MYOB: access token refreshed (valid ~%ds)", expires_in)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _access_token() -> str:
|
||||||
|
with _token_lock:
|
||||||
|
if _cached_token and time.monotonic() < _cached_token_expires_at:
|
||||||
|
return _cached_token
|
||||||
|
return _refresh_access_token()
|
||||||
|
|
||||||
|
|
||||||
|
def _api_headers() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {_access_token()}",
|
||||||
|
"x-myobapi-key": MYOB_API_KEY,
|
||||||
|
"x-myobapi-version": "v2",
|
||||||
|
"x-myobapi-cftoken": _cf_token(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _split_name(full_name: str) -> tuple[str, str]:
|
||||||
|
"""Best-effort split of a single owner name into first/last for MYOB."""
|
||||||
|
parts = full_name.split()
|
||||||
|
if not parts:
|
||||||
|
return ("", "")
|
||||||
|
if len(parts) == 1:
|
||||||
|
return (parts[0], "")
|
||||||
|
return (parts[0], " ".join(parts[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
def create_customer(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
full_name: str,
|
||||||
|
phone: str = "",
|
||||||
|
street: str = "",
|
||||||
|
source: str = "",
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Create an individual customer contact in MYOB.
|
||||||
|
|
||||||
|
Returns ``{"uid": ..., "uri": ...}`` parsed from the ``Location`` header MYOB
|
||||||
|
returns on a successful create. Raises :class:`MyobError` on any failure so
|
||||||
|
the caller can record it without aborting the local client creation.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
raise MyobError("MYOB integration is not configured.")
|
||||||
|
|
||||||
|
first_name, last_name = _split_name(full_name)
|
||||||
|
body = {
|
||||||
|
"IsIndividual": True,
|
||||||
|
"FirstName": first_name,
|
||||||
|
"LastName": last_name,
|
||||||
|
"IsActive": True,
|
||||||
|
"Addresses": [
|
||||||
|
{
|
||||||
|
"Location": 1,
|
||||||
|
"Email": email,
|
||||||
|
"Phone1": phone,
|
||||||
|
"Street": street,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if source:
|
||||||
|
body["Notes"] = f"Added via Goodwalk control panel — source: {source}"
|
||||||
|
|
||||||
|
url = f"{MYOB_COMPANY_FILE_URI}/Contact/Customer"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
json=body,
|
||||||
|
headers=_api_headers(),
|
||||||
|
timeout=MYOB_HTTP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise MyobError(f"Could not reach MYOB: {exc}") from exc
|
||||||
|
|
||||||
|
# 200/201 both indicate success depending on API version; MYOB returns the
|
||||||
|
# new record's URI in the Location header.
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
detail = _error_detail(resp)
|
||||||
|
raise MyobError(f"MYOB rejected the customer (HTTP {resp.status_code}): {detail}")
|
||||||
|
|
||||||
|
location = resp.headers.get("Location", "")
|
||||||
|
uid = location.rstrip("/").rsplit("/", 1)[-1] if location else ""
|
||||||
|
logger.info("MYOB: created customer uid=%s for %s", uid or "?", email)
|
||||||
|
return {"uid": uid, "uri": location}
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(resp: requests.Response) -> str:
|
||||||
|
"""Extract a human-readable error message from a MYOB error response."""
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return (resp.text or "").strip()[:300] or "no detail provided"
|
||||||
|
errors = data.get("Errors") if isinstance(data, dict) else None
|
||||||
|
if isinstance(errors, list) and errors:
|
||||||
|
messages = [str(e.get("Message", "")).strip() for e in errors if isinstance(e, dict)]
|
||||||
|
joined = "; ".join(m for m in messages if m)
|
||||||
|
if joined:
|
||||||
|
return joined
|
||||||
|
return str(data)[:300]
|
||||||
+1856
-156
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,4 @@ resend>=2.0
|
|||||||
pydantic[email]>=2.10
|
pydantic[email]>=2.10
|
||||||
asyncpg>=0.30
|
asyncpg>=0.30
|
||||||
weasyprint>=63
|
weasyprint>=63
|
||||||
|
requests>=2.32
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
$env:DEV_MODE = '1'
|
||||||
|
if (-not $env:OWNER_EMAIL) {
|
||||||
|
$env:OWNER_EMAIL = 'info@goodwalk.co.nz'
|
||||||
|
}
|
||||||
|
|
||||||
|
$python = Join-Path $PSScriptRoot '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $python)) {
|
||||||
|
throw "Missing virtualenv interpreter at $python. Create it first with: py -3.14 -m venv .venv"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $python -m uvicorn main:app --host 127.0.0.1 --port 8000 --reload
|
||||||
+56
-134
@@ -4,14 +4,19 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<title>Be right back | Goodwalk</title>
|
<title>Back soon | Goodwalk</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Readex+Pro:wght@400;500&family=Unbounded:wght@700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--green: #213021;
|
--green: #213021;
|
||||||
--green-soft: #2c3f2c;
|
|
||||||
--yellow: #ffd100;
|
--yellow: #ffd100;
|
||||||
--ink: #1a1a1a;
|
--off-white: #f8f7f2;
|
||||||
--muted: #4c5056;
|
--line: rgba(255, 255, 255, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -21,166 +26,83 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
min-height: 100%;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(255, 209, 0, 0.12), transparent 55%),
|
|
||||||
radial-gradient(circle at bottom right, rgba(255, 209, 0, 0.08), transparent 60%),
|
|
||||||
var(--green);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 32px;
|
padding: 24px;
|
||||||
line-height: 1.5;
|
background: var(--green);
|
||||||
|
color: var(--off-white);
|
||||||
|
font-family: "Readex Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage {
|
main {
|
||||||
width: 100%;
|
width: min(100%, 30rem);
|
||||||
max-width: 560px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-bar img {
|
|
||||||
display: block;
|
|
||||||
height: 44px;
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 44px 40px 38px;
|
|
||||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card::before {
|
.logo {
|
||||||
content: "";
|
height: 36px;
|
||||||
position: absolute;
|
width: auto;
|
||||||
inset: 0 0 auto 0;
|
margin-bottom: 40px;
|
||||||
height: 6px;
|
|
||||||
background: var(--yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paw {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
margin: 4px auto 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(33, 48, 33, 0.08);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paw svg {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--green);
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 16px;
|
||||||
font-size: clamp(28px, 4vw, 38px);
|
font-family: "Unbounded", sans-serif;
|
||||||
|
font-size: clamp(1.6rem, 5vw, 2.1rem);
|
||||||
|
font-weight: 800;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
color: var(--ink);
|
color: var(--off-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0 0 12px;
|
margin: 0 auto;
|
||||||
color: var(--muted);
|
max-width: 26rem;
|
||||||
font-size: 16px;
|
color: rgba(248, 247, 242, 0.82);
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.contact {
|
||||||
margin-top: 24px;
|
margin-top: 32px;
|
||||||
padding-top: 22px;
|
padding-top: 28px;
|
||||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
border-top: 1px solid var(--line);
|
||||||
font-size: 14px;
|
font-size: 0.95rem;
|
||||||
color: var(--muted);
|
color: rgba(248, 247, 242, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta a {
|
.contact a {
|
||||||
color: var(--green);
|
color: var(--yellow);
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 209, 0, 0.4);
|
||||||
|
padding-bottom: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta a:hover {
|
.contact a:hover {
|
||||||
text-decoration: underline;
|
border-bottom-color: var(--yellow);
|
||||||
}
|
|
||||||
|
|
||||||
.footnote {
|
|
||||||
margin-top: 22px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.card {
|
|
||||||
padding: 36px 24px 28px;
|
|
||||||
border-radius: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-bar img {
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="stage">
|
<main>
|
||||||
<div class="brand-bar">
|
<img class="logo" src="/m/logo.png" alt="Goodwalk" width="241" height="48" />
|
||||||
<img src="/m/logo.png" alt="Goodwalk" width="241" height="48" />
|
<h1>Back on the lead shortly</h1>
|
||||||
</div>
|
<p>
|
||||||
|
We’re making a few quick updates. The site will be back to normal in a moment — thanks for your patience.
|
||||||
<main class="card" role="main">
|
</p>
|
||||||
<span class="paw" aria-hidden="true">
|
<p class="contact">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
Need us now? Email
|
||||||
<path d="M7.5 10.5c1.1 0 2-1.3 2-3s-.9-3-2-3-2 1.3-2 3 .9 3 2 3Zm9 0c1.1 0 2-1.3 2-3s-.9-3-2-3-2 1.3-2 3 .9 3 2 3ZM4 13.5c1 0 1.8-1.1 1.8-2.5S5 8.5 4 8.5s-1.8 1.1-1.8 2.5S3 13.5 4 13.5Zm16 0c1 0 1.8-1.1 1.8-2.5S21 8.5 20 8.5s-1.8 1.1-1.8 2.5.8 2.5 1.8 2.5ZM12 13.5c-3.3 0-6 2.5-6 5.4 0 1.4 1.1 2.6 2.5 2.6 1 0 1.5-.4 2.2-.8.4-.2.8-.4 1.3-.4s.9.2 1.3.4c.7.4 1.2.8 2.2.8 1.4 0 2.5-1.2 2.5-2.6 0-2.9-2.7-5.4-6-5.4Z"/>
|
<a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>
|
||||||
</svg>
|
or call <a href="tel:+64226421011">(022) 642 1011</a>.
|
||||||
</span>
|
</p>
|
||||||
|
</main>
|
||||||
<p class="eyebrow">Goodwalk</p>
|
|
||||||
<h1>Be right back!</h1>
|
|
||||||
<p>We're updating the site — should only take a minute. Thanks for your patience.</p>
|
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
Need us now? Email <a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>
|
|
||||||
or call <a href="tel:+64226421011">(022) 642 1011</a>.
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<p class="footnote">Auckland Central dog walking · Tiny Gang pack walks · 1:1 walks · Puppy visits</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Auto-recheck once a minute so visitors don't sit on this page forever.
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gw-svelte",
|
"name": "gw-svelte",
|
||||||
"version": "4.0.1",
|
"version": "4.0.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -277,6 +277,17 @@ if [[ -f "$DEPLOY_PATH/.env" ]]; then
|
|||||||
echo "[deploy-remote] Preserving existing $DEPLOY_PATH/.env"
|
echo "[deploy-remote] Preserving existing $DEPLOY_PATH/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The application source tree is fully owned by the repo and is only used as
|
||||||
|
# Docker build context — it holds no runtime data. The rsync below uses no
|
||||||
|
# --delete, so a file removed or renamed in the repo (e.g. a moved SvelteKit
|
||||||
|
# route) would otherwise linger here and break the build with a route conflict.
|
||||||
|
# Wipe src/ first so it is rebuilt exactly from the archive. Runtime data,
|
||||||
|
# .env, and Docker volumes live outside src/ and are untouched.
|
||||||
|
if [[ -d "$DEPLOY_PATH/src" ]]; then
|
||||||
|
echo "[deploy-remote] Refreshing $DEPLOY_PATH/src from archive (pruning stale files)"
|
||||||
|
rm -rf "$DEPLOY_PATH/src"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[deploy-remote] Copying application files into $DEPLOY_PATH"
|
echo "[deploy-remote] Copying application files into $DEPLOY_PATH"
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
rsync -a \
|
rsync -a \
|
||||||
|
|||||||
+15
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
import { resolveSurface } from '$lib/server/surface';
|
import { resolveSurface } from '$lib/server/surface';
|
||||||
import { resolveAnonId, resolveHeroVariant } from '$lib/server/ab';
|
import { resolveAnonId, resolveHeroVariant } from '$lib/server/ab';
|
||||||
|
|
||||||
@@ -8,6 +9,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
const { surface } = resolveSurface(event.url, event.cookies);
|
const { surface } = resolveSurface(event.url, event.cookies);
|
||||||
const path = event.url.pathname;
|
const path = event.url.pathname;
|
||||||
|
|
||||||
|
// Design variant previews are not for production. The whole /variants
|
||||||
|
// namespace returns 500 off-localhost so unfinished work never leaks to the
|
||||||
|
// public site. Localhost (the dev server, or a local prod build) is allowed.
|
||||||
|
const onLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(event.url.hostname);
|
||||||
|
if (!dev && !onLocalhost && (path === '/variants' || path.startsWith('/variants/'))) {
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
// Sticky A/B assignment, marketing surface only — no point polluting the
|
// Sticky A/B assignment, marketing surface only — no point polluting the
|
||||||
// owner/clients hosts with marketing-experiment cookies, and it skews
|
// owner/clients hosts with marketing-experiment cookies, and it skews
|
||||||
// exposure counts when staff hit the public site from the dashboard.
|
// exposure counts when staff hit the public site from the dashboard.
|
||||||
@@ -16,8 +25,12 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
event.locals.abHero = resolveHeroVariant(event.url, event.cookies);
|
event.locals.abHero = resolveHeroVariant(event.url, event.cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin host (cp.*) serves the dashboard at its root.
|
// The admin host (cp.*) serves the dashboard at its root. In production the
|
||||||
if (surface === 'cp' && (path === '/' || path === '')) {
|
// universal `reroute` hook maps / (and /clients, /birthdays, …) onto the
|
||||||
|
// internal /owner/welcome routes without changing the URL. In dev the cp
|
||||||
|
// surface is reached via ?preview=cp on localhost, where reroute can't detect
|
||||||
|
// the host, so fall back to redirecting the root to the dashboard path.
|
||||||
|
if (dev && surface === 'cp' && (path === '/' || path === '')) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: { location: ADMIN_PATH },
|
headers: { location: ADMIN_PATH },
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Reroute } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean URLs for the control panel.
|
||||||
|
*
|
||||||
|
* The owner dashboard lives internally at /owner/welcome (home) and
|
||||||
|
* /owner/welcome/<tab>. On the cp.* / admin.* hosts we serve it at the root so
|
||||||
|
* the visible URLs are /, /clients, /birthdays, /messaging, /scheduled and
|
||||||
|
* /activity. This hook rewrites those clean paths to the real route without
|
||||||
|
* changing the URL in the browser, and runs on both the server and the client
|
||||||
|
* so SSR and client-side navigation stay in sync.
|
||||||
|
*
|
||||||
|
* Keep CP_HOSTS in sync with $lib/server/surface.ts — it can't be imported here
|
||||||
|
* because this hook also runs in the browser and $lib/server is server-only.
|
||||||
|
*/
|
||||||
|
const CP_HOSTS = new Set(['cp.goodwalk.co.nz', 'admin.goodwalk.co.nz']);
|
||||||
|
const ADMIN_BASE = '/owner/welcome';
|
||||||
|
const CP_TABS = new Set(['clients', 'birthdays', 'messaging', 'scheduled', 'activity']);
|
||||||
|
|
||||||
|
export const reroute: Reroute = ({ url }) => {
|
||||||
|
if (!CP_HOSTS.has(url.hostname.toLowerCase())) return;
|
||||||
|
|
||||||
|
// Already addressing the internal admin routes (e.g. a direct hit) — leave it.
|
||||||
|
if (url.pathname === ADMIN_BASE || url.pathname.startsWith(`${ADMIN_BASE}/`)) return;
|
||||||
|
|
||||||
|
const segment = url.pathname.replace(/^\/+/, '').split('/')[0];
|
||||||
|
if (segment === '') return ADMIN_BASE; // / → dashboard home
|
||||||
|
if (CP_TABS.has(segment)) return `${ADMIN_BASE}/${segment}`;
|
||||||
|
|
||||||
|
// Anything else (static assets, /owner/experiments, …) routes normally.
|
||||||
|
};
|
||||||
+186
-19
@@ -1,6 +1,7 @@
|
|||||||
type GtagFn = (...args: unknown[]) => void;
|
type GtagFn = (...args: unknown[]) => void;
|
||||||
|
|
||||||
const TEXT_LIMIT = 80;
|
const TEXT_LIMIT = 80;
|
||||||
|
const EVENT_NAME_LIMIT = 40;
|
||||||
|
|
||||||
function gtag(): GtagFn | null {
|
function gtag(): GtagFn | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
@@ -8,10 +9,156 @@ function gtag(): GtagFn | null {
|
|||||||
return typeof fn === 'function' ? fn : null;
|
return typeof fn === 'function' ? fn : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackPageView(path: string, title: string): void {
|
function sanitizeValue(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, ' ').slice(0, TEXT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEventName(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, EVENT_NAME_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const JOURNEY_STORAGE_KEY = 'goodwalk:journey:v1';
|
||||||
|
const JOURNEY_BUFFER_LIMIT = 30;
|
||||||
|
|
||||||
|
type JourneyEntry = {
|
||||||
|
name: string;
|
||||||
|
page_path: string;
|
||||||
|
label: string;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pushJourneyBuffer(entry: JourneyEntry): void {
|
||||||
|
if (typeof sessionStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);
|
||||||
|
const list: JourneyEntry[] = raw ? JSON.parse(raw) : [];
|
||||||
|
list.push(entry);
|
||||||
|
if (list.length > JOURNEY_BUFFER_LIMIT) list.splice(0, list.length - JOURNEY_BUFFER_LIMIT);
|
||||||
|
sessionStorage.setItem(JOURNEY_STORAGE_KEY, JSON.stringify(list));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage can throw on quota / private mode — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJourneyBuffer(): JourneyEntry[] {
|
||||||
|
if (typeof sessionStorage === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as JourneyEntry[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearJourneyBuffer(): void {
|
||||||
|
if (typeof sessionStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(JOURNEY_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToServerTrack(payload: Record<string, unknown>): void {
|
||||||
|
if (typeof navigator === 'undefined') return;
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
// sendBeacon survives page unloads (link clicks, form submits) where a
|
||||||
|
// regular fetch would be cancelled. Falls back to fetch+keepalive.
|
||||||
|
if (typeof navigator.sendBeacon === 'function') {
|
||||||
|
const blob = new Blob([body], { type: 'application/json' });
|
||||||
|
if (navigator.sendBeacon('/api/track', blob)) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fetch('/api/track', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// analytics must never throw into the UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackEvent(eventName: string, params: Record<string, unknown> = {}): void {
|
||||||
|
const normalizedName = sanitizeEventName(eventName);
|
||||||
|
if (!normalizedName) return;
|
||||||
|
|
||||||
|
const enriched: Record<string, unknown> = {
|
||||||
|
page_path: window.location.pathname + window.location.search,
|
||||||
|
page_location: window.location.href,
|
||||||
|
...params
|
||||||
|
};
|
||||||
|
|
||||||
const send = gtag();
|
const send = gtag();
|
||||||
if (!send) return;
|
const gtagMissing = !send;
|
||||||
send('event', 'page_view', {
|
if (send) {
|
||||||
|
send('event', normalizedName, enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always fire to /api/track for two reasons:
|
||||||
|
// 1. Persists into session_events for journey reconstruction on submit.
|
||||||
|
// 2. When gtag is missing (ad blocker), `forward_ga4: true` tells the
|
||||||
|
// server to also forward to GA4 so we don't lose the hit.
|
||||||
|
// De-dup: when gtag IS present, GA4 receives the event exactly once
|
||||||
|
// (client). When gtag is missing, GA4 receives it exactly once (server).
|
||||||
|
sendToServerTrack({
|
||||||
|
name: normalizedName,
|
||||||
|
params: enriched,
|
||||||
|
forward_ga4: gtagMissing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client-side rolling buffer used as a fallback inside /api/track/promote
|
||||||
|
// for environments where the server-side write was also blocked.
|
||||||
|
pushJourneyBuffer({
|
||||||
|
name: normalizedName,
|
||||||
|
page_path: typeof enriched.page_path === 'string' ? enriched.page_path : '',
|
||||||
|
label: typeof enriched.label === 'string' ? enriched.label : '',
|
||||||
|
ts: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the booking form on successful submission to link the
|
||||||
|
* visitor's recent browsing journey to their submitted email. See
|
||||||
|
* docs/server-side-analytics.md for the full flow.
|
||||||
|
*/
|
||||||
|
export function promoteJourney(email: string): void {
|
||||||
|
if (typeof navigator === 'undefined') return;
|
||||||
|
const trimmed = email.trim().toLowerCase();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
email: trimmed,
|
||||||
|
client_events: readJourneyBuffer()
|
||||||
|
});
|
||||||
|
if (typeof navigator.sendBeacon === 'function') {
|
||||||
|
const blob = new Blob([payload], { type: 'application/json' });
|
||||||
|
if (navigator.sendBeacon('/api/track/promote', blob)) {
|
||||||
|
clearJourneyBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fetch('/api/track/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: payload,
|
||||||
|
keepalive: true
|
||||||
|
})
|
||||||
|
.then(() => clearJourneyBuffer())
|
||||||
|
.catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPageView(path: string, title: string): void {
|
||||||
|
trackEvent('page_view', {
|
||||||
page_path: path,
|
page_path: path,
|
||||||
page_title: title,
|
page_title: title,
|
||||||
page_location: window.location.href
|
page_location: window.location.href
|
||||||
@@ -20,11 +167,11 @@ export function trackPageView(path: string, title: string): void {
|
|||||||
|
|
||||||
function getLabel(el: HTMLElement): string {
|
function getLabel(el: HTMLElement): string {
|
||||||
const aria = el.getAttribute('aria-label');
|
const aria = el.getAttribute('aria-label');
|
||||||
if (aria) return aria.trim().slice(0, TEXT_LIMIT);
|
if (aria) return sanitizeValue(aria);
|
||||||
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
const text = sanitizeValue(el.textContent ?? '');
|
||||||
if (text) return text.slice(0, TEXT_LIMIT);
|
if (text) return text;
|
||||||
const title = el.getAttribute('title');
|
const title = el.getAttribute('title');
|
||||||
if (title) return title.trim().slice(0, TEXT_LIMIT);
|
if (title) return sanitizeValue(title);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,38 +188,58 @@ function getLocation(el: HTMLElement): string {
|
|||||||
return 'body';
|
return 'body';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTextParam(el: HTMLElement): string {
|
||||||
|
const explicit = el.dataset.trackText;
|
||||||
|
if (explicit) return sanitizeValue(explicit);
|
||||||
|
return getLabel(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHrefParams(href: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.href);
|
||||||
|
return {
|
||||||
|
link_url: href,
|
||||||
|
destination_path: `${url.pathname}${url.search}${url.hash}`,
|
||||||
|
destination_host: url.hostname,
|
||||||
|
outbound: url.hostname !== window.location.hostname
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
link_url: href,
|
||||||
|
outbound: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initClickTracking(): () => void {
|
export function initClickTracking(): () => void {
|
||||||
if (typeof window === 'undefined') return () => {};
|
if (typeof window === 'undefined') return () => {};
|
||||||
|
|
||||||
const handler = (event: MouseEvent) => {
|
const handler = (event: MouseEvent) => {
|
||||||
const send = gtag();
|
|
||||||
if (!send) return;
|
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof Element)) return;
|
if (!(target instanceof Element)) return;
|
||||||
const interactive = target.closest<HTMLElement>('a, button, [role="button"]');
|
const interactive = target.closest<HTMLElement>('a, button, [role="button"]');
|
||||||
if (!interactive) return;
|
if (!interactive) return;
|
||||||
|
|
||||||
const isLink = interactive.tagName === 'A';
|
const isLink = interactive.tagName === 'A';
|
||||||
const label = getLabel(interactive);
|
const eventName = interactive.dataset.trackEvent || (isLink ? 'link_click' : 'button_click');
|
||||||
|
const label = interactive.dataset.trackLabel ? sanitizeValue(interactive.dataset.trackLabel) : getLabel(interactive);
|
||||||
const location = getLocation(interactive);
|
const location = getLocation(interactive);
|
||||||
|
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
element: isLink ? 'link' : 'button',
|
element: isLink ? 'link' : 'button',
|
||||||
label,
|
label,
|
||||||
location,
|
location,
|
||||||
page_path: window.location.pathname
|
text: getTextParam(interactive)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (interactive.dataset.trackType) params.track_type = sanitizeValue(interactive.dataset.trackType);
|
||||||
|
if (interactive.dataset.trackContext) params.track_context = sanitizeValue(interactive.dataset.trackContext);
|
||||||
|
if (interactive.dataset.trackVariant) params.track_variant = sanitizeValue(interactive.dataset.trackVariant);
|
||||||
|
|
||||||
if (isLink) {
|
if (isLink) {
|
||||||
const href = (interactive as HTMLAnchorElement).href;
|
const href = (interactive as HTMLAnchorElement).href;
|
||||||
if (href) {
|
if (href) {
|
||||||
params.link_url = href;
|
Object.assign(params, getHrefParams(href));
|
||||||
try {
|
|
||||||
const url = new URL(href, window.location.href);
|
|
||||||
params.outbound = url.hostname !== window.location.hostname;
|
|
||||||
} catch {
|
|
||||||
params.outbound = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const btn = interactive as HTMLButtonElement;
|
const btn = interactive as HTMLButtonElement;
|
||||||
@@ -80,7 +247,7 @@ export function initClickTracking(): () => void {
|
|||||||
if (btn.name) params.button_name = btn.name;
|
if (btn.name) params.button_name = btn.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
send('event', isLink ? 'link_click' : 'button_click', params);
|
trackEvent(eventName, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('click', handler, { capture: true });
|
document.addEventListener('click', handler, { capture: true });
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,929 +0,0 @@
|
|||||||
<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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let disabled = false;
|
||||||
|
export let placeholder = 'Write your message…';
|
||||||
|
// Optional one-tap insert chips, e.g. the client's first name / dog name.
|
||||||
|
export let tokens: { label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ input: string }>();
|
||||||
|
|
||||||
|
let editor: HTMLDivElement;
|
||||||
|
let focused = false;
|
||||||
|
// Remember the HTML we last pushed into the DOM so external `value` changes
|
||||||
|
// (presets, reopening the dialog) update the editor without fighting the
|
||||||
|
// caret while the owner is actively typing.
|
||||||
|
let lastSet = '';
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const html = editor?.innerHTML ?? '';
|
||||||
|
value = html;
|
||||||
|
lastSet = html;
|
||||||
|
dispatch('input', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (editor && value !== lastSet && !focused) {
|
||||||
|
editor.innerHTML = value || '';
|
||||||
|
lastSet = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (editor) {
|
||||||
|
editor.innerHTML = value || '';
|
||||||
|
lastSet = value || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function exec(command: string, arg?: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
document.execCommand(command, false, arg);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn the selected line(s) into a heading (or back to a normal paragraph).
|
||||||
|
// The bracketed tag form is the one browsers accept consistently for formatBlock.
|
||||||
|
function setBlock(tag: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
document.execCommand('formatBlock', false, `<${tag}>`);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLink() {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
const url = window.prompt('Link address (e.g. https://goodwalk.co.nz)');
|
||||||
|
if (!url) return;
|
||||||
|
const trimmed = url.trim();
|
||||||
|
const href = /^(https?:|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
document.execCommand('createLink', false, href);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertToken(token: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
document.execCommand('insertText', false, token);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text view of the content drives the placeholder + lets the parent
|
||||||
|
// validate that the message isn't empty.
|
||||||
|
$: plainText = (value || '').replace(/<[^>]*>/g, '').replace(/ /g, ' ').trim();
|
||||||
|
$: isEmpty = plainText.length === 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rte" class:rte-disabled={disabled}>
|
||||||
|
<div class="rte-toolbar">
|
||||||
|
<button type="button" class="rte-btn rte-btn-text" title="Large heading" aria-label="Large heading" on:click={() => setBlock('h1')} disabled={disabled}>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rte-btn rte-btn-text" title="Medium heading" aria-label="Medium heading" on:click={() => setBlock('h2')} disabled={disabled}>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rte-btn rte-btn-text" title="Small heading" aria-label="Small heading" on:click={() => setBlock('h3')} disabled={disabled}>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rte-btn" title="Normal text" aria-label="Normal text" on:click={() => setBlock('p')} disabled={disabled}>
|
||||||
|
<Icon name="fas fa-paragraph" />
|
||||||
|
</button>
|
||||||
|
<span class="rte-sep" aria-hidden="true"></span>
|
||||||
|
<button type="button" class="rte-btn" title="Bold" aria-label="Bold" on:click={() => exec('bold')} disabled={disabled}>
|
||||||
|
<Icon name="fas fa-bold" />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rte-btn" title="Italic" aria-label="Italic" on:click={() => exec('italic')} disabled={disabled}>
|
||||||
|
<Icon name="fas fa-italic" />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rte-btn" title="Bulleted list" aria-label="Bulleted list" on:click={() => exec('insertUnorderedList')} disabled={disabled}>
|
||||||
|
<Icon name="fas fa-list-ul" />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rte-btn" title="Add a link" aria-label="Add a link" on:click={addLink} disabled={disabled}>
|
||||||
|
<Icon name="fas fa-link" />
|
||||||
|
</button>
|
||||||
|
{#if tokens.length}
|
||||||
|
<span class="rte-sep" aria-hidden="true"></span>
|
||||||
|
{#each tokens as t}
|
||||||
|
<button type="button" class="rte-token" on:click={() => insertToken(t.value)} disabled={disabled}>
|
||||||
|
+ {t.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rte-surface">
|
||||||
|
<div
|
||||||
|
class="rte-input"
|
||||||
|
bind:this={editor}
|
||||||
|
contenteditable={!disabled}
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label="Message"
|
||||||
|
tabindex="0"
|
||||||
|
on:input={sync}
|
||||||
|
on:focus={() => (focused = true)}
|
||||||
|
on:blur={() => { focused = false; sync(); }}
|
||||||
|
></div>
|
||||||
|
{#if isEmpty}
|
||||||
|
<div class="rte-placeholder" aria-hidden="true">{placeholder}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rte {
|
||||||
|
border: 1px solid rgba(33, 48, 33, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f4f5f2;
|
||||||
|
border-bottom: 1px solid rgba(33, 48, 33, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(33, 48, 33, 0.14);
|
||||||
|
background: #fff;
|
||||||
|
color: #213021;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-btn:hover:not(:disabled) {
|
||||||
|
background: var(--yellow, #ffd100);
|
||||||
|
border-color: var(--yellow, #ffd100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-btn-text {
|
||||||
|
width: auto;
|
||||||
|
min-width: 32px;
|
||||||
|
padding: 0 9px;
|
||||||
|
font-family: var(--font-head, inherit);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgba(33, 48, 33, 0.16);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px dashed rgba(33, 48, 33, 0.28);
|
||||||
|
background: #fff;
|
||||||
|
color: #213021;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-token:hover:not(:disabled) {
|
||||||
|
background: #213021;
|
||||||
|
border-color: #213021;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-token:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-surface {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input {
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #213021;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 209, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tidy the rich content the owner produces. */
|
||||||
|
.rte-input :global(p) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input :global(h1) {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #171b20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input :global(h2) {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #171b20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input :global(h3) {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #213021;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input :global(ul),
|
||||||
|
.rte-input :global(ol) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-input :global(a) {
|
||||||
|
color: #213021;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #9aa39a;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { accordion } from '$lib/actions/accordion';
|
import { accordion } from '$lib/actions/accordion';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import CtaCard from '$lib/components/CtaCard.svelte';
|
import CtaCard from '$lib/components/ui/CtaCard.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import ServiceHero from '$lib/components/ServiceHero.svelte';
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import type { AboutPageContent } from '$lib/types';
|
import type { AboutPageContent } from '$lib/types';
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BookingWizard from '$lib/components/BookingWizard.svelte';
|
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import InfoSection from '$lib/components/InfoSection.svelte';
|
import InfoSection from '$lib/components/sections/InfoSection.svelte';
|
||||||
import ServiceHero from '$lib/components/ServiceHero.svelte';
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
import type { BookingContent, InfoContent } from '$lib/types';
|
import type { BookingContent, InfoContent } from '$lib/types';
|
||||||
|
|
||||||
export let booking: BookingContent;
|
export let booking: BookingContent;
|
||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import OnboardingSignaturePad from '$lib/components/OnboardingSignaturePad.svelte';
|
import OnboardingSignaturePad from '$lib/components/ui/OnboardingSignaturePad.svelte';
|
||||||
import OnboardingAuth from '$lib/components/OnboardingAuth.svelte';
|
import OnboardingAuth from '$lib/components/sections/OnboardingAuth.svelte';
|
||||||
import OnboardingFooter from '$lib/components/OnboardingFooter.svelte';
|
import OnboardingFooter from '$lib/components/ui/OnboardingFooter.svelte';
|
||||||
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
||||||
import type { Picture } from '@sveltejs/enhanced-img';
|
import type { Picture } from '@sveltejs/enhanced-img';
|
||||||
|
|
||||||
@@ -847,7 +847,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
<OnboardingFooter on:logout={handleLogout} />
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Clarity homepage design (the live homepage as of the swap).
|
||||||
|
*
|
||||||
|
* Purpose-built green hero that says the specialism in the first second
|
||||||
|
* (small & medium dogs), a founder philosophy strip, then offer-first order:
|
||||||
|
* Services -> How it works -> Values -> the rest. A view component only: the
|
||||||
|
* route that renders it owns data loading, SEO head, and structured data.
|
||||||
|
*
|
||||||
|
* Paired design: HomeClassic.svelte. To roll back, swap the component the
|
||||||
|
* homepage route renders. See src/routes/+page.svelte.
|
||||||
|
*/
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
import HowItWorksSection from '$lib/components/sections/HowItWorksSection.svelte';
|
||||||
|
import InfoSection from '$lib/components/sections/InfoSection.svelte';
|
||||||
|
import InstagramSection from '$lib/components/sections/InstagramSection.svelte';
|
||||||
|
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||||
|
import FounderStorySection from '$lib/components/sections/FounderStorySection.svelte';
|
||||||
|
import ServicesSection from '$lib/components/sections/ServicesSection.svelte';
|
||||||
|
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||||
|
import ValuesSection from '$lib/components/sections/ValuesSection.svelte';
|
||||||
|
import type { HomePageContent } from '$lib/types';
|
||||||
|
import type { AbContext } from '$lib/ab';
|
||||||
|
|
||||||
|
export let content: HomePageContent;
|
||||||
|
export let ab: AbContext | undefined = undefined;
|
||||||
|
export let pagePath = '/';
|
||||||
|
// The booking form is swappable so variants can preview an alternate Meet &
|
||||||
|
// Greet form on the same homepage design. Defaults to the live wizard, so the
|
||||||
|
// homepage route renders unchanged. See /variants/meet-greet.
|
||||||
|
export let BookingComponent: typeof BookingWizard = BookingWizard;
|
||||||
|
|
||||||
|
const meetGreetVariantPath = '/variants/meet-greet';
|
||||||
|
$: isMeetGreetVariant = pagePath === meetGreetVariantPath;
|
||||||
|
const reviewCta = content.intro.reviewCta;
|
||||||
|
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||||
|
const stars = Array.from({ length: 5 });
|
||||||
|
$: heroLead = isMeetGreetVariant
|
||||||
|
? 'Small group walks, solo support, and puppy visits for Auckland dogs who do better with a calm, consistent routine.'
|
||||||
|
: 'Real adventures with the Tiny Gang, in small, matched groups across Auckland. Same walker every time, so your dog comes home happy, tired, and ready to settle.';
|
||||||
|
$: founderQuote = isMeetGreetVariant
|
||||||
|
? "Small groups, careful matching, and the same walker each time. That's how a walk feels safe, social, and worth looking forward to."
|
||||||
|
: "Every dog gets a walk built around them, not a one-size pack: the right small group, a walker who knows their name and their quirks, and adventures that grow with their confidence. That's how a walk becomes the best part of a dog's day.";
|
||||||
|
$: servicesHeading = isMeetGreetVariant
|
||||||
|
? 'Choose the setup that fits your dog.'
|
||||||
|
: undefined;
|
||||||
|
$: servicesIntro = isMeetGreetVariant
|
||||||
|
? 'Pack walks for social dogs, solo walks for dogs who need more space, and puppy visits for the early routine.'
|
||||||
|
: undefined;
|
||||||
|
$: howItWorksContent = isMeetGreetVariant
|
||||||
|
? {
|
||||||
|
...content.howItWorks,
|
||||||
|
intro: 'Start with a free Meet & Greet. We assess the fit, then build the right routine for your dog.'
|
||||||
|
}
|
||||||
|
: content.howItWorks;
|
||||||
|
$: bookingContent = isMeetGreetVariant
|
||||||
|
? {
|
||||||
|
...content.booking,
|
||||||
|
subtitle: 'Tell us a little about your dog. We reply within 24 hours to arrange your free Meet & Greet.'
|
||||||
|
}
|
||||||
|
: content.booking;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header navigation={content.navigation} />
|
||||||
|
|
||||||
|
<section class="chero" data-track-location="hero" aria-label="Goodwalk dog walking">
|
||||||
|
<div class="chero-inner">
|
||||||
|
<div class="chero-copy">
|
||||||
|
<p class="chero-eyebrow">Goodwalk · Auckland dog walking</p>
|
||||||
|
<h1 class="chero-title">
|
||||||
|
<span class="chero-title-lead">Specialist walks for small & medium dogs</span>
|
||||||
|
<span class="chero-title-main">Meet the Tiny Gang.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="chero-lead">{heroLead}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chero-media">
|
||||||
|
<div class="chero-photo">
|
||||||
|
<img
|
||||||
|
src={heroImage}
|
||||||
|
alt="Maya, a happy small dog, after a Goodwalk walk in Auckland"
|
||||||
|
width="1536"
|
||||||
|
height="1024"
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chero-actions">
|
||||||
|
<a
|
||||||
|
href="#newlead"
|
||||||
|
class="btn btn-yellow btn-with-arrow"
|
||||||
|
data-track-event="cta_click"
|
||||||
|
data-track-type="hero_primary"
|
||||||
|
data-track-label="Book a free Meet & Greet"
|
||||||
|
>
|
||||||
|
Book a free Meet & Greet
|
||||||
|
<Icon name="fas fa-arrow-right" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="chero-reviews"
|
||||||
|
href={reviewCta.href}
|
||||||
|
target={reviewCta.external ? '_blank' : undefined}
|
||||||
|
rel={reviewCta.external ? 'noopener' : undefined}
|
||||||
|
aria-label="30+ five-star Google reviews"
|
||||||
|
data-track-event="social_proof_click"
|
||||||
|
data-track-type="hero_reviews"
|
||||||
|
>
|
||||||
|
<span class="chero-reviews-mark" aria-hidden="true">
|
||||||
|
<img src="/images/google-g-logo.svg" alt="" width="15" height="16" />
|
||||||
|
</span>
|
||||||
|
<span class="chero-reviews-stars" aria-hidden="true">
|
||||||
|
{#each stars as _}
|
||||||
|
<Icon name="fas fa-star" />
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="chero-reviews-label">30+ five-star reviews</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Founder philosophy strip: the sincere counter to a competitor's
|
||||||
|
"total freedom in nature" pitch. Frames the upside the owner is paying
|
||||||
|
for: a day built around their dog, not a one-size pack. -->
|
||||||
|
<section class="cfounder" aria-label="Our approach">
|
||||||
|
<div class="cfounder-inner">
|
||||||
|
<figure class="cfounder-quote">
|
||||||
|
<blockquote class="cfounder-blockquote">
|
||||||
|
<p class="cfounder-text">
|
||||||
|
{founderQuote}
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
<figcaption class="cfounder-attr">
|
||||||
|
<img
|
||||||
|
class="cfounder-avatar"
|
||||||
|
src="/images/alessandra-goodwalk-founder-auckland.webp"
|
||||||
|
alt="Alessandra, founder of Goodwalk, walking a dog in Auckland"
|
||||||
|
width="96"
|
||||||
|
height="96"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<span class="cfounder-byline">
|
||||||
|
<span class="cfounder-name">Alessandra</span>
|
||||||
|
<span class="cfounder-role">Founder of Goodwalk</span>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Offer first: what can I actually book, and roughly what does it cost? -->
|
||||||
|
<ServicesSection services={content.services} heading={servicesHeading} intro={servicesIntro} />
|
||||||
|
|
||||||
|
<!-- Then the process: what happens after I enquire? Lowers the unknown. -->
|
||||||
|
<HowItWorksSection content={howItWorksContent} />
|
||||||
|
|
||||||
|
{#if isMeetGreetVariant}
|
||||||
|
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||||
|
<InfoSection info={content.info} />
|
||||||
|
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||||
|
{:else}
|
||||||
|
<!-- Only now the reasons to choose us. -->
|
||||||
|
<ValuesSection values={content.values} />
|
||||||
|
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||||
|
<FounderStorySection founderStory={content.founderStory} />
|
||||||
|
<InfoSection info={content.info} />
|
||||||
|
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||||
|
<InstagramSection instagram={content.instagram} />
|
||||||
|
{/if}
|
||||||
|
<Footer footer={content.footer} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Hero ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A single soft warm light, top-right, for depth. Not glassmorphism,
|
||||||
|
not a gradient headline: just one breath of the brand yellow. */
|
||||||
|
.chero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -28%;
|
||||||
|
right: -10%;
|
||||||
|
width: 60%;
|
||||||
|
height: 90%;
|
||||||
|
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.16), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: var(--max-w);
|
||||||
|
min-height: clamp(500px, 68vh, 640px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(48px, 5.5vw, 80px) var(--space-container-x);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||||
|
grid-template-areas:
|
||||||
|
'copy media'
|
||||||
|
'actions media';
|
||||||
|
column-gap: clamp(32px, 5vw, 76px);
|
||||||
|
row-gap: clamp(22px, 3vw, 32px);
|
||||||
|
align-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-copy {
|
||||||
|
grid-area: copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--white-rgb), 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 26px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-title-lead {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: clamp(18px, 2.1vw, 26px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: rgba(var(--white-rgb), 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-title-main {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(42px, 6.4vw, 78px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--yellow);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-lead {
|
||||||
|
margin: 22px 0 0;
|
||||||
|
max-width: 40ch;
|
||||||
|
font-size: clamp(16px, 1.3vw, 19px);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(var(--white-rgb), 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-actions {
|
||||||
|
grid-area: actions;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-reviews {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: rgba(var(--white-rgb), 0.9);
|
||||||
|
transition: color var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-reviews-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-page);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-reviews-mark img {
|
||||||
|
display: block;
|
||||||
|
width: 15px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-reviews-stars {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-reviews-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--white-rgb), 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.chero-reviews:hover {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-reviews:focus-visible {
|
||||||
|
outline: 2px solid var(--yellow);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media */
|
||||||
|
.chero-media {
|
||||||
|
grid-area: media;
|
||||||
|
position: relative;
|
||||||
|
justify-self: end;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-media::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 8% -6% -10% 10%;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: rgba(var(--accent-rgb), 0.22);
|
||||||
|
filter: blur(46px);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-photo {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
border-radius: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(var(--white-rgb), 0.12),
|
||||||
|
var(--shadow-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Founder philosophy strip ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cfounder {
|
||||||
|
background: var(--surface-panel-cream);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-inner {
|
||||||
|
max-width: 940px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(30px, 3.6vw, 48px) var(--space-container-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-quote {
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
padding-left: clamp(40px, 5vw, 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-blockquote {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A single large opening quote mark, brand yellow, as the editorial cue.
|
||||||
|
Keeps the strip recognisably a statement without a repeated eyebrow. */
|
||||||
|
.cfounder-quote::before {
|
||||||
|
content: '\201C';
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: -0.18em;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(64px, 9vw, 104px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-text {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 46ch;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: clamp(19px, 2.1vw, 27px);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-heading);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-attr {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: clamp(18px, 2.4vw, 26px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 25%;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 2px rgba(var(--white-rgb), 0.9),
|
||||||
|
var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-byline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-name {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-role {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entrance */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.chero-eyebrow,
|
||||||
|
.chero-title,
|
||||||
|
.chero-lead,
|
||||||
|
.chero-actions {
|
||||||
|
animation: cheroRise 0.6s var(--ease-out-soft) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-title {
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
.chero-lead {
|
||||||
|
animation-delay: 0.16s;
|
||||||
|
}
|
||||||
|
.chero-actions {
|
||||||
|
animation-delay: 0.26s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-photo {
|
||||||
|
animation: cheroPhoto 0.8s var(--ease-out-soft) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cheroRise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cheroPhoto {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile: one calm column, message first, then Maya, then action ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chero-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 28px var(--space-container-x-mobile) 36px;
|
||||||
|
row-gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-copy {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-media {
|
||||||
|
order: 2;
|
||||||
|
justify-self: stretch;
|
||||||
|
max-width: none;
|
||||||
|
margin: 24px 0 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-actions {
|
||||||
|
order: 3;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-photo {
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-lead {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chero-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-quote {
|
||||||
|
padding-left: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfounder-quote::before {
|
||||||
|
left: -2px;
|
||||||
|
top: -0.08em;
|
||||||
|
font-size: 52px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Classic homepage design (the original layout, pre-clarity swap).
|
||||||
|
*
|
||||||
|
* Hero -> Values -> Services -> How it works -> Testimonials -> Founder ->
|
||||||
|
* Info -> Booking -> Instagram. Preserved so the homepage can be rolled back
|
||||||
|
* to it by swapping the component rendered in src/routes/+page.svelte.
|
||||||
|
*
|
||||||
|
* Paired design: HomeClarity.svelte. A view component only: the route that
|
||||||
|
* renders it owns data loading, SEO head, and structured data.
|
||||||
|
*/
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import HeroSection from '$lib/components/sections/HeroSection.svelte';
|
||||||
|
import HowItWorksSection from '$lib/components/sections/HowItWorksSection.svelte';
|
||||||
|
import InfoSection from '$lib/components/sections/InfoSection.svelte';
|
||||||
|
import InstagramSection from '$lib/components/sections/InstagramSection.svelte';
|
||||||
|
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||||
|
import FounderStorySection from '$lib/components/sections/FounderStorySection.svelte';
|
||||||
|
import ServicesSection from '$lib/components/sections/ServicesSection.svelte';
|
||||||
|
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||||
|
import ValuesSection from '$lib/components/sections/ValuesSection.svelte';
|
||||||
|
import type { CallToAction, HomePageContent } from '$lib/types';
|
||||||
|
import type { AbContext } from '$lib/ab';
|
||||||
|
|
||||||
|
export let content: HomePageContent;
|
||||||
|
export let ab: AbContext | undefined = undefined;
|
||||||
|
export let primaryCtaOverride: CallToAction | undefined = undefined;
|
||||||
|
export let pagePath = '/';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header navigation={content.navigation} />
|
||||||
|
<HeroSection
|
||||||
|
hero={content.hero}
|
||||||
|
reviewCta={content.intro.reviewCta}
|
||||||
|
{primaryCtaOverride}
|
||||||
|
{ab}
|
||||||
|
/>
|
||||||
|
<ValuesSection values={content.values} />
|
||||||
|
<ServicesSection services={content.services} />
|
||||||
|
<HowItWorksSection content={content.howItWorks} />
|
||||||
|
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||||
|
<FounderStorySection founderStory={content.founderStory} />
|
||||||
|
<InfoSection info={content.info} />
|
||||||
|
<BookingWizard booking={content.booking} {pagePath} {ab} />
|
||||||
|
<InstagramSection instagram={content.instagram} />
|
||||||
|
<Footer footer={content.footer} />
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Services-focus homepage variant.
|
||||||
|
*
|
||||||
|
* Same design language as HomeClarity (green hero, yellow CTAs, the shared
|
||||||
|
* ServicesSection / ValuesSection / TestimonialsSection), but every section is
|
||||||
|
* pointed at one job: get the visitor to click through into a service page.
|
||||||
|
*
|
||||||
|
* The site gets traffic but few leads, so this variant removes the big-commitment
|
||||||
|
* booking form from the homepage and lowers the first ask to "pick the walk that
|
||||||
|
* fits your dog". The three services are offered as quick-picks in the hero
|
||||||
|
* itself, repeated as the main ServicesSection cards, reinforced with proof, and
|
||||||
|
* offered a final time in a closing band. A view component only: the route that
|
||||||
|
* renders it owns data loading, SEO head, and structured data.
|
||||||
|
*
|
||||||
|
* Preview route: /variants/services-first (noindex).
|
||||||
|
*/
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
import ServicesSection from '$lib/components/sections/ServicesSection.svelte';
|
||||||
|
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||||
|
import type { HomePageContent } from '$lib/types';
|
||||||
|
|
||||||
|
export let content: HomePageContent;
|
||||||
|
export let pagePath = '/variants/services-first';
|
||||||
|
|
||||||
|
const reviewCta = content.intro.reviewCta;
|
||||||
|
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||||
|
const stars = Array.from({ length: 5 });
|
||||||
|
|
||||||
|
// The assurance bar replaces the old grid of value cards: four plain facts,
|
||||||
|
// stated once. Each is a reason to trust the click, not a feature to skim.
|
||||||
|
const assurances = [
|
||||||
|
{ icon: 'fas fa-user-check', label: 'Same walker, every time' },
|
||||||
|
{ icon: 'fas fa-users', label: 'Small, matched groups' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Pet first-aid certified & insured' },
|
||||||
|
{ icon: 'fas fa-star', label: '5.0 from 30+ owners' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// The hero quick-picks and the closing band both draw from the same services
|
||||||
|
// data the main ServicesSection uses, so the click targets never drift apart.
|
||||||
|
$: services = content.services;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header navigation={content.navigation} />
|
||||||
|
|
||||||
|
<section class="sfhero" data-track-location="hero" aria-label="Goodwalk dog walking">
|
||||||
|
<div class="sfhero-inner">
|
||||||
|
<div class="sfhero-copy">
|
||||||
|
<p class="sfhero-eyebrow">Professional dog walkers</p>
|
||||||
|
<h1 class="sfhero-title">
|
||||||
|
<span class="sfhero-title-main">Come home to a calmer dog.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="sfhero-lead">
|
||||||
|
Small, matched groups for small & medium dogs. The same walker, every time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="sfhero-actions">
|
||||||
|
<a
|
||||||
|
href="#services"
|
||||||
|
class="btn btn-yellow btn-with-arrow"
|
||||||
|
data-track-event="cta_click"
|
||||||
|
data-track-type="hero_primary"
|
||||||
|
data-track-label="See our walks"
|
||||||
|
>
|
||||||
|
See our walks
|
||||||
|
<Icon name="fas fa-arrow-right" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="sfhero-reviews"
|
||||||
|
href={reviewCta.href}
|
||||||
|
target={reviewCta.external ? '_blank' : undefined}
|
||||||
|
rel={reviewCta.external ? 'noopener' : undefined}
|
||||||
|
aria-label="30+ five-star Google reviews"
|
||||||
|
data-track-event="social_proof_click"
|
||||||
|
data-track-type="hero_reviews"
|
||||||
|
>
|
||||||
|
<span class="sfhero-reviews-mark" aria-hidden="true">
|
||||||
|
<img src="/images/google-g-logo.svg" alt="" width="15" height="16" />
|
||||||
|
</span>
|
||||||
|
<span class="sfhero-reviews-stars" aria-hidden="true">
|
||||||
|
{#each stars as _}
|
||||||
|
<Icon name="fas fa-star" />
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="sfhero-reviews-label">30+ five-star reviews</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sfhero-media">
|
||||||
|
<div class="sfhero-photo">
|
||||||
|
<img
|
||||||
|
src={heroImage}
|
||||||
|
alt="Maya, a happy small dog, after a Goodwalk walk in Auckland"
|
||||||
|
width="1536"
|
||||||
|
height="1024"
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick-picks: the three services as clickable choices, in the hero, above
|
||||||
|
the fold. This is the conversion move — the visitor's first decision is
|
||||||
|
"which walk", not "fill in a form". -->
|
||||||
|
<nav class="sfhero-picks" aria-label="Choose a service">
|
||||||
|
{#each services as service}
|
||||||
|
<a
|
||||||
|
class="sfhero-pick"
|
||||||
|
href={service.href}
|
||||||
|
data-track-event="service_quickpick_click"
|
||||||
|
data-track-type="hero_quickpick"
|
||||||
|
data-track-label={service.title}
|
||||||
|
>
|
||||||
|
<span class="sfhero-pick-glyph" aria-hidden="true">
|
||||||
|
<Icon name={service.icon} />
|
||||||
|
</span>
|
||||||
|
<span class="sfhero-pick-text">
|
||||||
|
<span class="sfhero-pick-title">{service.title}</span>
|
||||||
|
{#if service.priceFrom}
|
||||||
|
<span class="sfhero-pick-price">{service.priceFrom}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<Icon name="fas fa-arrow-right" className="sfhero-pick-arrow" />
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- The conversion engine: the service cards themselves link straight through to
|
||||||
|
each service page. -->
|
||||||
|
<ServicesSection
|
||||||
|
services={content.services}
|
||||||
|
heading="Choose your dog's walk."
|
||||||
|
intro="Three ways to walk — pack, solo, or puppy visits. Same walker, small groups, built around your dog."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- One calm assurance beat. Replaces the old wall of value cards: four plain
|
||||||
|
facts, stated once, then straight on to proof. No competing form. -->
|
||||||
|
<section class="sfproof" aria-label="What every Goodwalk includes">
|
||||||
|
<ul class="sfproof-row">
|
||||||
|
{#each assurances as item}
|
||||||
|
<li class="sfproof-item">
|
||||||
|
<Icon name={item.icon} className="sfproof-icon" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||||
|
|
||||||
|
<!-- Closing band: offer the same three choices one more time, on brand green. -->
|
||||||
|
<section class="sfclose" aria-label="Choose your dog's walk">
|
||||||
|
<div class="sfclose-inner">
|
||||||
|
<div class="sfclose-head">
|
||||||
|
<h2 class="sfclose-title">Find the right walk for your dog.</h2>
|
||||||
|
<p class="sfclose-lead">
|
||||||
|
Three options. No commitment. Just pick the one that sounds like your dog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sfclose-grid">
|
||||||
|
{#each services as service}
|
||||||
|
<a
|
||||||
|
class="sfclose-card"
|
||||||
|
href={service.href}
|
||||||
|
data-track-event="service_card_click"
|
||||||
|
data-track-type="closing_band"
|
||||||
|
data-track-label={service.title}
|
||||||
|
>
|
||||||
|
<span class="sfclose-card-glyph" aria-hidden="true">
|
||||||
|
<Icon name={service.icon} />
|
||||||
|
</span>
|
||||||
|
<span class="sfclose-card-title">{service.title}</span>
|
||||||
|
<span class="sfclose-card-body">{service.body}</span>
|
||||||
|
{#if service.priceFrom}
|
||||||
|
<span class="sfclose-card-price">{service.priceFrom}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="sfclose-card-cta">
|
||||||
|
View this walk
|
||||||
|
<Icon name="fas fa-arrow-right" className="sfclose-card-arrow" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Footer footer={content.footer} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Hero ──────────────────────────────────────────────────────────────
|
||||||
|
Mirrors HomeClarity's .chero so the variant reads as the same site:
|
||||||
|
green field, one soft yellow light, identical type scale. The layout
|
||||||
|
adds a third row for the service quick-picks. */
|
||||||
|
|
||||||
|
.sfhero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -28%;
|
||||||
|
right: -10%;
|
||||||
|
width: 60%;
|
||||||
|
height: 90%;
|
||||||
|
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.16), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: var(--max-w);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(48px, 5.5vw, 80px) var(--space-container-x);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||||
|
grid-template-areas:
|
||||||
|
'copy media'
|
||||||
|
'picks picks';
|
||||||
|
column-gap: clamp(32px, 5vw, 76px);
|
||||||
|
row-gap: clamp(32px, 4vw, 48px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-copy {
|
||||||
|
grid-area: copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--white-rgb), 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 26px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-title-main {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(42px, 6.4vw, 78px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--yellow);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-lead {
|
||||||
|
margin: 22px 0 0;
|
||||||
|
max-width: 42ch;
|
||||||
|
font-size: clamp(16px, 1.3vw, 19px);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(var(--white-rgb), 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px 26px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-reviews {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: rgba(var(--white-rgb), 0.9);
|
||||||
|
transition: color var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-reviews-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-page);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-reviews-mark img {
|
||||||
|
display: block;
|
||||||
|
width: 15px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-reviews-stars {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-reviews-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--white-rgb), 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sfhero-reviews:hover {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-reviews:focus-visible {
|
||||||
|
outline: 2px solid var(--yellow);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media */
|
||||||
|
.sfhero-media {
|
||||||
|
grid-area: media;
|
||||||
|
position: relative;
|
||||||
|
justify-self: end;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-media::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 8% -6% -10% 10%;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: rgba(var(--accent-rgb), 0.22);
|
||||||
|
filter: blur(46px);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-photo {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
border-radius: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(var(--white-rgb), 0.12),
|
||||||
|
var(--shadow-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero quick-picks ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sfhero-picks {
|
||||||
|
grid-area: picks;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding-top: clamp(8px, 1.5vw, 16px);
|
||||||
|
border-top: 1px solid rgba(var(--white-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(var(--white-rgb), 0.05);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.1);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background 0.22s ease,
|
||||||
|
box-shadow 0.22s ease,
|
||||||
|
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick-glyph {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.16);
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick-title {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick-price {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(var(--white-rgb), 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sfhero-pick-arrow) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--yellow);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sfhero-pick:hover {
|
||||||
|
background: rgba(var(--white-rgb), 0.09);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.22);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick:hover :global(.sfhero-pick-arrow) {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-pick:focus-visible {
|
||||||
|
outline: 2px solid var(--yellow);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entrance — same choreography as the clarity hero. */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.sfhero-copy,
|
||||||
|
.sfhero-picks {
|
||||||
|
animation: sfheroRise 0.6s var(--ease-out-soft) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-picks {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-photo {
|
||||||
|
animation: sfheroPhoto 0.8s var(--ease-out-soft) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sfheroRise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sfheroPhoto {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Assurance bar ─────────────────────────────────────────────────────
|
||||||
|
A single calm strip of facts — the "say it plainly" beat that replaces a
|
||||||
|
long grid of value cards. Warm cream divider between services and proof. */
|
||||||
|
|
||||||
|
.sfproof {
|
||||||
|
background: var(--surface-panel-cream);
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfproof-row {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: var(--max-w);
|
||||||
|
padding: clamp(18px, 2.4vw, 26px) var(--space-container-x);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px clamp(24px, 4vw, 52px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfproof-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sfproof-icon) {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Closing band ──────────────────────────────────────────────────────
|
||||||
|
Brand-green field that repeats the three service choices. Mirrors the
|
||||||
|
service-card styling so the page closes on the same call it opened with. */
|
||||||
|
|
||||||
|
.sfclose {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
padding: var(--space-section-support-y) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -30%;
|
||||||
|
left: -8%;
|
||||||
|
width: 55%;
|
||||||
|
height: 90%;
|
||||||
|
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.14), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: min(1180px, calc(var(--max-w) - 40px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-container-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-head {
|
||||||
|
max-width: 46ch;
|
||||||
|
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(28px, 4vw, 44px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-inverse);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(15px, 1.3vw, 18px);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(var(--white-rgb), 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 28px 24px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(var(--white-rgb), 0.05);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.1);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background 0.24s ease,
|
||||||
|
box-shadow 0.24s ease,
|
||||||
|
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card-glyph {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.16);
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card-title {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card-body {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(var(--white-rgb), 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card-price {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 18px;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid rgba(var(--white-rgb), 0.12);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sfclose-card-arrow) {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--yellow);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sfclose-card:hover {
|
||||||
|
background: rgba(var(--white-rgb), 0.09);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.22);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card:hover :global(.sfclose-card-arrow) {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-card:focus-visible {
|
||||||
|
outline: 2px solid var(--yellow);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ────────────────────────────────────────────────────────────
|
||||||
|
Same single-column logic as the clarity hero: message, photo, action,
|
||||||
|
then the picks stack full width. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.sfhero-picks {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sfhero-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 28px var(--space-container-x-mobile) 36px;
|
||||||
|
row-gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-copy {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-media {
|
||||||
|
order: 2;
|
||||||
|
justify-self: stretch;
|
||||||
|
max-width: none;
|
||||||
|
margin: 24px 0 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-picks {
|
||||||
|
order: 3;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-photo {
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-lead {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfhero-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfclose-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sharedServices } from '$lib/content/services';
|
import { sharedServices } from '$lib/content/services';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import CtaCard from '$lib/components/CtaCard.svelte';
|
import CtaCard from '$lib/components/ui/CtaCard.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import { getSeededTestimonialIndex } from '$lib/testimonials';
|
import { getSeededTestimonialIndex } from '$lib/testimonials';
|
||||||
import type { LocationPageContent, TestimonialContent } from '$lib/types';
|
import type { LocationPageContent, TestimonialContent } from '$lib/types';
|
||||||
+141
-376
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import OnboardingSignaturePad from '$lib/components/OnboardingSignaturePad.svelte';
|
import OnboardingSignaturePad from '$lib/components/ui/OnboardingSignaturePad.svelte';
|
||||||
import OnboardingAuth from '$lib/components/OnboardingAuth.svelte';
|
import OnboardingAuth from '$lib/components/sections/OnboardingAuth.svelte';
|
||||||
import OnboardingFooter from '$lib/components/OnboardingFooter.svelte';
|
import OnboardingFooter from '$lib/components/ui/OnboardingFooter.svelte';
|
||||||
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
||||||
import type { Picture } from '@sveltejs/enhanced-img';
|
import type { Picture } from '@sveltejs/enhanced-img';
|
||||||
|
|
||||||
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
export const preview = false;
|
export const preview = false;
|
||||||
|
|
||||||
|
// Owner "view as client" impersonation. When `impersonation` is true the
|
||||||
|
// page renders the given client's onboarding view from injected data (no
|
||||||
|
// /auth/verify call) and the owner can walk through every step interactively.
|
||||||
|
// It still never touches the real client: client-side persistence is
|
||||||
|
// neutralised (saveDraft / the localStorage reactive block are no-ops) and
|
||||||
|
// the final submit is routed to `onImpersonationSubmit` instead of the real
|
||||||
|
// endpoint. Defaults keep the normal client flow intact.
|
||||||
|
export let impersonation = false;
|
||||||
|
export let impersonationData: {
|
||||||
|
email?: string;
|
||||||
|
profile?: Record<string, unknown>;
|
||||||
|
draft?: Record<string, unknown>;
|
||||||
|
} | null = null;
|
||||||
|
// Called on final submit while impersonating. The parent decides whether to
|
||||||
|
// email the run-through to a preview address or do nothing (dry run); either
|
||||||
|
// way the real client is never contacted or updated. Throwing surfaces an
|
||||||
|
// error in the form just like a real failed submit.
|
||||||
|
export let onImpersonationSubmit: ((payload: Record<string, unknown>) => Promise<void>) | null = null;
|
||||||
|
|
||||||
const ownerEmail = 'info@goodwalk.co.nz';
|
const ownerEmail = 'info@goodwalk.co.nz';
|
||||||
const ownerPhone = '(022) 642 1011';
|
const ownerPhone = '(022) 642 1011';
|
||||||
const services = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Unsure yet'];
|
const services = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits', 'Unsure yet'];
|
||||||
@@ -56,6 +75,9 @@
|
|||||||
let vetPhone = '';
|
let vetPhone = '';
|
||||||
let emergencyContactName = '';
|
let emergencyContactName = '';
|
||||||
let emergencyContactPhone = '';
|
let emergencyContactPhone = '';
|
||||||
|
let regularFleaTickTreatment = '';
|
||||||
|
let petInsurance = '';
|
||||||
|
let petInsuranceOwnerExpenseAccepted = false;
|
||||||
|
|
||||||
// Health & diet
|
// Health & diet
|
||||||
let isVaccinated = ''; // 'yes' | 'no'
|
let isVaccinated = ''; // 'yes' | 'no'
|
||||||
@@ -75,6 +97,7 @@
|
|||||||
let visitsDogParks = '';
|
let visitsDogParks = '';
|
||||||
let dogParksFrequency = '';
|
let dogParksFrequency = '';
|
||||||
let biteHistory = '';
|
let biteHistory = '';
|
||||||
|
let resourceGuards = '';
|
||||||
let reactiveToDogs = '';
|
let reactiveToDogs = '';
|
||||||
let reactiveToAnimals = '';
|
let reactiveToAnimals = '';
|
||||||
let reactiveToChildren = '';
|
let reactiveToChildren = '';
|
||||||
@@ -100,17 +123,6 @@
|
|||||||
// Computed legacy field for downstream consumers expecting a single full name.
|
// Computed legacy field for downstream consumers expecting a single full name.
|
||||||
$: fullName = `${ownerFirstName.trim()} ${ownerLastName.trim()}`.trim();
|
$: fullName = `${ownerFirstName.trim()} ${ownerLastName.trim()}`.trim();
|
||||||
|
|
||||||
$: displayName = ownerFirstName.trim() || (userEmail ? userEmail.split('@')[0] : '');
|
|
||||||
|
|
||||||
$: accountInitials = (() => {
|
|
||||||
const first = ownerFirstName.trim();
|
|
||||||
const last = ownerLastName.trim();
|
|
||||||
if (first || last) {
|
|
||||||
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase() || first.charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
return (userEmail.charAt(0) || '?').toUpperCase();
|
|
||||||
})();
|
|
||||||
|
|
||||||
let formStartedAt = 0;
|
let formStartedAt = 0;
|
||||||
let visitStartedAt = 0;
|
let visitStartedAt = 0;
|
||||||
let pageEnteredAt = 0;
|
let pageEnteredAt = 0;
|
||||||
@@ -211,6 +223,7 @@
|
|||||||
servicesNeeded, temperament, accessInstructions,
|
servicesNeeded, temperament, accessInstructions,
|
||||||
vetName, vetAddress, vetPhone,
|
vetName, vetAddress, vetPhone,
|
||||||
emergencyContactName, emergencyContactPhone,
|
emergencyContactName, emergencyContactPhone,
|
||||||
|
regularFleaTickTreatment, petInsurance, petInsuranceOwnerExpenseAccepted,
|
||||||
isVaccinated,
|
isVaccinated,
|
||||||
hasFoodAllergies, foodAllergiesDetail,
|
hasFoodAllergies, foodAllergiesDetail,
|
||||||
hasEnvAllergies, envAllergiesDetail,
|
hasEnvAllergies, envAllergiesDetail,
|
||||||
@@ -218,7 +231,7 @@
|
|||||||
onMedication, medicationDetail,
|
onMedication, medicationDetail,
|
||||||
wellSocialised, dogsInteractedWeekly,
|
wellSocialised, dogsInteractedWeekly,
|
||||||
visitsBeach, visitsDogParks, dogParksFrequency,
|
visitsBeach, visitsDogParks, dogParksFrequency,
|
||||||
biteHistory,
|
biteHistory, resourceGuards,
|
||||||
reactiveToDogs, reactiveToAnimals, reactiveToChildren, reactiveToPeople,
|
reactiveToDogs, reactiveToAnimals, reactiveToChildren, reactiveToPeople,
|
||||||
isDesexed, isRegistered, leashTrained,
|
isDesexed, isRegistered, leashTrained,
|
||||||
recallRating, ranAwayBefore,
|
recallRating, ranAwayBefore,
|
||||||
@@ -229,6 +242,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
|
if (impersonation) return; // read-only replica never persists drafts
|
||||||
try {
|
try {
|
||||||
const token = window.localStorage.getItem('gw_onboarding_session');
|
const token = window.localStorage.getItem('gw_onboarding_session');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -280,6 +294,9 @@
|
|||||||
vetPhone = pickString(draft, 'vetPhone', vetPhone);
|
vetPhone = pickString(draft, 'vetPhone', vetPhone);
|
||||||
emergencyContactName = pickString(draft, 'emergencyContactName', emergencyContactName);
|
emergencyContactName = pickString(draft, 'emergencyContactName', emergencyContactName);
|
||||||
emergencyContactPhone = pickString(draft, 'emergencyContactPhone', emergencyContactPhone);
|
emergencyContactPhone = pickString(draft, 'emergencyContactPhone', emergencyContactPhone);
|
||||||
|
regularFleaTickTreatment = pickString(draft, 'regularFleaTickTreatment', regularFleaTickTreatment);
|
||||||
|
petInsurance = pickString(draft, 'petInsurance', petInsurance);
|
||||||
|
petInsuranceOwnerExpenseAccepted = pickBool(draft, 'petInsuranceOwnerExpenseAccepted', petInsuranceOwnerExpenseAccepted);
|
||||||
isVaccinated = pickString(draft, 'isVaccinated', isVaccinated);
|
isVaccinated = pickString(draft, 'isVaccinated', isVaccinated);
|
||||||
hasFoodAllergies = pickString(draft, 'hasFoodAllergies', hasFoodAllergies);
|
hasFoodAllergies = pickString(draft, 'hasFoodAllergies', hasFoodAllergies);
|
||||||
foodAllergiesDetail = pickString(draft, 'foodAllergiesDetail', foodAllergiesDetail);
|
foodAllergiesDetail = pickString(draft, 'foodAllergiesDetail', foodAllergiesDetail);
|
||||||
@@ -295,6 +312,7 @@
|
|||||||
visitsDogParks = pickString(draft, 'visitsDogParks', visitsDogParks);
|
visitsDogParks = pickString(draft, 'visitsDogParks', visitsDogParks);
|
||||||
dogParksFrequency = pickString(draft, 'dogParksFrequency', dogParksFrequency);
|
dogParksFrequency = pickString(draft, 'dogParksFrequency', dogParksFrequency);
|
||||||
biteHistory = pickString(draft, 'biteHistory', biteHistory);
|
biteHistory = pickString(draft, 'biteHistory', biteHistory);
|
||||||
|
resourceGuards = pickString(draft, 'resourceGuards', resourceGuards);
|
||||||
reactiveToDogs = pickString(draft, 'reactiveToDogs', reactiveToDogs);
|
reactiveToDogs = pickString(draft, 'reactiveToDogs', reactiveToDogs);
|
||||||
reactiveToAnimals = pickString(draft, 'reactiveToAnimals', reactiveToAnimals);
|
reactiveToAnimals = pickString(draft, 'reactiveToAnimals', reactiveToAnimals);
|
||||||
reactiveToChildren = pickString(draft, 'reactiveToChildren', reactiveToChildren);
|
reactiveToChildren = pickString(draft, 'reactiveToChildren', reactiveToChildren);
|
||||||
@@ -313,7 +331,7 @@
|
|||||||
termsAccepted = pickBool(draft, 'termsAccepted', termsAccepted);
|
termsAccepted = pickBool(draft, 'termsAccepted', termsAccepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (typeof window !== 'undefined') {
|
$: if (typeof window !== 'undefined' && !impersonation) {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(draftStorageKey, JSON.stringify(getDraftSnapshot()));
|
window.localStorage.setItem(draftStorageKey, JSON.stringify(getDraftSnapshot()));
|
||||||
} catch { /* storage unavailable */ }
|
} catch { /* storage unavailable */ }
|
||||||
@@ -379,7 +397,30 @@
|
|||||||
userEmail = '';
|
userEmail = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initImpersonation() {
|
||||||
|
// Render straight from the owner-supplied snapshot. No tokens are read,
|
||||||
|
// no network calls are made, and no timers are started.
|
||||||
|
const data = impersonationData ?? {};
|
||||||
|
const serverEmail = typeof data.email === 'string' ? data.email : '';
|
||||||
|
isAuthenticated = true;
|
||||||
|
userEmail = serverEmail;
|
||||||
|
applyProfile(serverEmail, (data.profile ?? {}) as Record<string, unknown>);
|
||||||
|
const onboardingDraft =
|
||||||
|
data.draft && typeof data.draft === 'object'
|
||||||
|
? (data.draft as Record<string, unknown>).onboarding
|
||||||
|
: null;
|
||||||
|
if (!onboardingCompleted && onboardingDraft && typeof onboardingDraft === 'object') {
|
||||||
|
applyDraft(onboardingDraft as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
authChecking = false;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (impersonation) {
|
||||||
|
initImpersonation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
|
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
@@ -433,96 +474,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEmail(raw: string): string {
|
// The form is intentionally free-fill: no fields are required and it can be
|
||||||
const value = raw.trim();
|
// submitted incomplete. Owners often onboard before they've named a dog or
|
||||||
if (!value) return 'Please enter your email address';
|
// sorted council registration, and we follow up on the gaps ourselves.
|
||||||
if (!/^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/.test(value)) {
|
|
||||||
return 'Please enter a valid email address';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
type RequirementCheck = { key: string; label: string; met: boolean; step: number };
|
|
||||||
|
|
||||||
$: contactDetailsMet =
|
|
||||||
Boolean(ownerFirstName.trim()) &&
|
|
||||||
Boolean(ownerLastName.trim()) &&
|
|
||||||
!validateEmail(email) &&
|
|
||||||
Boolean(phone.trim()) &&
|
|
||||||
Boolean(address.trim());
|
|
||||||
|
|
||||||
$: requirementChecks = [
|
|
||||||
{ key: 'contact', label: 'Your contact details (name, email, phone, address)', met: contactDetailsMet, step: 1 },
|
|
||||||
{ key: 'services', label: 'At least one service selected', met: servicesNeeded.length > 0, step: 2 },
|
|
||||||
{ key: 'vaccination', label: 'Vaccinations confirmed', met: isVaccinated === 'yes', step: 3 },
|
|
||||||
{ key: 'council', label: 'Council registration confirmed', met: isRegistered === 'yes', step: 4 },
|
|
||||||
{ key: 'vetConsent', label: 'Emergency vet treatment consent', met: emergencyVetConsent, step: 5 },
|
|
||||||
{ key: 'declaration', label: 'Onboarding declaration ticked', met: termsAccepted, step: 5 },
|
|
||||||
{ key: 'signature', label: 'Valid signature added', met: Boolean(signatureDataUrl), step: 5 },
|
|
||||||
] as RequirementCheck[];
|
|
||||||
|
|
||||||
$: requirementsAllMet = requirementChecks.every((r) => r.met);
|
|
||||||
|
|
||||||
function goToStep(step: number) {
|
|
||||||
if (step < 1 || step > steps.length) return;
|
|
||||||
errors = {};
|
|
||||||
currentStep = step;
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateStep(step: number): boolean {
|
|
||||||
const next: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (step === 1) {
|
|
||||||
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';
|
|
||||||
if (!address.trim()) next.address = 'Please enter your address';
|
|
||||||
} 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 (!isRegistered) next.isRegistered = 'Please confirm whether your dog is registered with council';
|
|
||||||
} else if (step === 5) {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = next;
|
|
||||||
return Object.keys(next).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextStep() {
|
function nextStep() {
|
||||||
noteInteraction();
|
noteInteraction();
|
||||||
if (!validateStep(currentStep)) return;
|
|
||||||
currentStep = Math.min(currentStep + 1, steps.length);
|
currentStep = Math.min(currentStep + 1, steps.length);
|
||||||
void saveDraft();
|
void saveDraft();
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
@@ -547,6 +504,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
{ label: 'Email', value: fieldValue(email) },
|
{ label: 'Email', value: fieldValue(email) },
|
||||||
{ label: 'Contact Number', value: fieldValue(phone) },
|
{ label: 'Contact Number', value: fieldValue(phone) },
|
||||||
{ label: 'Home Address', value: fieldValue(address) },
|
{ label: 'Home Address', value: fieldValue(address) },
|
||||||
|
{ label: 'Home Access Instructions', value: fieldValue(accessInstructions) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -570,7 +528,9 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
{ label: 'Vet Address', value: fieldValue(vetAddress) },
|
{ label: 'Vet Address', value: fieldValue(vetAddress) },
|
||||||
{ label: 'Emergency Contact Name', value: fieldValue(emergencyContactName) },
|
{ label: 'Emergency Contact Name', value: fieldValue(emergencyContactName) },
|
||||||
{ label: 'Emergency Contact Number', value: fieldValue(emergencyContactPhone) },
|
{ label: 'Emergency Contact Number', value: fieldValue(emergencyContactPhone) },
|
||||||
{ label: 'Home Access Instructions', value: fieldValue(accessInstructions) },
|
{ label: 'Regular Flea and Tick Treatment?', value: yesNoText(regularFleaTickTreatment) },
|
||||||
|
{ label: 'Pet Insurance?', value: yesNoText(petInsurance) },
|
||||||
|
{ label: 'Owner Covers All Vet Costs if Uninsured', value: petInsurance === 'no' ? yesNoText(petInsuranceOwnerExpenseAccepted) : '—' },
|
||||||
{ label: 'Are Vaccinations Current?', value: yesNoText(isVaccinated) },
|
{ label: 'Are Vaccinations Current?', value: yesNoText(isVaccinated) },
|
||||||
{ label: 'Any Food Allergies?', value: hasFoodAllergies === 'yes' ? fieldValue(foodAllergiesDetail) : yesNoText(hasFoodAllergies) },
|
{ label: 'Any Food Allergies?', value: hasFoodAllergies === 'yes' ? fieldValue(foodAllergiesDetail) : yesNoText(hasFoodAllergies) },
|
||||||
{ label: 'Any Environmental Allergies?', value: hasEnvAllergies === 'yes' ? fieldValue(envAllergiesDetail) : yesNoText(hasEnvAllergies) },
|
{ label: 'Any Environmental Allergies?', value: hasEnvAllergies === 'yes' ? fieldValue(envAllergiesDetail) : yesNoText(hasEnvAllergies) },
|
||||||
@@ -587,6 +547,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
{ label: 'Visits the Beach?', value: yesNoText(visitsBeach) },
|
{ label: 'Visits the Beach?', value: yesNoText(visitsBeach) },
|
||||||
{ label: 'Visits Dog Parks Frequently?', value: visitsDogParks === 'yes' ? fieldValue(dogParksFrequency ? `${dogParksFrequency} per week` : 'Yes') : yesNoText(visitsDogParks) },
|
{ label: 'Visits Dog Parks Frequently?', value: visitsDogParks === 'yes' ? fieldValue(dogParksFrequency ? `${dogParksFrequency} per week` : 'Yes') : yesNoText(visitsDogParks) },
|
||||||
{ label: 'Any Bite History?', value: yesNoText(biteHistory) },
|
{ label: 'Any Bite History?', value: yesNoText(biteHistory) },
|
||||||
|
{ label: 'Resource Guards?', value: yesNoText(resourceGuards) },
|
||||||
{ label: 'Reactive to Other Dogs?', value: yesNoText(reactiveToDogs) },
|
{ label: 'Reactive to Other Dogs?', value: yesNoText(reactiveToDogs) },
|
||||||
{ label: 'Reactive to Other Animals?', value: yesNoText(reactiveToAnimals) },
|
{ label: 'Reactive to Other Animals?', value: yesNoText(reactiveToAnimals) },
|
||||||
{ label: 'Reactive to Children?', value: yesNoText(reactiveToChildren) },
|
{ label: 'Reactive to Children?', value: yesNoText(reactiveToChildren) },
|
||||||
@@ -621,15 +582,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
sendClickedAt = Date.now();
|
sendClickedAt = Date.now();
|
||||||
submitError = '';
|
submitError = '';
|
||||||
|
|
||||||
if (!validateStep(5)) return;
|
|
||||||
|
|
||||||
const firstUnmet = requirementChecks.find((r) => !r.met);
|
|
||||||
if (firstUnmet) {
|
|
||||||
submitError = `Please complete: ${firstUnmet.label}.`;
|
|
||||||
if (firstUnmet.step !== currentStep) goToStep(firstUnmet.step);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting = true;
|
submitting = true;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -648,30 +600,46 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
toStr(additionalNotes).trim(),
|
toStr(additionalNotes).trim(),
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...getDraftSnapshot(),
|
||||||
|
fullName,
|
||||||
|
dogAge: dogDateOfBirth,
|
||||||
|
medicalNotes,
|
||||||
|
regularFleaTickTreatment,
|
||||||
|
petInsurance,
|
||||||
|
petInsuranceOwnerExpenseAccepted,
|
||||||
|
councilRegistrationConfirmed: isRegistered === 'yes',
|
||||||
|
vaccinationsConfirmed: isVaccinated === 'yes',
|
||||||
|
signatureDataUrl,
|
||||||
|
signedOnDate,
|
||||||
|
submissionSnapshot,
|
||||||
|
website,
|
||||||
|
formStartedAt, visitStartedAt, pageEnteredAt, firstInteractionAt, sendClickedAt,
|
||||||
|
referrer: document.referrer,
|
||||||
|
page: window.location.href,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (impersonation) {
|
||||||
|
// Owner run-through: hand off to the parent (preview email or dry run).
|
||||||
|
// Never hits the real client endpoint or the owner's own saved draft.
|
||||||
|
if (onImpersonationSubmit) await onImpersonationSubmit(payload);
|
||||||
|
onboardingCompleted = true;
|
||||||
|
onboardingSummary = submissionSnapshot;
|
||||||
|
onboardingSubmittedAt = signedOnDate;
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/onboarding-submit', {
|
const response = await fetch('/api/onboarding-submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload)
|
||||||
...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,
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null);
|
const errorPayload = await response.json().catch(() => null);
|
||||||
throw new Error(payload?.detail?.message ?? payload?.detail ?? payload?.error ?? `Submission failed (${response.status})`);
|
throw new Error(errorPayload?.detail?.message ?? errorPayload?.detail ?? errorPayload?.error ?? `Submission failed (${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
onboardingCompleted = true;
|
onboardingCompleted = true;
|
||||||
@@ -724,12 +692,10 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-topbar-right">
|
<div class="onboarding-topbar-right">
|
||||||
{#if isAuthenticated && displayName}
|
{#if isAuthenticated}
|
||||||
<div class="onboarding-account" aria-label="Account">
|
<div class="onboarding-account" aria-label="Account">
|
||||||
<span class="onboarding-account-avatar" aria-hidden="true">{accountInitials}</span>
|
<span class="onboarding-account-avatar" aria-hidden="true">
|
||||||
<span class="onboarding-account-text">
|
<Icon name="fas fa-user" />
|
||||||
<span class="onboarding-account-prefix">Logged in as</span>
|
|
||||||
<span class="onboarding-account-name">{displayName}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -770,8 +736,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
<h1>
|
<h1>
|
||||||
{#if onboardingCompleted}
|
{#if onboardingCompleted}
|
||||||
Your submitted onboarding details.
|
Your submitted onboarding details.
|
||||||
{:else if displayName}
|
|
||||||
Hi {displayName}, let's get your dog onboarded.
|
|
||||||
{:else}
|
{:else}
|
||||||
A few details so we can care for your dog properly.
|
A few details so we can care for your dog properly.
|
||||||
{/if}
|
{/if}
|
||||||
@@ -970,6 +934,16 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
<input bind:value={address} on:input={noteInteraction} autocomplete="street-address" placeholder="Street, suburb, city" />
|
<input bind:value={address} on:input={noteInteraction} autocomplete="street-address" placeholder="Street, suburb, city" />
|
||||||
{#if errors.address}<small>{errors.address}</small>{/if}
|
{#if errors.address}<small>{errors.address}</small>{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-nav">
|
<div class="panel-nav">
|
||||||
@@ -1125,19 +1099,33 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
<div class="panel-subsection-icon"><Icon name="fas fa-notes-medical" /></div>
|
<div class="panel-subsection-icon"><Icon name="fas fa-notes-medical" /></div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Health notes</h3>
|
<h3>Health notes</h3>
|
||||||
<p>Day-to-day care, access, and anything we should know. Vaccination status is required below.</p>
|
<p>Day-to-day care and anything we should know. Vaccination status is required below.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="field">
|
{@render yesNo('Does your dog get regular flea and tick treatment?', 'regularFleaTickTreatment', regularFleaTickTreatment, (v) => (regularFleaTickTreatment = v))}
|
||||||
<span>Home access instructions</span>
|
|
||||||
<textarea
|
{@render yesNo('Does your dog have pet insurance?', 'petInsurance', petInsurance, (v) => {
|
||||||
bind:value={accessInstructions}
|
petInsurance = v;
|
||||||
rows="3"
|
if (v !== 'no') {
|
||||||
placeholder="Keys, gate code, where to collect your dog, alarm notes."
|
petInsuranceOwnerExpenseAccepted = false;
|
||||||
on:input={noteInteraction}
|
if (errors.petInsuranceOwnerExpenseAccepted) {
|
||||||
></textarea>
|
errors = { ...errors, petInsuranceOwnerExpenseAccepted: '' };
|
||||||
</label>
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{#if petInsurance === 'no'}
|
||||||
|
<label class="confirm-row">
|
||||||
|
<input bind:checked={petInsuranceOwnerExpenseAccepted} type="checkbox" on:change={() => {
|
||||||
|
noteInteraction();
|
||||||
|
if (errors.petInsuranceOwnerExpenseAccepted) {
|
||||||
|
errors = { ...errors, petInsuranceOwnerExpenseAccepted: '' };
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<span>If my dog does not have pet insurance, I agree that Goodwalk takes no responsibility and all vet costs are incurred at the owner's expense.</span>
|
||||||
|
</label>
|
||||||
|
{#if errors.petInsuranceOwnerExpenseAccepted}<small>{errors.petInsuranceOwnerExpenseAccepted}</small>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{@render yesNo("Are your dog's vaccinations up to date?", 'isVaccinated', isVaccinated, (v) => (isVaccinated = v))}
|
{@render yesNo("Are your dog's vaccinations up to date?", 'isVaccinated', isVaccinated, (v) => (isVaccinated = v))}
|
||||||
|
|
||||||
@@ -1218,6 +1206,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{@render yesNo('Any bite history?', 'biteHistory', biteHistory, (v) => (biteHistory = v))}
|
{@render yesNo('Any bite history?', 'biteHistory', biteHistory, (v) => (biteHistory = v))}
|
||||||
|
{@render yesNo('Does your dog resource guard?', 'resourceGuards', resourceGuards, (v) => (resourceGuards = v))}
|
||||||
{@render yesNo('Reactive to other dogs?', 'reactiveToDogs', reactiveToDogs, (v) => (reactiveToDogs = 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 other animals?', 'reactiveToAnimals', reactiveToAnimals, (v) => (reactiveToAnimals = v))}
|
||||||
{@render yesNo('Reactive to children?', 'reactiveToChildren', reactiveToChildren, (v) => (reactiveToChildren = v))}
|
{@render yesNo('Reactive to children?', 'reactiveToChildren', reactiveToChildren, (v) => (reactiveToChildren = v))}
|
||||||
@@ -1300,41 +1289,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="requirements-panel" class:requirements-complete={requirementsAllMet} aria-label="Onboarding requirements">
|
|
||||||
<div class="requirements-head">
|
|
||||||
<h3>Before you send</h3>
|
|
||||||
<p>
|
|
||||||
{#if requirementsAllMet}
|
|
||||||
All set. You're ready to sign and send.
|
|
||||||
{:else}
|
|
||||||
We need each of these before you submit. Tap any item to jump back to that step.
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ul class="requirements-list">
|
|
||||||
{#each requirementChecks as req (req.key)}
|
|
||||||
<li class="requirement-row" class:requirement-met={req.met}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="requirement-button"
|
|
||||||
on:click={() => goToStep(req.step)}
|
|
||||||
aria-label="{req.met ? 'Met' : 'Not met'}: {req.label}. Go to step {req.step}."
|
|
||||||
>
|
|
||||||
<span class="requirement-status" aria-hidden="true">
|
|
||||||
{#if req.met}
|
|
||||||
<Icon name="fas fa-circle-check" />
|
|
||||||
{:else}
|
|
||||||
<Icon name="fas fa-circle" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="requirement-label">{req.label}</span>
|
|
||||||
<span class="requirement-step">Step {req.step}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Anything else we should know?</span>
|
<span>Anything else we should know?</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1347,7 +1301,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
|
|
||||||
<div class="onboarding-fields two-up">
|
<div class="onboarding-fields two-up">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Instagram or social handle <span class="field-hint">Optional</span></span>
|
<span>Instagram or social handle</span>
|
||||||
<input
|
<input
|
||||||
bind:value={socialMediaAccount}
|
bind:value={socialMediaAccount}
|
||||||
on:input={() => {
|
on:input={() => {
|
||||||
@@ -1415,7 +1369,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
<Icon name="fas fa-arrow-left" /> Back
|
<Icon name="fas fa-arrow-left" /> Back
|
||||||
</button>
|
</button>
|
||||||
<div class="panel-nav-submit-group">
|
<div class="panel-nav-submit-group">
|
||||||
<button class="btn btn-yellow onboarding-submit" type="submit" disabled={submitting || !requirementsAllMet}>
|
<button class="btn btn-yellow onboarding-submit" type="submit" disabled={submitting}>
|
||||||
{#if submitting}Sending…{:else}Send onboarding form{/if}
|
{#if submitting}Sending…{:else}Send onboarding form{/if}
|
||||||
</button>
|
</button>
|
||||||
<p>This goes directly to Aless for review.</p>
|
<p>This goes directly to Aless for review.</p>
|
||||||
@@ -1430,7 +1384,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
<OnboardingFooter on:logout={handleLogout} />
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1535,33 +1489,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.12);
|
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-account-text {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1.15;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-prefix {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-name {
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.005em;
|
|
||||||
color: #fff;
|
|
||||||
max-width: 22ch;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-logout {
|
.onboarding-account-logout {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1606,15 +1533,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-account-prefix {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-name {
|
|
||||||
font-size: 12px;
|
|
||||||
max-width: 12ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-logout-label {
|
.onboarding-account-logout-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2356,157 +2274,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Requirements summary (step 5) ── */
|
|
||||||
.requirements-panel {
|
|
||||||
margin: 4px 0 28px;
|
|
||||||
padding: 22px 22px 18px;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: linear-gradient(180deg, #fffefb 0%, #faf8f1 100%);
|
|
||||||
border: 1px solid rgba(33, 48, 33, 0.12);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
|
|
||||||
0 8px 20px rgba(17, 20, 24, 0.04);
|
|
||||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirements-panel.requirements-complete {
|
|
||||||
border-color: rgba(33, 48, 33, 0.22);
|
|
||||||
background: linear-gradient(180deg, #fffbe8 0%, #fff5c9 100%);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, 0.6),
|
|
||||||
0 10px 26px rgba(255, 209, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirements-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirements-head h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--gw-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirements-head p {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 56ch;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.5;
|
|
||||||
letter-spacing: 0.005em;
|
|
||||||
color: rgba(33, 48, 33, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirements-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-button {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 24px 1fr auto;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(33, 48, 33, 0.1);
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
color: var(--gw-green);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background 0.18s ease,
|
|
||||||
border-color 0.18s ease,
|
|
||||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
box-shadow 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-button:hover {
|
|
||||||
border-color: rgba(33, 48, 33, 0.3);
|
|
||||||
background: #fff;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 6px 14px rgba(17, 20, 24, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-button:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--gw-green);
|
|
||||||
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.32);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-status {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: rgba(33, 48, 33, 0.3);
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-met .requirement-status {
|
|
||||||
color: #2f7a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-met .requirement-button {
|
|
||||||
background: rgba(47, 122, 58, 0.07);
|
|
||||||
border-color: rgba(47, 122, 58, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-label {
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #171b20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-met .requirement-label {
|
|
||||||
color: var(--gw-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-step {
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(33, 48, 33, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-met .requirement-step {
|
|
||||||
color: rgba(47, 122, 58, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.requirements-head {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-button {
|
|
||||||
grid-template-columns: 22px 1fr;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
row-gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.requirement-step {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Declaration ── */
|
/* ── Declaration ── */
|
||||||
.onboarding-confirm-list {
|
.onboarding-confirm-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2730,7 +2497,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.field input,
|
.field input,
|
||||||
.field textarea,
|
.field textarea,
|
||||||
.requirement-button,
|
|
||||||
.panel-nav-back,
|
.panel-nav-back,
|
||||||
.panel-nav-next,
|
.panel-nav-next,
|
||||||
.onboarding-submit,
|
.onboarding-submit,
|
||||||
@@ -2744,8 +2510,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
|
|
||||||
.panel-nav-back:hover,
|
.panel-nav-back:hover,
|
||||||
.panel-nav-next:hover,
|
.panel-nav-next:hover,
|
||||||
.onboarding-submit:not(:disabled):hover,
|
.onboarding-submit:not(:disabled):hover {
|
||||||
.requirement-button:hover {
|
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* v5 clarity treatment for the pricing page. Keeps each service prominent,
|
||||||
|
* like the live PricingPage, but strips the message right back: one photo,
|
||||||
|
* the name, a single outcome line, the "from" price, then the even plan
|
||||||
|
* cards. No chooser grid, lede, assurance, fit lists, or meta rows.
|
||||||
|
*
|
||||||
|
* View component only. Preview at /variants/our-pricing.
|
||||||
|
*/
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
import { reveal } from '$lib/actions/reveal';
|
||||||
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
|
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||||
|
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||||
|
import { decoratePlans } from '$lib/utils/pricing';
|
||||||
|
import { ourPricingContent } from '$lib/content/our-pricing';
|
||||||
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
|
import type { PricingPageSection, ServicePageContent } from '$lib/types';
|
||||||
|
|
||||||
|
type Block = { section: PricingPageSection; service: ServicePageContent; href: string };
|
||||||
|
|
||||||
|
// Pair each pricing section with its service (for the distilled per-plan copy)
|
||||||
|
// and its variant page. Order matches ourPricingContent.sections.
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{ section: ourPricingContent.sections[0], service: packWalksContent, href: '/pack-walks' },
|
||||||
|
{ section: ourPricingContent.sections[1], service: dogWalkingContent, href: '/dog-walking' },
|
||||||
|
{ section: ourPricingContent.sections[2], service: puppyVisitsContent, href: '/puppy-visits' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function fromValue(section: PricingPageSection): string {
|
||||||
|
return section.meta?.find((m) => m.label === 'From')?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function plansFor(block: Block) {
|
||||||
|
const lines = block.service.clarity?.planLines ?? {};
|
||||||
|
return decoratePlans(
|
||||||
|
block.section.plans.map((plan) => ({
|
||||||
|
...plan,
|
||||||
|
features: lines[plan.title] ?? plan.features
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At-a-glance comparison. Distilled, accurate facts per service; the "from"
|
||||||
|
// price is read from content (fromFor) so it stays single-sourced with the
|
||||||
|
// cards below and the pricing in each service page's schema.
|
||||||
|
const comparisonRows = [
|
||||||
|
{
|
||||||
|
href: '/pack-walks',
|
||||||
|
service: 'Tiny Gang Pack Walks',
|
||||||
|
bestFor: 'Sociable small & medium dogs',
|
||||||
|
format: 'Small group of 4–8 dogs',
|
||||||
|
length: '60–75 min'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/dog-walking',
|
||||||
|
service: 'Solo Walks',
|
||||||
|
bestFor: 'Reactive, anxious, senior or recovering dogs',
|
||||||
|
format: 'One dog, one-on-one',
|
||||||
|
length: '30 / 45 / 60 min'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/puppy-visits',
|
||||||
|
service: 'Puppy Visits',
|
||||||
|
bestFor: 'Puppies too young for full walks',
|
||||||
|
format: 'In-home, one-on-one',
|
||||||
|
length: '20 / 45 / 60 min'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function fromFor(href: string): string {
|
||||||
|
const block = blocks.find((b) => b.href === href);
|
||||||
|
return block ? fromValue(block.section) : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="pc">
|
||||||
|
<!-- Standard service hero (green copy left, photo right). -->
|
||||||
|
<ServiceHero
|
||||||
|
eyebrow={ourPricingContent.eyebrow ?? 'Pricing'}
|
||||||
|
title={ourPricingContent.title}
|
||||||
|
subtitle={ourPricingContent.subtitle}
|
||||||
|
imageUrl={ourPricingContent.heroImageUrl}
|
||||||
|
imageAlt={ourPricingContent.heroImageAlt}
|
||||||
|
chips={ourPricingContent.heroChips ?? []}
|
||||||
|
cta={{ label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'yellow' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Value before price: one line, given room to land. -->
|
||||||
|
<section class="pc-value" use:reveal={{ distance: 0 }} aria-labelledby="pc-value-quote">
|
||||||
|
<div class="pc-inner">
|
||||||
|
<span class="pc-value-rule" aria-hidden="true"></span>
|
||||||
|
<p id="pc-value-quote" class="pc-value-quote">
|
||||||
|
The best investment you’ll ever make is in your dog’s happiness.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- At a glance: a compact comparison of the three services before the
|
||||||
|
detailed per-service blocks. A real table, so it reads cleanly and is
|
||||||
|
easy to lift as a unit. -->
|
||||||
|
<section class="pc-compare" use:reveal={{ distance: 0 }} aria-labelledby="pc-compare-heading">
|
||||||
|
<div class="pc-inner">
|
||||||
|
<div class="pc-compare-head">
|
||||||
|
<p class="pc-eyebrow">Compare at a glance</p>
|
||||||
|
<h2 id="pc-compare-heading" class="pc-h2">Three ways Goodwalk fits your dog</h2>
|
||||||
|
</div>
|
||||||
|
<div class="pc-table-wrap">
|
||||||
|
<table class="pc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Service</th>
|
||||||
|
<th scope="col">Best for</th>
|
||||||
|
<th scope="col">Format</th>
|
||||||
|
<th scope="col">Length</th>
|
||||||
|
<th scope="col">From</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each comparisonRows as row}
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><a href={row.href}>{row.service}</a></th>
|
||||||
|
<td>{row.bestFor}</td>
|
||||||
|
<td>{row.format}</td>
|
||||||
|
<td>{row.length}</td>
|
||||||
|
<td class="pc-table-from">{fromFor(row.href)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- One prominent block per service: photo + simplified message, then cards. -->
|
||||||
|
{#each blocks as block, i}
|
||||||
|
<section
|
||||||
|
class="pc-service"
|
||||||
|
class:pc-service-tint={i % 2 === 1}
|
||||||
|
use:reveal={{ distance: 0 }}
|
||||||
|
aria-labelledby={`pc-svc-${i}`}
|
||||||
|
>
|
||||||
|
<div class="pc-inner">
|
||||||
|
<div class="pc-row" class:pc-row-reverse={i % 2 === 1}>
|
||||||
|
{#if block.section.imageUrl}
|
||||||
|
<figure class="pc-media">
|
||||||
|
<img
|
||||||
|
src={block.section.imageUrl}
|
||||||
|
alt={block.section.imageAlt ?? block.section.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="pc-copy">
|
||||||
|
{#if block.section.eyebrow}
|
||||||
|
<p class="pc-eyebrow">{block.section.eyebrow}</p>
|
||||||
|
{/if}
|
||||||
|
<h2 id={`pc-svc-${i}`} class="pc-h2">{block.section.title}</h2>
|
||||||
|
{#if block.section.outcome}
|
||||||
|
<p class="pc-outcome">{block.section.outcome}</p>
|
||||||
|
{/if}
|
||||||
|
{#if fromValue(block.section)}
|
||||||
|
<p class="pc-from"><span>From</span> <strong>{fromValue(block.section)}</strong></p>
|
||||||
|
{/if}
|
||||||
|
<a class="pc-link" href={block.href}>
|
||||||
|
See {block.section.title}
|
||||||
|
<Icon name="fas fa-arrow-right" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-plans" class:pc-plans-three={block.section.plans.length === 3}>
|
||||||
|
{#each plansFor(block) as plan}
|
||||||
|
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Coverage -->
|
||||||
|
<div class="pc-area" use:reveal={{ distance: 0 }}>
|
||||||
|
<ServiceAreaCoverage
|
||||||
|
title="Where we work across Auckland Central"
|
||||||
|
lead="Free door-to-door pickup and drop-off on walks across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close -->
|
||||||
|
<section class="pc-cta" use:reveal={{ distance: 0 }}>
|
||||||
|
<div class="pc-inner">
|
||||||
|
<h2 class="pc-cta-title">Not sure which one fits?</h2>
|
||||||
|
<p class="pc-cta-sub">Every Goodwalk routine starts with a free Meet & Greet. We will tell you what fits, and what does not.</p>
|
||||||
|
<a class="btn btn-yellow" href="/contact-us">Book a free Meet & Greet</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pc {
|
||||||
|
--pc-max: 1180px;
|
||||||
|
background: var(--off-white);
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-inner {
|
||||||
|
max-width: var(--pc-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-inline: var(--space-container-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-service {
|
||||||
|
padding-block: clamp(48px, 6vw, 88px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Value statement: one line, given room to land ── */
|
||||||
|
.pc-value {
|
||||||
|
padding-block: clamp(64px, 9vw, 128px);
|
||||||
|
background: var(--surface-panel-cream);
|
||||||
|
border-block: 1px solid var(--border-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-value-rule {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 3px;
|
||||||
|
margin: 0 auto 30px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-value-quote {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 16ch;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(30px, 4.8vw, 56px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--gw-green);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── At-a-glance comparison table ── */
|
||||||
|
.pc-compare {
|
||||||
|
padding-block: clamp(48px, 6vw, 88px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-compare-head {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-compare-head .pc-eyebrow {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrolls on narrow screens rather than cramping five columns. */
|
||||||
|
.pc-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 760px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--surface-panel);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table thead th {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: rgba(255, 248, 230, 0.96);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table tbody th,
|
||||||
|
.pc-table tbody td {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-heading-soft);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table tbody th {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table tbody th a {
|
||||||
|
color: var(--gw-green);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table tbody th a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-table-from {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gw-green);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternating tint for rhythm, the same trick the service pages use. */
|
||||||
|
.pc-service-tint,
|
||||||
|
.pc-area {
|
||||||
|
background: var(--surface-panel-cream);
|
||||||
|
border-block: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prominent service row: photo + simplified message ── */
|
||||||
|
.pc-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: clamp(28px, 5vw, 64px);
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: clamp(32px, 4.5vw, 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-media {
|
||||||
|
order: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
background: var(--surface-brand);
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-row-reverse .pc-media {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-media img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-copy {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-row-reverse .pc-copy {
|
||||||
|
order: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(28px, 3.4vw, 44px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-heading);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The simplified message: one line, carried with weight. */
|
||||||
|
.pc-outcome {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
max-width: 34ch;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(18px, 1.9vw, 23px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.32;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--gw-green);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-from {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 22px 0 0;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-brand-soft);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-from span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-from strong {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 22px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gw-green);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-from + .pc-link {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Plans ── */
|
||||||
|
.pc-plans {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans-three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
max-width: 1040px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans :global(.plan-card) {
|
||||||
|
padding: 40px 28px 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans :global(.plan-card--featured) {
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans :global(.plan-card__cta) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans :global(.plan-card__cta .icon) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Close CTA ── */
|
||||||
|
.pc-cta {
|
||||||
|
padding-block: clamp(48px, 6vw, 88px);
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: rgba(255, 248, 230, 0.96);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-cta-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(26px, 3.2vw, 40px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--yellow);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-cta-sub {
|
||||||
|
margin: 14px auto 24px;
|
||||||
|
max-width: 38rem;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 248, 230, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.pc-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: clamp(20px, 5vw, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-media,
|
||||||
|
.pc-row-reverse .pc-media {
|
||||||
|
order: 0;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-copy,
|
||||||
|
.pc-row-reverse .pc-copy {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.pc-plans,
|
||||||
|
.pc-plans-three {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pc-plans,
|
||||||
|
.pc-plans-three {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans :global(.plan-card) {
|
||||||
|
padding: 30px 24px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-plans :global(.plan-card--featured) {
|
||||||
|
padding-top: 46px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Shared v5 "clarity" service page. One design system, one message, said
|
||||||
|
* once in order: what it is -> what's different -> how it works -> fit ->
|
||||||
|
* proof -> price -> FAQ -> where we cover -> book.
|
||||||
|
*
|
||||||
|
* Drives all three service pages (Tiny Gang, Solo Walks, Puppy Visits) from
|
||||||
|
* content. Page-specific copy lives in each content file's `clarity` block;
|
||||||
|
* see docs/v5.md. View component only: the route owns Header/Footer/SEO.
|
||||||
|
*/
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
|
import { reveal } from '$lib/actions/reveal';
|
||||||
|
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||||
|
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||||
|
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||||
|
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||||
|
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||||
|
import { decoratePlans } from '$lib/utils/pricing';
|
||||||
|
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||||
|
|
||||||
|
export let content: SiteSharedContent;
|
||||||
|
export let pageContent: ServicePageContent;
|
||||||
|
export let currentPath = '/';
|
||||||
|
|
||||||
|
$: clarity = pageContent.clarity;
|
||||||
|
$: traits = clarity?.traits ?? [];
|
||||||
|
$: steps = clarity?.steps ?? [];
|
||||||
|
$: planLines = clarity?.planLines ?? {};
|
||||||
|
$: priceNotes = clarity?.priceNotes ?? [];
|
||||||
|
$: pricingPlans = decoratePlans(
|
||||||
|
pageContent.pricing.plans.map((plan) => ({
|
||||||
|
...plan,
|
||||||
|
features: planLines[plan.title] ?? plan.features
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
$: faqItems = pageContent.faq?.items?.slice(0, 6) ?? [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="tg">
|
||||||
|
<!-- Hero: shared service-page hero (green copy left, photo bleeds right). -->
|
||||||
|
<ServiceHero
|
||||||
|
eyebrow={pageContent.hero.eyebrow}
|
||||||
|
title={pageContent.hero.title}
|
||||||
|
subtitle={pageContent.hero.subtitle}
|
||||||
|
imageUrl={pageContent.hero.imageUrl}
|
||||||
|
imageAlt={pageContent.hero.imageAlt}
|
||||||
|
chips={pageContent.hero.chips ?? []}
|
||||||
|
cta={pageContent.hero.cta}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- What it is: a plain, self-contained definition that opens the page. -->
|
||||||
|
{#if clarity?.definition}
|
||||||
|
<section class="tg-section tg-section-def" use:reveal={{ distance: 0 }} aria-labelledby="tg-def-heading">
|
||||||
|
<div class="tg-inner tg-inner-narrow">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity.definition.eyebrow ?? 'In plain terms'}</p>
|
||||||
|
<h2 id="tg-def-heading" class="tg-h2">{clarity.definition.heading}</h2>
|
||||||
|
</div>
|
||||||
|
<p class="tg-def-body">{clarity.definition.body}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- What makes it different (said once) -->
|
||||||
|
{#if traits.length}
|
||||||
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
||||||
|
<div class="tg-inner">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity?.diffEyebrow ?? 'What makes it different'}</p>
|
||||||
|
<h2 id="tg-diff-heading" class="tg-h2">{clarity?.diffHeading ?? ''}</h2>
|
||||||
|
</div>
|
||||||
|
<ul class="tg-traits">
|
||||||
|
{#each traits as trait}
|
||||||
|
<li class="tg-trait">
|
||||||
|
<span class="tg-trait-icon" aria-hidden="true"><Icon name={trait.icon} /></span>
|
||||||
|
<div>
|
||||||
|
<h3>{trait.title}</h3>
|
||||||
|
<p>{trait.body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- How it works (the process, explicit) -->
|
||||||
|
{#if steps.length}
|
||||||
|
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
|
||||||
|
<div class="tg-inner">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity?.stepsEyebrow ?? 'How it works'}</p>
|
||||||
|
<h2 id="tg-steps-heading" class="tg-h2">{clarity?.stepsHeading ?? ''}</h2>
|
||||||
|
</div>
|
||||||
|
<ol class="tg-steps">
|
||||||
|
{#each steps as step, i}
|
||||||
|
<li class="tg-step">
|
||||||
|
<span class="tg-step-num" aria-hidden="true">{i + 1}</span>
|
||||||
|
<h3>{step.title}</h3>
|
||||||
|
<p>{step.body}</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Image beat: one deliberate photo that breaks the longest text run. -->
|
||||||
|
{#if clarity?.imageBand}
|
||||||
|
<div class="tg-band-wrap" use:reveal={{ distance: 0 }}>
|
||||||
|
<figure class="tg-band">
|
||||||
|
<img
|
||||||
|
class="tg-band-img"
|
||||||
|
src={clarity.imageBand.imageUrl}
|
||||||
|
alt={clarity.imageBand.imageAlt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<figcaption class="tg-band-cap">{clarity.imageBand.caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Is it right for your dog? -->
|
||||||
|
{#if pageContent.decision}
|
||||||
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
|
||||||
|
<div class="tg-inner">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity?.fitEyebrow ?? 'Honest fit check'}</p>
|
||||||
|
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is this right for your dog?'}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="tg-fit">
|
||||||
|
<div class="tg-fit-col">
|
||||||
|
<h3>{pageContent.decision.fitTitle ?? 'A great fit if your dog:'}</h3>
|
||||||
|
<ul>
|
||||||
|
{#each pageContent.decision.fitItems as item}
|
||||||
|
<li>
|
||||||
|
<span class="tg-fit-mark tg-fit-mark-yes" aria-hidden="true"><Icon name="fas fa-check" /></span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="tg-fit-col">
|
||||||
|
<h3>{pageContent.decision.notFitTitle ?? 'Better with another option if your dog:'}</h3>
|
||||||
|
<ul>
|
||||||
|
{#each pageContent.decision.notFitItems as item}
|
||||||
|
<li>
|
||||||
|
<span class="tg-fit-mark tg-fit-mark-no" aria-hidden="true"><Icon name="fas fa-xmark" /></span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if pageContent.decision.footnote}
|
||||||
|
<p class="tg-fit-note">{pageContent.decision.footnote}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Proof -->
|
||||||
|
<TestimonialsSection
|
||||||
|
heading={clarity?.testimonialsHeading ?? pageContent.testimonialsHeading}
|
||||||
|
testimonials={content.testimonials}
|
||||||
|
seedKey={currentPath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
|
||||||
|
<div class="tg-inner tg-inner-wide">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity?.pricingEyebrow ?? 'Simple pricing'}</p>
|
||||||
|
<h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2>
|
||||||
|
{#if clarity?.pricingTagline}
|
||||||
|
<p class="tg-price-tagline">{clarity.pricingTagline}</p>
|
||||||
|
{/if}
|
||||||
|
{#if pageContent.pricing.intro}
|
||||||
|
<p class="tg-head-sub">{pageContent.pricing.intro}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tg-plans" class:tg-plans-three={pricingPlans.length === 3}>
|
||||||
|
{#each pricingPlans as plan}
|
||||||
|
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pageContent.pricing.scarcityNote || priceNotes.length}
|
||||||
|
<div class="tg-price-notes">
|
||||||
|
{#if pageContent.pricing.scarcityNote}
|
||||||
|
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
||||||
|
{/if}
|
||||||
|
{#each priceNotes as note}
|
||||||
|
<p class="tg-note"><Icon name={note.icon} />{note.label}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet & Greet</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
{#if faqItems.length}
|
||||||
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-faq-heading">
|
||||||
|
<div class="tg-inner tg-inner-narrow">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity?.faqEyebrow ?? 'Common questions'}</p>
|
||||||
|
<h2 id="tg-faq-heading" class="tg-h2">{pageContent.faq?.title ?? 'FAQs'}</h2>
|
||||||
|
{#if pageContent.faq?.intro}
|
||||||
|
<p class="tg-head-sub">{pageContent.faq.intro}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tg-faq">
|
||||||
|
<FaqSection title={pageContent.faq?.title ?? 'FAQs'} faqs={faqItems} showHeading={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Service area: a coverage field that works on every screen. -->
|
||||||
|
<div class="tg-section-area" use:reveal={{ distance: 0 }}>
|
||||||
|
<ServiceAreaCoverage
|
||||||
|
title={clarity?.areaTitle ?? 'Where we walk across Auckland Central'}
|
||||||
|
lead={clarity?.areaLead ??
|
||||||
|
'Free door-to-door pickup and drop-off across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes.'}
|
||||||
|
stats={clarity?.areaStats ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BookingWizard booking={pageContent.booking} pagePath={currentPath} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── One system: tokens for this page ──────────────────────────────── */
|
||||||
|
.tg {
|
||||||
|
--tg-max: 1120px;
|
||||||
|
--tg-narrow: 820px;
|
||||||
|
background: var(--off-white);
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-inner {
|
||||||
|
max-width: var(--tg-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-inline: var(--space-container-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-inner-narrow {
|
||||||
|
max-width: var(--tg-narrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pricing breaks out wider than the body so four cards have real width
|
||||||
|
(and the full CTA never clips). */
|
||||||
|
.tg-inner-wide {
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-section {
|
||||||
|
padding-block: clamp(48px, 6vw, 84px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternating surface for rhythm, not a new "system": just a tint. */
|
||||||
|
.tg-section-steps,
|
||||||
|
.tg-section-area {
|
||||||
|
background: var(--surface-panel-cream);
|
||||||
|
border-block: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One eyebrow, one heading, everywhere. */
|
||||||
|
.tg-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-head {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(28px, 3.4vw, 42px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-heading);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-head-sub {
|
||||||
|
margin: 14px auto 0;
|
||||||
|
max-width: 36rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Definition lede: one calm, readable paragraph under the opening head ── */
|
||||||
|
.tg-section-def {
|
||||||
|
padding-bottom: clamp(8px, 2vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-def-body {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--text-heading-soft);
|
||||||
|
font-size: clamp(17px, 1.7vw, 19px);
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-price-tagline {
|
||||||
|
margin: 14px auto 0;
|
||||||
|
max-width: 32rem;
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(17px, 1.7vw, 20px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Traits: a clean feature list, not boxed cards ─────────────────── */
|
||||||
|
.tg-traits {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: clamp(24px, 3vw, 40px) clamp(32px, 5vw, 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-trait {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-trait-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-brand);
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-trait h3 {
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-trait p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 38ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Steps: numerals, not cards ────────────────────────────────────── */
|
||||||
|
.tg-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: clamp(24px, 4vw, 48px);
|
||||||
|
counter-reset: tg-step;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-step {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-step-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-step h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-step p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fit check: one card, two columns ──────────────────────────────── */
|
||||||
|
.tg-fit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: clamp(20px, 3vw, 32px);
|
||||||
|
padding: clamp(24px, 3vw, 40px);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--surface-panel);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px var(--border-soft-strong),
|
||||||
|
var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-col h3 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-col ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-col li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
color: var(--text-heading-soft);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-top: 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-mark-yes {
|
||||||
|
background: rgba(var(--brand-rgb), 0.1);
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-mark-no {
|
||||||
|
background: rgba(var(--ink-rgb), 0.06);
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit-note {
|
||||||
|
margin: 22px auto 0;
|
||||||
|
max-width: 48rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Image beat: a contained cinematic band between sections ───────────── */
|
||||||
|
.tg-band-wrap {
|
||||||
|
max-width: var(--tg-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-inline: var(--space-container-x);
|
||||||
|
padding-block: clamp(8px, 2vw, 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-band {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
background: var(--surface-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-band-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 7;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Caption rides on the photo: legible over the image, keyword-bearing copy
|
||||||
|
that reads as a moment, not a label. */
|
||||||
|
.tg-band-cap {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(28px, 5vw, 44px) clamp(18px, 4vw, 32px) clamp(16px, 2.4vw, 22px);
|
||||||
|
background: linear-gradient(to top, rgba(33, 48, 33, 0.78), rgba(33, 48, 33, 0));
|
||||||
|
color: rgba(255, 248, 230, 0.96);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(15px, 1.8vw, 19px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pricing ───────────────────────────────────────────────────────── */
|
||||||
|
.tg-plans {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three-plan pages (solo walks, puppy visits) centre nicely at three-up. */
|
||||||
|
.tg-plans-three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
max-width: 1040px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
|
||||||
|
PricingPlanCard and other pages keep their own sizing. */
|
||||||
|
.tg-plans :global(.plan-card) {
|
||||||
|
padding: 44px 30px 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The featured card carries the "Goodwalk favourite" ribbon (absolute,
|
||||||
|
top: 16px). Give it enough head room that the ribbon sits clearly above
|
||||||
|
the title instead of over it. */
|
||||||
|
.tg-plans :global(.plan-card--featured) {
|
||||||
|
padding-top: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width CTA: never squished, and consistent across all cards.
|
||||||
|
display:flex (block-level) overrides the component's inline-flex +
|
||||||
|
align-self:center, so the button always spans the full card width. */
|
||||||
|
.tg-plans :global(.plan-card__cta) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The narrow 4-up cards can't fit "Book a Meet & Greet" plus the arrow on
|
||||||
|
one line, and the card clips overflow — so the label was chopped. Drop the
|
||||||
|
arrow here and let the label use the whole width on its own. */
|
||||||
|
.tg-plans :global(.plan-card__cta .icon) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-price-notes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-note {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-brand-soft);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-note-warm {
|
||||||
|
background: var(--surface-accent-muted);
|
||||||
|
color: #5a4500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tg-note .icon) {
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tg-note-warm .icon) {
|
||||||
|
color: #b88800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-plans-cta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAQ: a calm ruled list, not a stack of cards ──────────────────────
|
||||||
|
The shared FaqSection renders each question as a bordered, shadowed white
|
||||||
|
card. On this off-white page that reads as visual noise. Here we strip the
|
||||||
|
card chrome to hairline dividers so the questions sit as one quiet column,
|
||||||
|
matching the page's restraint. Scoped via .tg-faq; every other page keeps
|
||||||
|
the card treatment. */
|
||||||
|
.tg-faq :global(.faq details) {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details[open]) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-bleed rows: no card inset, generous vertical breathing room. */
|
||||||
|
.tg-faq :global(.faq summary) {
|
||||||
|
padding: 22px 4px;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover lifts the question to brand green instead of filling a card. */
|
||||||
|
.tg-faq :global(.faq summary:hover) {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details[open] summary) {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details p) {
|
||||||
|
padding: 0 44px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.tg-plans,
|
||||||
|
.tg-plans-three {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tg-traits {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-steps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-fit {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-band-img {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-plans,
|
||||||
|
.tg-plans-three {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-plans :global(.plan-card) {
|
||||||
|
padding: 30px 24px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-plans :global(.plan-card--featured) {
|
||||||
|
padding-top: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-plans-cta {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BookingWizard from '$lib/components/BookingWizard.svelte';
|
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import ServiceHero from '$lib/components/ServiceHero.svelte';
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
import type { SiteSharedContent, TestimonialContent } from '$lib/types';
|
import type { SiteSharedContent, TestimonialContent } from '$lib/types';
|
||||||
|
|
||||||
export let content: SiteSharedContent;
|
export let content: SiteSharedContent;
|
||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import type { BookingContent } from '$lib/types';
|
import type { BookingContent } from '$lib/types';
|
||||||
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
|
type SuccessModalComponentType = typeof import('$lib/components/ui/SuccessModal.svelte').default;
|
||||||
type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default;
|
type ErrorModalComponentType = typeof import('$lib/components/ui/ErrorModal.svelte').default;
|
||||||
|
|
||||||
export let booking: BookingContent;
|
export let booking: BookingContent;
|
||||||
export let allowGeneralEnquiry = false;
|
export let allowGeneralEnquiry = false;
|
||||||
@@ -87,12 +87,12 @@
|
|||||||
|
|
||||||
async function ensureSuccessModal() {
|
async function ensureSuccessModal() {
|
||||||
if (SuccessModalComponent) return;
|
if (SuccessModalComponent) return;
|
||||||
SuccessModalComponent = (await import('$lib/components/SuccessModal.svelte')).default;
|
SuccessModalComponent = (await import('$lib/components/ui/SuccessModal.svelte')).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureErrorModal() {
|
async function ensureErrorModal() {
|
||||||
if (ErrorModalComponent) return;
|
if (ErrorModalComponent) return;
|
||||||
ErrorModalComponent = (await import('$lib/components/ErrorModal.svelte')).default;
|
ErrorModalComponent = (await import('$lib/components/ui/ErrorModal.svelte')).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEmail(raw: string): string {
|
function validateEmail(raw: string): string {
|
||||||
+5
-2
@@ -114,7 +114,9 @@ describe('BookingSection', () => {
|
|||||||
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
||||||
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
// The success modal is code-split (dynamic import) and only mounts after
|
||||||
|
// submission resolves, so wait for it rather than asserting synchronously.
|
||||||
|
expect(await screen.findByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||||
@@ -167,7 +169,8 @@ describe('BookingSection', () => {
|
|||||||
journey: [window.location.pathname]
|
journey: [window.location.pathname]
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
// The success modal is code-split (dynamic import); wait for it to mount.
|
||||||
|
expect(await screen.findByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
+50
-5
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import type { BookingContent } from '$lib/types';
|
import type { BookingContent } from '$lib/types';
|
||||||
|
import { promoteJourney, trackEvent } from '$lib/analytics';
|
||||||
import { trackAb, type AbContext } from '$lib/ab';
|
import { trackAb, type AbContext } from '$lib/ab';
|
||||||
|
|
||||||
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
|
type SuccessModalComponentType = typeof import('$lib/components/ui/SuccessModal.svelte').default;
|
||||||
type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default;
|
type ErrorModalComponentType = typeof import('$lib/components/ui/ErrorModal.svelte').default;
|
||||||
|
|
||||||
export let booking: BookingContent;
|
export let booking: BookingContent;
|
||||||
export let pagePath = '';
|
export let pagePath = '';
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
let sendClickedAt = 0;
|
let sendClickedAt = 0;
|
||||||
let stepChanges = 0;
|
let stepChanges = 0;
|
||||||
let journey: string[] = [];
|
let journey: string[] = [];
|
||||||
|
let trackedFormStart = false;
|
||||||
|
|
||||||
let errors: Record<string, string> = {};
|
let errors: Record<string, string> = {};
|
||||||
let submitting = false;
|
let submitting = false;
|
||||||
@@ -154,16 +156,24 @@
|
|||||||
|
|
||||||
async function ensureSuccessModal() {
|
async function ensureSuccessModal() {
|
||||||
if (SuccessModalComponent) return;
|
if (SuccessModalComponent) return;
|
||||||
SuccessModalComponent = (await import('$lib/components/SuccessModal.svelte')).default;
|
SuccessModalComponent = (await import('$lib/components/ui/SuccessModal.svelte')).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureErrorModal() {
|
async function ensureErrorModal() {
|
||||||
if (ErrorModalComponent) return;
|
if (ErrorModalComponent) return;
|
||||||
ErrorModalComponent = (await import('$lib/components/ErrorModal.svelte')).default;
|
ErrorModalComponent = (await import('$lib/components/ui/ErrorModal.svelte')).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
function noteInteraction() {
|
function noteInteraction() {
|
||||||
if (!firstInteractionAt) firstInteractionAt = Date.now();
|
if (!firstInteractionAt) firstInteractionAt = Date.now();
|
||||||
|
if (!trackedFormStart) {
|
||||||
|
trackedFormStart = true;
|
||||||
|
trackEvent('lead_form_start', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
step: step,
|
||||||
|
page_path: typeof window !== 'undefined' ? window.location.pathname : pagePath || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearError(field: string) {
|
function clearError(field: string) {
|
||||||
@@ -175,6 +185,12 @@
|
|||||||
selectedServices = selectedServices.includes(service)
|
selectedServices = selectedServices.includes(service)
|
||||||
? selectedServices.filter((s) => s !== service)
|
? selectedServices.filter((s) => s !== service)
|
||||||
: [...selectedServices, service];
|
: [...selectedServices, service];
|
||||||
|
trackEvent('service_interest_select', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
selected_service: service,
|
||||||
|
selection_state: selectedServices.includes(service) ? 'removed' : 'added',
|
||||||
|
step
|
||||||
|
});
|
||||||
clearError('services');
|
clearError('services');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +242,11 @@
|
|||||||
function goNext() {
|
function goNext() {
|
||||||
noteInteraction();
|
noteInteraction();
|
||||||
if (!validateStep(step)) return;
|
if (!validateStep(step)) return;
|
||||||
|
trackEvent('lead_form_step_complete', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
step,
|
||||||
|
next_step: step + 1
|
||||||
|
});
|
||||||
step += 1;
|
step += 1;
|
||||||
stepChanges += 1;
|
stepChanges += 1;
|
||||||
}
|
}
|
||||||
@@ -233,6 +254,11 @@
|
|||||||
function goBack() {
|
function goBack() {
|
||||||
noteInteraction();
|
noteInteraction();
|
||||||
if (step > 1) {
|
if (step > 1) {
|
||||||
|
trackEvent('lead_form_step_back', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
step,
|
||||||
|
previous_step: step - 1
|
||||||
|
});
|
||||||
step -= 1;
|
step -= 1;
|
||||||
stepChanges += 1;
|
stepChanges += 1;
|
||||||
errors = {};
|
errors = {};
|
||||||
@@ -249,6 +275,12 @@
|
|||||||
sendClickedAt = Date.now();
|
sendClickedAt = Date.now();
|
||||||
submitErrorDetail = '';
|
submitErrorDetail = '';
|
||||||
showErrorModal = false;
|
showErrorModal = false;
|
||||||
|
trackEvent('lead_form_submit_attempt', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
selected_services: selectedServices.join(' | '),
|
||||||
|
step,
|
||||||
|
step_changes: stepChanges
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/submit', {
|
const res = await fetch('/api/submit', {
|
||||||
@@ -287,10 +319,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitted = true;
|
submitted = true;
|
||||||
|
trackEvent('lead_form_submit_success', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
selected_services: selectedServices.join(' | '),
|
||||||
|
step_changes: stepChanges
|
||||||
|
});
|
||||||
|
// Link the visitor's session_events journey to the submitted email so
|
||||||
|
// the owner can review it from the CP dashboard. Without this call,
|
||||||
|
// session_events expire after 24h. See docs/server-side-analytics.md.
|
||||||
|
promoteJourney(email);
|
||||||
if (ab) trackAb({ ...ab, event_type: 'conversion', meta: { surface: 'booking_submit' } });
|
if (ab) trackAb({ ...ab, event_type: 'conversion', meta: { surface: 'booking_submit' } });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
submitErrorDetail = err instanceof Error ? err.message : String(err);
|
submitErrorDetail = err instanceof Error ? err.message : String(err);
|
||||||
showErrorModal = true;
|
showErrorModal = true;
|
||||||
|
trackEvent('lead_form_submit_error', {
|
||||||
|
form_name: isCompactContactPage ? 'contact_compact' : 'booking_wizard',
|
||||||
|
error_detail: submitErrorDetail.slice(0, 120)
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { accordion } from '$lib/actions/accordion';
|
import { accordion } from '$lib/actions/accordion';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { FaqItem } from '$lib/types';
|
import type { FaqItem } from '$lib/types';
|
||||||
|
|
||||||
export let title = 'FAQs';
|
export let title = 'FAQs';
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
export let faqs: FaqItem[];
|
export let faqs: FaqItem[];
|
||||||
export let emitSchema = true;
|
export let emitSchema = true;
|
||||||
export let variant: 'panel' | 'plain' = 'panel';
|
export let variant: 'panel' | 'plain' = 'panel';
|
||||||
|
// When the host page supplies its own heading (e.g. to match a page-wide
|
||||||
|
// section system), suppress the built-in one but keep the schema + list.
|
||||||
|
export let showHeading = true;
|
||||||
|
|
||||||
$: schemaJson = JSON.stringify({
|
$: schemaJson = JSON.stringify({
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -30,12 +33,14 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
||||||
<h2 class="faq-section-heading">
|
{#if showHeading}
|
||||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
<h2 class="faq-section-heading">
|
||||||
{title}
|
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||||
</h2>
|
{title}
|
||||||
{#if intro}
|
</h2>
|
||||||
<p class="faq-section-intro">{intro}</p>
|
{#if intro}
|
||||||
|
<p class="faq-section-intro">{intro}</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div use:accordion class="faq">
|
<div use:accordion class="faq">
|
||||||
{#each faqs as faq}
|
{#each faqs as faq}
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import type { FounderStoryContent } from '$lib/types';
|
import type { FounderStoryContent } from '$lib/types';
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
$: founderStoryEnhanced = getEnhancedImage(founderStory.imageUrl);
|
$: founderStoryEnhanced = getEnhancedImage(founderStory.imageUrl);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="promise" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block">
|
<section id="promise" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block" data-track-location="founder_story">
|
||||||
<div class="founder-inner">
|
<div class="founder-inner">
|
||||||
<article class="founder-note">
|
<article class="founder-note">
|
||||||
<div class="founder-intro fade-up">
|
<div class="founder-intro fade-up">
|
||||||
@@ -55,12 +55,12 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="founder-actions fade-up">
|
<div class="founder-actions fade-up">
|
||||||
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk">
|
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk" data-track-event="contact_click" data-track-type="founder_email" data-track-label="Founder email note">
|
||||||
<span class="founder-contact-wave" aria-hidden="true">👋</span>
|
<span class="founder-contact-wave" aria-hidden="true">👋</span>
|
||||||
<span>If you are unsure about anything, feel free to email, call, or send me an Instagram DM anytime.</span>
|
<span>If you are unsure about anything, feel free to email, call, or send me an Instagram DM anytime.</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta cta-shimmer">
|
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta cta-shimmer" data-track-event="cta_click" data-track-type="founder_story_primary" data-track-label={founderStory.cta.label}>
|
||||||
{founderStory.cta.label}
|
{founderStory.cta.label}
|
||||||
<Icon name="fas fa-arrow-right" />
|
<Icon name="fas fa-arrow-right" />
|
||||||
</a>
|
</a>
|
||||||
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { CallToAction, HeroContent } from '$lib/types';
|
import type { CallToAction, HeroContent } from '$lib/types';
|
||||||
import { trackAb, type AbContext } from '$lib/ab';
|
import { trackAb, type AbContext } from '$lib/ab';
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="hero">
|
<section id="hero" data-track-location="hero">
|
||||||
<!-- hero-img is a direct child of #hero so it can be absolutely
|
<!-- hero-img is a direct child of #hero so it can be absolutely
|
||||||
positioned relative to the section on mobile without being
|
positioned relative to the section on mobile without being
|
||||||
constrained by hero-inner's stacking context -->
|
constrained by hero-inner's stacking context -->
|
||||||
@@ -124,6 +124,9 @@
|
|||||||
target={reviewCta.external ? '_blank' : undefined}
|
target={reviewCta.external ? '_blank' : undefined}
|
||||||
rel={reviewCta.external ? 'noopener' : undefined}
|
rel={reviewCta.external ? 'noopener' : undefined}
|
||||||
aria-label="Read our five-star Google reviews"
|
aria-label="Read our five-star Google reviews"
|
||||||
|
data-track-event="social_proof_click"
|
||||||
|
data-track-type="hero_reviews"
|
||||||
|
data-track-label={reviewCta.label}
|
||||||
>
|
>
|
||||||
<span class="hero-trust-mark" aria-hidden="true">
|
<span class="hero-trust-mark" aria-hidden="true">
|
||||||
<img
|
<img
|
||||||
@@ -152,6 +155,9 @@
|
|||||||
rel={linkRel(primaryCta.external)}
|
rel={linkRel(primaryCta.external)}
|
||||||
class="btn btn-yellow btn-with-arrow btn-hide-arrow-mobile"
|
class="btn btn-yellow btn-with-arrow btn-hide-arrow-mobile"
|
||||||
on:click={handlePrimaryCtaClick}
|
on:click={handlePrimaryCtaClick}
|
||||||
|
data-track-event="cta_click"
|
||||||
|
data-track-type="hero_primary"
|
||||||
|
data-track-label={primaryCta.label}
|
||||||
>
|
>
|
||||||
{primaryCta.label}
|
{primaryCta.label}
|
||||||
<Icon name="fas fa-arrow-right" />
|
<Icon name="fas fa-arrow-right" />
|
||||||
@@ -161,6 +167,9 @@
|
|||||||
target={linkTarget(hero.secondaryCta.external)}
|
target={linkTarget(hero.secondaryCta.external)}
|
||||||
rel={linkRel(hero.secondaryCta.external)}
|
rel={linkRel(hero.secondaryCta.external)}
|
||||||
class="hero-secondary-link"
|
class="hero-secondary-link"
|
||||||
|
data-track-event="cta_click"
|
||||||
|
data-track-type="hero_secondary"
|
||||||
|
data-track-label={hero.secondaryCta.label}
|
||||||
>
|
>
|
||||||
{hero.secondaryCta.label}
|
{hero.secondaryCta.label}
|
||||||
<Icon name="fas fa-arrow-down" className="hero-cta-arrow" />
|
<Icon name="fas fa-arrow-down" className="hero-cta-arrow" />
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { HowItWorksContent } from '$lib/types';
|
import type { HowItWorksContent } from '$lib/types';
|
||||||
|
|
||||||
export let content: HowItWorksContent;
|
export let content: HowItWorksContent;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="how-it-works" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block">
|
<section id="how-it-works" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block" data-track-location="how_it_works">
|
||||||
<div class="hiw-inner">
|
<div class="hiw-inner">
|
||||||
|
|
||||||
<div class="section-header hiw-header fade-up">
|
<div class="section-header hiw-header fade-up">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-cta fade-up">
|
<div class="hiw-cta fade-up">
|
||||||
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow cta-shimmer">
|
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow cta-shimmer" data-track-event="cta_click" data-track-type="how_it_works_primary" data-track-label="Book your free Meet and Greet">
|
||||||
Book your free Meet & Greet
|
Book your free Meet & Greet
|
||||||
<Icon name="fas fa-arrow-right" />
|
<Icon name="fas fa-arrow-right" />
|
||||||
</a>
|
</a>
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import FaqSection from '$lib/components/FaqSection.svelte';
|
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||||
import { locationPages } from '$lib/content/locations';
|
import { locationPages } from '$lib/content/locations';
|
||||||
import type { InfoContent } from '$lib/types';
|
import type { InfoContent } from '$lib/types';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
.map((suburb) => ({ name: suburb, slug: slugBySuburb.get(suburb) ?? null }));
|
.map((suburb) => ({ name: suburb, slug: slugBySuburb.get(suburb) ?? null }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="info">
|
<section id="info" data-track-location="info">
|
||||||
<div class="info-inner">
|
<div class="info-inner">
|
||||||
<div class="info-block">
|
<div class="info-block">
|
||||||
<h2>
|
<h2>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<span class="info-nearby-kicker">Nearby but not listed?</span>
|
<span class="info-nearby-kicker">Nearby but not listed?</span>
|
||||||
<p>{info.nearbyText} There's a good chance we can still help.</p>
|
<p>{info.nearbyText} There's a good chance we can still help.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="info-nearby-cta" href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
|
<a class="info-nearby-cta" href={info.nearbyCta.href} data-track-event="cta_click" data-track-type="info_nearby_suburb" data-track-label={info.nearbyCta.label}>{info.nearbyCta.label}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-hours-card">
|
<div class="info-hours-card">
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { HomePageContent } from '$lib/types';
|
import type { HomePageContent } from '$lib/types';
|
||||||
|
|
||||||
export let instagram: HomePageContent['instagram'];
|
export let instagram: HomePageContent['instagram'];
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { IntroContent } from '$lib/types';
|
import type { IntroContent } from '$lib/types';
|
||||||
|
|
||||||
export let intro: IntroContent;
|
export let intro: IntroContent;
|
||||||
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
export let context: 'onboarding' | 'contract' | 'owner' = 'onboarding';
|
export let context: 'onboarding' | 'contract' | 'owner' = 'onboarding';
|
||||||
$: introText =
|
$: introText =
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
? "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your contract."
|
? "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your contract."
|
||||||
: "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your onboarding.";
|
: "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your onboarding.";
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, unknown>; draft: Record<string, unknown>; cpAdmin?: boolean; ownerEmail?: string } }>();
|
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, unknown>; draft: Record<string, unknown>; cpAdmin?: boolean; ownerEmail?: string; previewEmails?: string[] } }>();
|
||||||
|
|
||||||
const ownerEmail = 'info@goodwalk.co.nz';
|
const ownerEmail = 'info@goodwalk.co.nz';
|
||||||
const ownerPhone = '(022) 642 1011';
|
const ownerPhone = '(022) 642 1011';
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
let draft: Record<string, unknown> = {};
|
let draft: Record<string, unknown> = {};
|
||||||
let cpAdmin = false;
|
let cpAdmin = false;
|
||||||
let verifiedOwnerEmail = '';
|
let verifiedOwnerEmail = '';
|
||||||
|
let previewEmails: string[] = [];
|
||||||
try {
|
try {
|
||||||
const verifyRes = await fetch('/api/auth/verify', {
|
const verifyRes = await fetch('/api/auth/verify', {
|
||||||
headers: { Authorization: `Bearer ${data.token}` },
|
headers: { Authorization: `Bearer ${data.token}` },
|
||||||
@@ -68,9 +69,10 @@
|
|||||||
draft = verifyData.draft ?? {};
|
draft = verifyData.draft ?? {};
|
||||||
cpAdmin = Boolean(verifyData.cpAdmin);
|
cpAdmin = Boolean(verifyData.cpAdmin);
|
||||||
verifiedOwnerEmail = typeof verifyData.ownerEmail === 'string' ? verifyData.ownerEmail : '';
|
verifiedOwnerEmail = typeof verifyData.ownerEmail === 'string' ? verifyData.ownerEmail : '';
|
||||||
|
previewEmails = Array.isArray(verifyData.previewEmails) ? verifyData.previewEmails.filter((value: unknown): value is string => typeof value === 'string') : [];
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
dispatch('authenticated', { email: data.email, profile, draft, cpAdmin, ownerEmail: verifiedOwnerEmail });
|
dispatch('authenticated', { email: data.email, profile, draft, cpAdmin, ownerEmail: verifiedOwnerEmail, previewEmails });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Something went wrong';
|
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Service-area coverage for the v5 service pages.
|
||||||
|
*
|
||||||
|
* Replaces the old fragile ServiceAreaMap (hand-placed SVG pins on fake
|
||||||
|
* geography, hidden entirely below 768px). This says the same thing more
|
||||||
|
* honestly: a central Auckland hub with coverage radiating out, and every
|
||||||
|
* suburb a real, tappable link to its local page. No pixel coordinates, so
|
||||||
|
* it is robust and identical on desktop and mobile.
|
||||||
|
*/
|
||||||
|
import { locationPages } from '$lib/content/locations';
|
||||||
|
import type { LocationPageContent, ServiceAreaStat } from '$lib/types';
|
||||||
|
|
||||||
|
export let eyebrow = 'Service area';
|
||||||
|
export let title = 'Where we walk across Auckland Central';
|
||||||
|
export let lead =
|
||||||
|
'Free door-to-door pickup and drop-off across Auckland Central and the suburbs around it. Tap your suburb for local parks, routes, and details.';
|
||||||
|
export let hubLabel = 'Auckland Central';
|
||||||
|
export let locations: LocationPageContent[] = locationPages;
|
||||||
|
export let stats: ServiceAreaStat[] | null = null;
|
||||||
|
|
||||||
|
$: suburbCount = locations.length;
|
||||||
|
$: resolvedStats =
|
||||||
|
stats ?? [
|
||||||
|
{ strong: `${suburbCount}`, label: 'suburbs covered' },
|
||||||
|
{ strong: 'Free', label: 'pickup & drop-off' },
|
||||||
|
{ strong: 'Door to door,', label: 'every walk' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="area" aria-labelledby="area-heading">
|
||||||
|
<div class="area-inner">
|
||||||
|
<div class="area-head">
|
||||||
|
<p class="area-eyebrow">{eyebrow}</p>
|
||||||
|
<h2 id="area-heading" class="area-title">{title}</h2>
|
||||||
|
<p class="area-lead">{lead}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area-stats">
|
||||||
|
{#each resolvedStats as stat}
|
||||||
|
<span class="area-stat"><strong>{stat.strong}</strong> {stat.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area-field">
|
||||||
|
<svg
|
||||||
|
class="area-rings"
|
||||||
|
viewBox="0 0 100 64"
|
||||||
|
preserveAspectRatio="xMidYMin slice"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="0.3">
|
||||||
|
<circle cx="50" cy="2" r="14" />
|
||||||
|
<circle cx="50" cy="2" r="28" />
|
||||||
|
<circle cx="50" cy="2" r="44" />
|
||||||
|
<circle cx="50" cy="2" r="60" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p class="area-hub">
|
||||||
|
<span class="area-hub-dot" aria-hidden="true"></span>
|
||||||
|
<span class="area-hub-text">
|
||||||
|
<strong>{hubLabel}</strong>
|
||||||
|
<span>The Goodwalk base. We come to you.</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="area-suburbs">
|
||||||
|
{#each locations as location}
|
||||||
|
<li>
|
||||||
|
<a class="area-suburb" href={`/locations/${location.slug}`}>{location.suburb}</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="area-caption">
|
||||||
|
Every suburb links to its local page with parks, routes, and service details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.area {
|
||||||
|
padding-block: clamp(48px, 6vw, 84px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-inner {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-inline: var(--space-container-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Head: matches the v5 section system (centered eyebrow + H2) ── */
|
||||||
|
.area-head {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto clamp(22px, 3vw, 32px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(28px, 3.4vw, 42px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-heading);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-lead {
|
||||||
|
margin: 14px auto 0;
|
||||||
|
max-width: 38rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat pills ── */
|
||||||
|
.area-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 auto clamp(24px, 3vw, 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-stat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-brand-soft);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-stat strong {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Coverage field ── */
|
||||||
|
.area-field {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--surface-panel);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px var(--border-soft-strong),
|
||||||
|
var(--shadow-card);
|
||||||
|
padding: clamp(28px, 4vw, 52px) clamp(18px, 3vw, 44px) clamp(30px, 4vw, 52px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coverage radiating from the hub: concentric rings anchored top-centre,
|
||||||
|
plus a soft brand glow. Decorative, resolution-independent, no pins. */
|
||||||
|
.area-rings {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
color: var(--gw-green);
|
||||||
|
opacity: 0.1;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-field::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(
|
||||||
|
120% 88% at 50% -12%,
|
||||||
|
rgba(var(--brand-rgb), 0.1),
|
||||||
|
rgba(var(--brand-rgb), 0) 62%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hub marker ── */
|
||||||
|
.area-hub {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 auto clamp(22px, 3vw, 32px);
|
||||||
|
padding: 10px 18px 10px 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
box-shadow: 0 12px 26px rgba(33, 48, 33, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-hub-dot {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-hub-dot::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--yellow);
|
||||||
|
opacity: 0.5;
|
||||||
|
animation: areaHubPulse 3s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-hub-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-hub-text strong {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-hub-text span {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Suburb links: a centred, wrapping cloud. The functional core. ── */
|
||||||
|
.area-suburbs {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: clamp(8px, 1vw, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-suburb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-panel);
|
||||||
|
border: 1px solid var(--border-soft-strong);
|
||||||
|
color: var(--text-heading);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 1px 2px rgba(17, 20, 24, 0.04);
|
||||||
|
transition:
|
||||||
|
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.area-suburb:hover {
|
||||||
|
background: var(--gw-green);
|
||||||
|
border-color: var(--gw-green);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(33, 48, 33, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-suburb:focus-visible {
|
||||||
|
outline: 2px solid var(--gw-green);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-suburb:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-caption {
|
||||||
|
margin: clamp(18px, 2.4vw, 24px) auto 0;
|
||||||
|
max-width: 40rem;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes areaHubPulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.area-hub-dot::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import type { CallToAction, HeroChip } from '$lib/types';
|
import type { CallToAction, HeroChip } from '$lib/types';
|
||||||
|
|
||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { IconCard } from '$lib/types';
|
import type { IconCard } from '$lib/types';
|
||||||
|
|
||||||
export let services: IconCard[];
|
export let services: IconCard[];
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
.map(({ service }) => service);
|
.map(({ service }) => service);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="services" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block">
|
<section id="services" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block" data-track-location="services">
|
||||||
<div class="services-inner">
|
<div class="services-inner">
|
||||||
<div class="section-header fade-up">
|
<div class="section-header fade-up">
|
||||||
<h2 class="section-heading">{heading}</h2>
|
<h2 class="section-heading">{heading}</h2>
|
||||||
@@ -84,6 +84,9 @@
|
|||||||
class:service-card-featured={meta?.featured}
|
class:service-card-featured={meta?.featured}
|
||||||
class="service-card fade-up"
|
class="service-card fade-up"
|
||||||
aria-label={`${service.title} — view service page`}
|
aria-label={`${service.title} — view service page`}
|
||||||
|
data-track-event="service_card_click"
|
||||||
|
data-track-type={meta?.featured ? 'featured_service_card' : 'service_card'}
|
||||||
|
data-track-label={service.title}
|
||||||
>
|
>
|
||||||
<div class="service-card-media">
|
<div class="service-card-media">
|
||||||
{#if meta}
|
{#if meta}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import { getSeededTestimonialIndex } from '$lib/testimonials';
|
import { getSeededTestimonialIndex } from '$lib/testimonials';
|
||||||
import type { TestimonialContent } from '$lib/types';
|
import type { TestimonialContent } from '$lib/types';
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { IconCard } from '$lib/types';
|
import type { IconCard } from '$lib/types';
|
||||||
|
|
||||||
export let values: IconCard[];
|
export let values: IconCard[];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
export let eyebrow = 'Get in touch';
|
export let eyebrow = 'Get in touch';
|
||||||
export let title: string;
|
export let title: string;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ModalShell from '$lib/components/ModalShell.svelte';
|
import ModalShell from '$lib/components/ui/ModalShell.svelte';
|
||||||
|
|
||||||
export let email = 'info@goodwalk.co.nz';
|
export let email = 'info@goodwalk.co.nz';
|
||||||
export let enquiryType: 'booking' | 'general' = 'booking';
|
export let enquiryType: 'booking' | 'general' = 'booking';
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { FooterContent, LinkItem } from '$lib/types';
|
import type { FooterContent, LinkItem } from '$lib/types';
|
||||||
import { locationPages } from '$lib/content/locations';
|
import { locationPages } from '$lib/content/locations';
|
||||||
|
|
||||||
export let footer: FooterContent;
|
export let footer: FooterContent;
|
||||||
|
// 'minimal' is a typography test running on the /variants/* pages.
|
||||||
|
export let variant: 'default' | 'minimal' = 'default';
|
||||||
|
|
||||||
|
|
||||||
const socialLinks: LinkItem[] = [
|
const socialLinks: LinkItem[] = [
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer>
|
<footer data-track-location="footer" class:footer--min={variant === 'minimal'}>
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
@@ -52,13 +54,13 @@
|
|||||||
<Icon name="fas fa-arrow-turn-down" className="footer-social-invite-arrow" />
|
<Icon name="fas fa-arrow-turn-down" className="footer-social-invite-arrow" />
|
||||||
</div>
|
</div>
|
||||||
<div class="social-links">
|
<div class="social-links">
|
||||||
<a href={socialLinks[0].href} target="_blank" rel="noopener" aria-label="Instagram">
|
<a href={socialLinks[0].href} target="_blank" rel="noopener" aria-label="Instagram" data-track-event="social_click" data-track-type="footer_social" data-track-label="Footer Instagram">
|
||||||
<Icon name="fab fa-instagram" />
|
<Icon name="fab fa-instagram" />
|
||||||
</a>
|
</a>
|
||||||
<a href={socialLinks[1].href} target="_blank" rel="noopener" aria-label="Facebook">
|
<a href={socialLinks[1].href} target="_blank" rel="noopener" aria-label="Facebook" data-track-event="social_click" data-track-type="footer_social" data-track-label="Footer Facebook">
|
||||||
<Icon name="fab fa-facebook-f" />
|
<Icon name="fab fa-facebook-f" />
|
||||||
</a>
|
</a>
|
||||||
<a href={socialLinks[2].href} target="_blank" rel="noopener" aria-label="Google">
|
<a href={socialLinks[2].href} target="_blank" rel="noopener" aria-label="Google" data-track-event="social_click" data-track-type="footer_social" data-track-label="Footer Google">
|
||||||
<Icon name="fab fa-google" />
|
<Icon name="fab fa-google" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,13 +69,13 @@
|
|||||||
{#if footer.email || footer.phone}
|
{#if footer.email || footer.phone}
|
||||||
<div class="footer-contact">
|
<div class="footer-contact">
|
||||||
{#if footer.email}
|
{#if footer.email}
|
||||||
<a href="mailto:{footer.email}" class="footer-contact-link">
|
<a href="mailto:{footer.email}" class="footer-contact-link" data-track-event="contact_click" data-track-type="email" data-track-label="Footer email">
|
||||||
<Icon name="fas fa-envelope" />
|
<Icon name="fas fa-envelope" />
|
||||||
{footer.email}
|
{footer.email}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if footer.phone}
|
{#if footer.phone}
|
||||||
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
|
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link" data-track-event="contact_click" data-track-type="phone" data-track-label="Footer phone">
|
||||||
<Icon name="fas fa-phone" />
|
<Icon name="fas fa-phone" />
|
||||||
{footer.phone}
|
{footer.phone}
|
||||||
</a>
|
</a>
|
||||||
@@ -82,7 +84,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-explore">
|
<div class="footer-explore" data-track-location="footer_explore">
|
||||||
<p class="footer-col-label">Explore Goodwalk</p>
|
<p class="footer-col-label">Explore Goodwalk</p>
|
||||||
<ul class="footer-nav">
|
<ul class="footer-nav">
|
||||||
{#each navigationLinks as link}
|
{#each navigationLinks as link}
|
||||||
@@ -91,6 +93,9 @@
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.external ? '_blank' : undefined}
|
target={link.external ? '_blank' : undefined}
|
||||||
rel={link.external ? 'noopener' : undefined}
|
rel={link.external ? 'noopener' : undefined}
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="footer_nav"
|
||||||
|
data-track-label={link.label}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
@@ -99,13 +104,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-locations">
|
<div class="footer-locations" data-track-location="footer_locations">
|
||||||
<p class="footer-col-label">
|
<p class="footer-col-label">
|
||||||
<a href="/locations">Areas we serve</a>
|
<a href="/locations">Areas we serve</a>
|
||||||
</p>
|
</p>
|
||||||
<ul class="footer-nav">
|
<ul class="footer-nav">
|
||||||
{#each locationPages as loc}
|
{#each locationPages as loc}
|
||||||
<li><a href="/locations/{loc.slug}">{loc.suburb}</a></li>
|
<li><a href="/locations/{loc.slug}" data-track-event="nav_click" data-track-type="footer_location" data-track-label={loc.suburb}>{loc.suburb}</a></li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,12 +118,70 @@
|
|||||||
|
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<span>{footer.copyright}</span>
|
<span>{footer.copyright}</span>
|
||||||
<nav class="footer-legal">
|
<nav class="footer-legal" data-track-location="footer_legal">
|
||||||
<a href="/terms-and-conditions">Terms & Conditions</a>
|
<a href="/terms-and-conditions" data-track-event="nav_click" data-track-type="footer_legal" data-track-label="Terms and Conditions">Terms & Conditions</a>
|
||||||
<a href="/privacy-policy">Privacy Policy</a>
|
<a href="/privacy-policy" data-track-event="nav_click" data-track-type="footer_legal" data-track-label="Privacy Policy">Privacy Policy</a>
|
||||||
</nav>
|
</nav>
|
||||||
<button type="button" class="footer-back-top" aria-label="Back to top" on:click={scrollToTop}>
|
<button type="button" class="footer-back-top" aria-label="Back to top" on:click={scrollToTop} data-track-event="nav_click" data-track-type="footer_back_to_top" data-track-label="Back to top">
|
||||||
↑ Back to top
|
↑ Back to top
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* Minimal footer variant (typography test on /variants/* pages). One calm
|
||||||
|
* type scale, lighter weights, more air. Structure and colour unchanged.
|
||||||
|
* Two sizes do all the work: 14px for links/text, 12px for meta. Labels drop
|
||||||
|
* the display font for tracked body caps, which reads cleaner at small sizes.
|
||||||
|
*/
|
||||||
|
.footer--min :global(.footer-logo) {
|
||||||
|
height: 26px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-brand p) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-col-label) {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-nav a),
|
||||||
|
.footer--min :global(.footer-contact-link) {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-nav a) {
|
||||||
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-nav a:hover),
|
||||||
|
.footer--min :global(.footer-contact-link:hover) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-bottom) {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-back-top) {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import type { NavigationContent } from '$lib/types';
|
import type { NavigationContent } from '$lib/types';
|
||||||
import type { Picture } from '@sveltejs/enhanced-img';
|
import type { Picture } from '@sveltejs/enhanced-img';
|
||||||
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
||||||
@@ -133,8 +133,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header bind:this={headerElement}>
|
<header bind:this={headerElement} data-track-location="header">
|
||||||
<nav>
|
<nav data-track-location="header_nav">
|
||||||
<ul class="nav-links nav-links-left">
|
<ul class="nav-links nav-links-left">
|
||||||
{#each leftLinks as link, i}
|
{#each leftLinks as link, i}
|
||||||
<li class:has-mega={i === 0 && navigation.megaMenuServices?.length}>
|
<li class:has-mega={i === 0 && navigation.megaMenuServices?.length}>
|
||||||
@@ -144,6 +144,9 @@
|
|||||||
rel={linkRel(link.external)}
|
rel={linkRel(link.external)}
|
||||||
aria-current={ariaCurrent(link.href)}
|
aria-current={ariaCurrent(link.href)}
|
||||||
class:nav-link-active={isActiveLink(link.href, i === 0 && Boolean(navigation.megaMenuServices?.length))}
|
class:nav-link-active={isActiveLink(link.href, i === 0 && Boolean(navigation.megaMenuServices?.length))}
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="desktop_primary"
|
||||||
|
data-track-label={link.label}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
{#if i === 0 && navigation.megaMenuServices?.length}
|
{#if i === 0 && navigation.megaMenuServices?.length}
|
||||||
@@ -162,6 +165,9 @@
|
|||||||
rel={linkRel(service.href.startsWith('http'))}
|
rel={linkRel(service.href.startsWith('http'))}
|
||||||
aria-current={ariaCurrent(service.href)}
|
aria-current={ariaCurrent(service.href)}
|
||||||
class="mega-service"
|
class="mega-service"
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="mega_menu_service"
|
||||||
|
data-track-label={service.label}
|
||||||
>
|
>
|
||||||
<div class="mega-icon">
|
<div class="mega-icon">
|
||||||
<Icon name={service.icon} />
|
<Icon name={service.icon} />
|
||||||
@@ -179,6 +185,9 @@
|
|||||||
target={linkTarget(navigation.megaMenuFooter.external)}
|
target={linkTarget(navigation.megaMenuFooter.external)}
|
||||||
rel={linkRel(navigation.megaMenuFooter.external)}
|
rel={linkRel(navigation.megaMenuFooter.external)}
|
||||||
class="mega-menu-footer"
|
class="mega-menu-footer"
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="mega_menu_footer"
|
||||||
|
data-track-label={navigation.megaMenuFooter.label}
|
||||||
>
|
>
|
||||||
<span>{navigation.megaMenuFooter.label}</span>
|
<span>{navigation.megaMenuFooter.label}</span>
|
||||||
<Icon name="fas fa-arrow-right" className="mega-menu-footer-arrow" />
|
<Icon name="fas fa-arrow-right" className="mega-menu-footer-arrow" />
|
||||||
@@ -191,7 +200,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<a href="/" class="logo" aria-label="Goodwalk – Auckland Dog Walking, home">
|
<a
|
||||||
|
href="/"
|
||||||
|
class="logo"
|
||||||
|
aria-label="Goodwalk – Auckland Dog Walking, home"
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="logo"
|
||||||
|
data-track-label="Goodwalk home"
|
||||||
|
>
|
||||||
<picture>
|
<picture>
|
||||||
{#if mobile.sources?.webp}
|
{#if mobile.sources?.webp}
|
||||||
<source type="image/webp" srcset={mobile.sources.webp} />
|
<source type="image/webp" srcset={mobile.sources.webp} />
|
||||||
@@ -216,6 +232,9 @@
|
|||||||
rel={linkRel(link.external)}
|
rel={linkRel(link.external)}
|
||||||
aria-current={ariaCurrent(link.href)}
|
aria-current={ariaCurrent(link.href)}
|
||||||
class:nav-link-active={isActiveLink(link.href)}
|
class:nav-link-active={isActiveLink(link.href)}
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="desktop_secondary"
|
||||||
|
data-track-label={link.label}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
@@ -224,7 +243,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<a href={`tel:${mobilePhoneHref}`} class="mobile-phone" aria-label={`Call Goodwalk on ${mobilePhoneDisplay}`}>
|
<a
|
||||||
|
href={`tel:${mobilePhoneHref}`}
|
||||||
|
class="mobile-phone"
|
||||||
|
aria-label={`Call Goodwalk on ${mobilePhoneDisplay}`}
|
||||||
|
data-track-event="contact_click"
|
||||||
|
data-track-type="phone"
|
||||||
|
data-track-label="Header phone"
|
||||||
|
>
|
||||||
<Icon name="fas fa-phone" />
|
<Icon name="fas fa-phone" />
|
||||||
</a>
|
</a>
|
||||||
{#if navigation.instagram}
|
{#if navigation.instagram}
|
||||||
@@ -234,6 +260,9 @@
|
|||||||
rel={linkRel(navigation.instagram.external)}
|
rel={linkRel(navigation.instagram.external)}
|
||||||
class="instagram-icon"
|
class="instagram-icon"
|
||||||
aria-label="Instagram"
|
aria-label="Instagram"
|
||||||
|
data-track-event="social_click"
|
||||||
|
data-track-type="instagram"
|
||||||
|
data-track-label="Header Instagram"
|
||||||
>
|
>
|
||||||
<Icon name="fab fa-instagram" />
|
<Icon name="fab fa-instagram" />
|
||||||
</a>
|
</a>
|
||||||
@@ -241,6 +270,9 @@
|
|||||||
<a
|
<a
|
||||||
href={navigation.cta.href}
|
href={navigation.cta.href}
|
||||||
class="btn btn-yellow"
|
class="btn btn-yellow"
|
||||||
|
data-track-event="cta_click"
|
||||||
|
data-track-type="header_primary"
|
||||||
|
data-track-label={navigation.cta.label}
|
||||||
>{navigation.cta.label}</a>
|
>{navigation.cta.label}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,6 +284,9 @@
|
|||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
on:click={toggleMenu}
|
on:click={toggleMenu}
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="mobile_menu_toggle"
|
||||||
|
data-track-label="Toggle menu"
|
||||||
>
|
>
|
||||||
<Icon name={mobileMenuOpen ? 'fas fa-xmark' : 'fas fa-bars'} />
|
<Icon name={mobileMenuOpen ? 'fas fa-xmark' : 'fas fa-bars'} />
|
||||||
</button>
|
</button>
|
||||||
@@ -284,6 +319,9 @@
|
|||||||
aria-current={ariaCurrent(link.href)}
|
aria-current={ariaCurrent(link.href)}
|
||||||
class:mobile-link-active={isActiveLink(link.href)}
|
class:mobile-link-active={isActiveLink(link.href)}
|
||||||
on:click={closeMenu}
|
on:click={closeMenu}
|
||||||
|
data-track-event="nav_click"
|
||||||
|
data-track-type="mobile_menu_link"
|
||||||
|
data-track-label={link.label}
|
||||||
>
|
>
|
||||||
<span class="mobile-menu-link-icon">
|
<span class="mobile-menu-link-icon">
|
||||||
<Icon name={mobileLinkIcon(link.href)} />
|
<Icon name={mobileLinkIcon(link.href)} />
|
||||||
@@ -2,7 +2,7 @@ import { fireEvent, render } from '@testing-library/svelte';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import Header from './Header.svelte';
|
import Header from './Header.svelte';
|
||||||
import { homepageContent } from '$lib/content/homepage';
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
import { setMockPage } from '../../test/mocks/app-stores';
|
import { setMockPage } from '../../../test/mocks/app-stores';
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
it('marks the services link active for service detail pages', () => {
|
it('marks the services link active for service detail pages', () => {
|
||||||
+3
-2
@@ -2,7 +2,7 @@
|
|||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { afterNavigate } from '$app/navigation';
|
import { afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
|
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -121,8 +121,9 @@
|
|||||||
class="mobile-book-bar"
|
class="mobile-book-bar"
|
||||||
class:mobile-book-bar-visible={visible}
|
class:mobile-book-bar-visible={visible}
|
||||||
aria-hidden={!visible}
|
aria-hidden={!visible}
|
||||||
|
data-track-location="mobile_sticky_cta"
|
||||||
>
|
>
|
||||||
<a class="mobile-book-bar-cta" href="/contact-us" tabindex={visible ? 0 : -1}>
|
<a class="mobile-book-bar-cta" href="/contact-us" tabindex={visible ? 0 : -1} data-track-event="cta_click" data-track-type="mobile_sticky_bar" data-track-label="Book a free Meet and Greet">
|
||||||
<Icon name="fas fa-paw" />
|
<Icon name="fas fa-paw" />
|
||||||
<span>Book a free Meet & Greet</span>
|
<span>Book a free Meet & Greet</span>
|
||||||
<Icon name="fas fa-arrow-right" className="mobile-book-bar-arrow" />
|
<Icon name="fas fa-arrow-right" className="mobile-book-bar-arrow" />
|
||||||
+1
-26
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
export let email = '';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ logout: void }>();
|
const dispatch = createEventDispatcher<{ logout: void }>();
|
||||||
|
|
||||||
@@ -32,9 +30,6 @@
|
|||||||
<Icon name="fas fa-arrow-left" />
|
<Icon name="fas fa-arrow-left" />
|
||||||
Back to main site
|
Back to main site
|
||||||
</a>
|
</a>
|
||||||
{#if email}
|
|
||||||
<span class="ob-footer-email">Signed in as {email}</span>
|
|
||||||
{/if}
|
|
||||||
<button type="button" class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
|
<button type="button" class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
|
||||||
<Icon name="fas fa-right-from-bracket" />
|
<Icon name="fas fa-right-from-bracket" />
|
||||||
{loggingOut ? 'Signing out…' : 'Sign out'}
|
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||||
@@ -75,16 +70,6 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ob-footer-email {
|
|
||||||
margin-left: auto;
|
|
||||||
min-width: 0;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ob-footer-logout {
|
.ob-footer-logout {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -116,16 +101,6 @@
|
|||||||
row-gap: 10px;
|
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-back,
|
||||||
.ob-footer-logout {
|
.ob-footer-logout {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
export let plan: {
|
export let plan: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import ModalShell from '$lib/components/ModalShell.svelte';
|
import ModalShell from '$lib/components/ui/ModalShell.svelte';
|
||||||
|
|
||||||
export let firstName: string;
|
export let firstName: string;
|
||||||
export let petName: string;
|
export let petName: string;
|
||||||
@@ -90,7 +90,6 @@ export const dogWalkingContent: ServicePageContent = {
|
|||||||
title: '45 Minute Solo Walk',
|
title: '45 Minute Solo Walk',
|
||||||
price: '$55',
|
price: '$55',
|
||||||
period: 'Per Walk',
|
period: 'Per Walk',
|
||||||
popular: true,
|
|
||||||
features: ['Free pickup and drop-off', 'Balanced walk length for most dogs', 'Time for calm handling and structure', 'Most popular length']
|
features: ['Free pickup and drop-off', 'Balanced walk length for most dogs', 'Time for calm handling and structure', 'Most popular length']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,6 +183,66 @@ export const dogWalkingContent: ServicePageContent = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
testimonialsHeading: 'What our clients say',
|
testimonialsHeading: 'What our clients say',
|
||||||
|
clarity: {
|
||||||
|
diffEyebrow: 'What makes solo walks different',
|
||||||
|
diffHeading: 'Built around your dog. Not a pack schedule.',
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user',
|
||||||
|
title: 'One dog, full attention',
|
||||||
|
body: 'No group to manage. Just your dog and their walker, the whole time.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-person-walking',
|
||||||
|
title: 'Their pace, their route',
|
||||||
|
body: 'Speed, distance, and tempo all shaped around your dog\'s confidence and energy.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user-check',
|
||||||
|
title: 'The same Goodwalk walker, every time',
|
||||||
|
body: 'One familiar handler your dog learns to trust. No rotating faces.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-shield-heart',
|
||||||
|
title: 'Room for sensitive dogs',
|
||||||
|
body: 'Reactive, anxious, senior, or recovering. Solo gives the space and calm they need.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stepsEyebrow: 'How it works',
|
||||||
|
stepsHeading: 'Three simple steps, no pressure.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Free Meet & Greet',
|
||||||
|
body: 'A Goodwalk walker comes to your home, meets your dog, and talks through exactly what they need.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'A settle-in walk',
|
||||||
|
body: 'A first one-on-one outing to learn your dog\'s pace, route, and the handling that suits them.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your regular slot',
|
||||||
|
body: 'A dependable weekly rhythm with the same walker, and a photo update after every walk.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
imageBand: {
|
||||||
|
imageUrl: '/images/goodwalk-large-dog-one-on-one-walk-auckland.webp',
|
||||||
|
imageAlt:
|
||||||
|
'A large dog enjoying a calm one-on-one Goodwalk solo walk in an Auckland park',
|
||||||
|
caption: 'One dog, one Goodwalk walker, full attention. Door-to-door across Auckland Central.'
|
||||||
|
},
|
||||||
|
fitEyebrow: 'Honest fit check',
|
||||||
|
pricingEyebrow: 'Simple per-walk pricing',
|
||||||
|
priceNotes: [
|
||||||
|
{ icon: 'fas fa-stopwatch', label: '30, 45, or 60 minutes.' },
|
||||||
|
{ icon: 'fas fa-car', label: 'Free pickup & drop-off, every walk.' },
|
||||||
|
{ icon: 'fas fa-camera', label: 'Photo update after every walk.' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||||
|
],
|
||||||
|
faqEyebrow: 'Common questions',
|
||||||
|
areaTitle: 'Where we walk across Auckland Central',
|
||||||
|
areaLead:
|
||||||
|
'Goodwalk covers Auckland Central and the suburbs around it, with free door-to-door pickup and drop-off. The same walker, every time. Tap your suburb for its local parks and routes.'
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
title: 'See if solo walks are the right fit',
|
title: 'See if solo walks are the right fit',
|
||||||
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
|
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
|
||||||
|
|||||||
+28
-18
@@ -36,9 +36,9 @@ export const homepageContent: HomePageContent = {
|
|||||||
title: 'Come home to a',
|
title: 'Come home to a',
|
||||||
highlight: 'calm, happy dog',
|
highlight: 'calm, happy dog',
|
||||||
mobileTitle: 'Come home to a\ncalm, happy dog',
|
mobileTitle: 'Come home to a\ncalm, happy dog',
|
||||||
seoHeading: 'Dog walking across Auckland Central',
|
seoHeading: 'Specialist dog walking for Auckland\'s small & medium dogs',
|
||||||
subtitle:
|
subtitle:
|
||||||
'Reliable dog walking across Auckland Central. Happier dogs. Quieter evenings.',
|
'Gentle pacing, familiar routines, and calmer evenings at home.',
|
||||||
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
|
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
|
||||||
secondaryCta: {
|
secondaryCta: {
|
||||||
label: 'See how it works',
|
label: 'See how it works',
|
||||||
@@ -99,30 +99,30 @@ export const homepageContent: HomePageContent = {
|
|||||||
howItWorks: {
|
howItWorks: {
|
||||||
title: 'How it works',
|
title: 'How it works',
|
||||||
intro:
|
intro:
|
||||||
'Meet your dog. Settle them in. Let the routine do the rest.',
|
'We assess carefully, build confidence gradually, and only give dogs the level of freedom they are ready for.',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
phase: 'Meet',
|
phase: 'Meet',
|
||||||
benefit: 'No pressure, just clarity',
|
benefit: 'Safe fit before anything starts',
|
||||||
title: 'We come to your home first.',
|
title: 'We come to your home and assess properly.',
|
||||||
body:
|
body:
|
||||||
"You show us your dog. We talk routine and temperament. Nobody books until everyone's sure.",
|
"We get to know your dog's temperament, confidence, energy, and routine. Nobody joins until the fit feels right for everyone.",
|
||||||
icon: 'fas fa-handshake'
|
icon: 'fas fa-handshake'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phase: 'Settle',
|
phase: 'Settle',
|
||||||
benefit: 'Confidence before commitment',
|
benefit: 'Confidence before more freedom',
|
||||||
title: 'Your dog settles in. No rushing.',
|
title: 'Your dog settles in at the right pace.',
|
||||||
body:
|
body:
|
||||||
'Two assessment walks. We find their pace before any regular spot.',
|
'Two assessment walks give us time to introduce the environment properly, support recall, and see what level of handling suits your dog best.',
|
||||||
icon: 'fas fa-clipboard-check'
|
icon: 'fas fa-clipboard-check'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phase: 'Thrive',
|
phase: 'Thrive',
|
||||||
benefit: 'Calmer evenings',
|
benefit: 'Calmer dogs, healthier mindset',
|
||||||
title: 'Then it just runs.',
|
title: 'Then the routine starts to work for them.',
|
||||||
body:
|
body:
|
||||||
'Your dog comes home tired and happy. Evenings get quieter. The workday stops carrying guilt.',
|
'Some dogs earn off-leash freedom, others stay more managed. Either way, the goal is the same: a dog who comes home calmer, happier, and more settled.',
|
||||||
icon: 'fas fa-heart'
|
icon: 'fas fa-heart'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -142,17 +142,17 @@ export const homepageContent: HomePageContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-users',
|
icon: 'fas fa-users',
|
||||||
title: 'Matched, not just grouped',
|
title: 'Matched, never random',
|
||||||
order: 2,
|
order: 2,
|
||||||
body:
|
body:
|
||||||
'4 to 8 dogs, matched on size, energy, and play style. Small and medium only.'
|
'Dogs are introduced thoughtfully based on temperament, confidence, energy, and social fit. Small and medium only.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-shield-heart',
|
icon: 'fas fa-shield-heart',
|
||||||
title: 'Safety, by default',
|
title: 'Safety with structure',
|
||||||
order: 1,
|
order: 1,
|
||||||
body:
|
body:
|
||||||
'Pet first aid certified. Careful screening. Proactive handling. The baseline, not a premium.'
|
'Pet first aid certified, carefully screened, and handled proactively. Not every dog is off leash, and that is by design.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-calendar-check',
|
icon: 'fas fa-calendar-check',
|
||||||
@@ -278,7 +278,7 @@ export const homepageContent: HomePageContent = {
|
|||||||
{
|
{
|
||||||
question: 'Can any dog use your service?',
|
question: 'Can any dog use your service?',
|
||||||
answer:
|
answer:
|
||||||
'Every dog goes through a screening process with a minimum of two assessment walks. We confirm the fit before anything regular starts.'
|
'No. Every dog goes through a screening process with a minimum of two assessment walks. We confirm the fit before anything regular starts, and we will say if a different setup would suit your dog better.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How does payment work?',
|
question: 'How does payment work?',
|
||||||
@@ -292,7 +292,17 @@ export const homepageContent: HomePageContent = {
|
|||||||
{
|
{
|
||||||
question: 'What requirements does my dog need?',
|
question: 'What requirements does my dog need?',
|
||||||
answer:
|
answer:
|
||||||
'A current Auckland Council dog registration and up-to-date vaccinations. That keeps every dog in our care safe.'
|
'A current Auckland Council dog registration, up-to-date vaccinations, and enough comfort around people, handling, and new environments to settle safely into the routine.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Are all dogs off leash on pack walks?',
|
||||||
|
answer:
|
||||||
|
'No. Off-leash time is only for dogs with the recall, social behaviour, and confidence to handle it well. We do not assume every dog should have the same level of freedom.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Does my dog need perfect recall before starting?',
|
||||||
|
answer:
|
||||||
|
'No. Dogs do not need to arrive fully off-leash ready. We work with each dog at the right pace to build confidence, healthier habits, and the right mindset before more freedom is introduced.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Do you have insurance or First Aid training?',
|
question: 'Do you have insurance or First Aid training?',
|
||||||
|
|||||||
+118
-24
@@ -4,17 +4,17 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
hero: {
|
hero: {
|
||||||
eyebrow: 'Tiny Gang Pack Walks',
|
eyebrow: 'Tiny Gang Pack Walks',
|
||||||
title: 'Pack walks that actually suit small & medium dogs',
|
title: 'Pack walks that actually suit small & medium dogs',
|
||||||
subtitle: 'Small groups. Same walker. A real walk, every time.',
|
subtitle: 'A proper adventure with a pack of like-minded dogs their own size, so yours comes home happy, completely worn out, and done for the day.',
|
||||||
introEyebrow: 'How Tiny Gang works',
|
introEyebrow: 'How Tiny Gang works',
|
||||||
introHeading: 'What joining the pack actually looks like',
|
introHeading: 'What joining Tiny Gang actually looks like',
|
||||||
detailHighlights: [
|
detailHighlights: [
|
||||||
{ value: '4-8', label: 'dogs per walk' },
|
{ value: '4-8', label: 'dogs per walk' },
|
||||||
{ value: '2', label: 'assessment walks first' },
|
{ value: '2', label: 'assessment walks first' },
|
||||||
{ value: '60-75', label: 'minutes on the ground' }
|
{ value: '60-75', label: 'minutes on the ground' }
|
||||||
],
|
],
|
||||||
paragraphs: [
|
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.',
|
'Tiny Gang is built for small and medium dogs who enjoy the right kind of company. We do not put random dogs together. Every group is matched carefully for temperament, confidence, energy, and social fit.',
|
||||||
'Free Meet & Greet at home, then two assessment walks. Regular slot only once we know the fit is right.',
|
'Free Meet & Greet at home, then two assessment walks. Regular slot only starts once we know your dog feels safe, settled, and well suited to the environment.',
|
||||||
'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.'
|
'60 to 75 minutes on the ground. We rotate Western Springs, Fowlds Park, Cornwall Park, Grey Lynn Park, and Oakley Creek — picked on the day for weather and group.'
|
||||||
],
|
],
|
||||||
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
|
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
|
||||||
@@ -28,24 +28,24 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
},
|
},
|
||||||
highlight: {
|
highlight: {
|
||||||
eyebrow: 'What Tiny Gang is',
|
eyebrow: 'What Tiny Gang is',
|
||||||
title: 'Small groups. Right dogs. Real walks.',
|
title: 'Safe, structured pack walks for the right dogs',
|
||||||
imageUrl: '/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp',
|
imageUrl: '/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp',
|
||||||
imageAlt: 'Small and medium dogs together on a Goodwalk pack walk in Auckland',
|
imageAlt: 'Small and medium dogs together on a Goodwalk pack walk in Auckland',
|
||||||
points: [
|
points: [
|
||||||
{
|
{
|
||||||
title: 'Small, social groups',
|
title: 'Carefully matched groups',
|
||||||
body:
|
body:
|
||||||
'4 to 8 dogs, carefully matched. Real play, movement, and social time — no oversized-pack chaos.'
|
'4 to 8 dogs, matched with intention. Temperament, confidence, energy, and social behaviour matter more than simply filling a slot.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Built for the right dogs',
|
title: 'Freedom is earned',
|
||||||
body:
|
body:
|
||||||
'Sociable small and medium dogs who enjoy company. If your dog needs more space, solo walks are the better fit.'
|
'Not every dog is off leash. Off-leash time is only for dogs with the recall, confidence, and social skills to handle that freedom well.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'A weekly rhythm that sticks',
|
title: 'Confidence comes before pressure',
|
||||||
body:
|
body:
|
||||||
'Regular weekday routine. Pickup and drop-off across Auckland Central. Easy to keep going.'
|
'Some dogs are ready quickly, others need more support first. We work with each dog at the pace that helps them feel secure and succeed.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -55,19 +55,21 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
fitItems: [
|
fitItems: [
|
||||||
'Is small or medium-sized',
|
'Is small or medium-sized',
|
||||||
'Enjoys other dogs',
|
'Enjoys other dogs',
|
||||||
'Is sociable and adaptable',
|
'Can settle into a new environment',
|
||||||
'Has settled basic on-lead manners',
|
'Has settled basic on-lead manners',
|
||||||
|
'Shows emerging recall or willingness to respond',
|
||||||
'Is up to date on vaccinations and Auckland Council registration'
|
'Is up to date on vaccinations and Auckland Council registration'
|
||||||
],
|
],
|
||||||
notFitTitle: 'Better with a solo walk if your dog:',
|
notFitTitle: 'Better with a solo walk if your dog:',
|
||||||
notFitItems: [
|
notFitItems: [
|
||||||
'Is reactive on the lead',
|
'Is reactive on the lead',
|
||||||
'Is anxious in groups of dogs',
|
'Is anxious in groups of dogs',
|
||||||
|
'Becomes overwhelmed by busy social environments',
|
||||||
'Is significantly larger than a typical pack member',
|
'Is significantly larger than a typical pack member',
|
||||||
'Is recovering from injury or surgery',
|
'Is recovering from injury or surgery',
|
||||||
'Needs a slower, steadier pace'
|
'Needs a slower, steadier pace'
|
||||||
],
|
],
|
||||||
footnote: 'Not sure? The free Meet & Greet decides — and we will be straight with you.'
|
footnote: 'Dogs do not need to arrive perfect or fully off-leash ready. They do need to be suitable for a structured, supportive environment, and we will be straight with you about the fit.'
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
title: 'Pick the weekly routine that fits',
|
title: 'Pick the weekly routine that fits',
|
||||||
@@ -83,7 +85,6 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
title: '2-3 Walks Per Week',
|
title: '2-3 Walks Per Week',
|
||||||
price: '$55',
|
price: '$55',
|
||||||
period: 'Per Walk',
|
period: 'Per Walk',
|
||||||
popular: true,
|
|
||||||
features: ['Two to three regular walks each week', 'Free pickup and drop-off', 'Consistent exercise and social time', 'Most popular routine']
|
features: ['Two to three regular walks each week', 'Free pickup and drop-off', 'Consistent exercise and social time', 'Most popular routine']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -108,7 +109,7 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
},
|
},
|
||||||
benefits: {
|
benefits: {
|
||||||
title: 'Why the right dogs thrive in Tiny Gang',
|
title: 'Why the right dogs thrive in Tiny Gang',
|
||||||
intro: 'Small groups. Compatible dogs. No chaos. That\'s why it works.',
|
intro: 'Safe groups, clear structure, and the right pace for each dog. That is why it works.',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Calmer evenings at home',
|
title: 'Calmer evenings at home',
|
||||||
@@ -118,13 +119,13 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Confidence with the right dogs',
|
title: 'Confidence with the right dogs',
|
||||||
body: 'Matched groups. Right pace. No pressure from bigger mixed packs.',
|
body: 'Matched groups, right pace, and no random mixing. Dogs can build social confidence without being thrown in at the deep end.',
|
||||||
badge: 'Carefully matched groups',
|
badge: 'Carefully matched groups',
|
||||||
icon: 'fas fa-user-group'
|
icon: 'fas fa-user-group'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'No bigger dogs to dodge',
|
title: 'Small and medium dogs only',
|
||||||
body: 'Built for small and medium dogs. Group size, pace, and play style all picked to help them feel safe.',
|
body: 'Built for small and medium dogs. Group size, pace, and play style are chosen to help them feel safe and capable.',
|
||||||
badge: 'Small & medium dogs',
|
badge: 'Small & medium dogs',
|
||||||
icon: 'fas fa-shield-dog'
|
icon: 'fas fa-shield-dog'
|
||||||
},
|
},
|
||||||
@@ -136,13 +137,13 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Eyes on every dog',
|
title: 'Eyes on every dog',
|
||||||
body: 'Smaller groups mean we notice confidence, handling, and the small details that matter.',
|
body: 'Smaller groups mean we notice recall, confidence, behaviour shifts, and the small details that matter before they become bigger problems.',
|
||||||
badge: '4–8 dogs per walk',
|
badge: '4–8 dogs per walk',
|
||||||
icon: 'fas fa-eye'
|
icon: 'fas fa-eye'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Safer than a one-size pack',
|
title: 'Safer than a one-size-fits-all pack',
|
||||||
body: 'Calm, compatible packs. Less intimidation. A safer walk than a one-size-fits-all approach.',
|
body: 'Calm, compatible groups and earned freedom. Less intimidation, less chaos, and better decisions for each dog.',
|
||||||
badge: 'Calm, compatible packs',
|
badge: 'Calm, compatible packs',
|
||||||
icon: 'fas fa-shield-heart'
|
icon: 'fas fa-shield-heart'
|
||||||
}
|
}
|
||||||
@@ -160,12 +161,12 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
{
|
{
|
||||||
question: 'How big are the pack walks?',
|
question: 'How big are the pack walks?',
|
||||||
answer:
|
answer:
|
||||||
'4 to 8 dogs, carefully matched on size, energy, and play style. We never run oversized packs — that is the whole point.'
|
'4 to 8 dogs, carefully matched on temperament, confidence, energy, and play style. We never run oversized, random packs. That is the whole point.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What size and type of dog suits a Tiny Gang walk?',
|
question: 'What size and type of dog suits a Tiny Gang walk?',
|
||||||
answer:
|
answer:
|
||||||
'Sociable small and medium dogs who enjoy other dogs. If your dog is reactive, anxious in groups, or much larger than the typical pack, a solo walk is the better fit — and we will say so at the Meet & Greet.'
|
'Sociable small and medium dogs who enjoy other dogs, can settle into a new environment, and are suitable for a structured group setting. If your dog is reactive, anxious in groups, or much larger than the typical pack, a solo walk is the better fit, and we will say so at the Meet & Greet.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How long is a Tiny Gang walk and where do you go?',
|
question: 'How long is a Tiny Gang walk and where do you go?',
|
||||||
@@ -180,7 +181,17 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
{
|
{
|
||||||
question: 'How does my dog join the Tiny Gang?',
|
question: 'How does my dog join the Tiny Gang?',
|
||||||
answer:
|
answer:
|
||||||
'Free Meet & Greet at home, then two assessment walks before a regular slot. Time for your dog to settle in without pressure.'
|
'Free Meet & Greet at home, then two assessment walks before a regular slot. That gives us time to assess fit properly, build confidence, and avoid rushing dogs into the wrong setup.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Are all dogs off leash on Tiny Gang walks?',
|
||||||
|
answer:
|
||||||
|
'No. Off-leash time is only for dogs with reliable recall, appropriate social behaviour, and the confidence to manage that freedom well. Some dogs stay more managed, and that is intentional.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Does my dog need perfect recall before joining Tiny Gang?',
|
||||||
|
answer:
|
||||||
|
'No. Dogs do not need to start perfect. What matters is that they are suitable for a structured, supportive environment and can build toward more freedom safely over time.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I book casual or one-off pack walks?',
|
question: 'Can I book casual or one-off pack walks?',
|
||||||
@@ -195,6 +206,89 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
testimonialsHeading: 'What our clients say',
|
testimonialsHeading: 'What our clients say',
|
||||||
|
clarity: {
|
||||||
|
diffEyebrow: 'What makes Tiny Gang different',
|
||||||
|
diffHeading: 'Not a big anonymous pack. A small, matched crew.',
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-users',
|
||||||
|
title: 'Carefully matched groups',
|
||||||
|
body: '4 to 8 dogs, grouped by temperament, energy, and play style. Never a random pack.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-shield-dog',
|
||||||
|
title: 'Small & medium dogs only',
|
||||||
|
body: 'Pace, group size, and play style are all built for little dogs, so yours is never the odd one out.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user-check',
|
||||||
|
title: 'The same Goodwalk walker, every week',
|
||||||
|
body: 'One familiar face at the door and on the walk. Your dog is known, not handed around.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-eye',
|
||||||
|
title: 'Eyes on every dog',
|
||||||
|
body: 'Small groups mean we notice the little things early, from recall to a quiet day.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stepsEyebrow: 'How joining works',
|
||||||
|
stepsHeading: 'Three simple steps, no pressure.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Free Meet & Greet',
|
||||||
|
body: 'A Goodwalk walker comes to your home, meets your dog, and talks honestly about whether the pack suits them.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Two assessment walks',
|
||||||
|
body: 'Your dog settles in gently while we confirm the right group, pace, and handling.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your weekly slot',
|
||||||
|
body: 'A regular spot with the same little crew and the same walker, week in, week out.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
imageBand: {
|
||||||
|
imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
|
||||||
|
imageAlt:
|
||||||
|
'Eight small and medium Tiny Gang dogs sitting together in the back of the Goodwalk pickup vehicle after an Auckland pack walk',
|
||||||
|
caption: 'Door-to-door pickup, every walk. The Goodwalk Tiny Gang, homeward bound after a park run.'
|
||||||
|
},
|
||||||
|
fitEyebrow: 'Honest fit check',
|
||||||
|
pricingEyebrow: 'Simple weekly pricing',
|
||||||
|
pricingTagline: "The best investment you'll ever make is in your dog's happiness.",
|
||||||
|
priceNotes: [
|
||||||
|
{ icon: 'fas fa-stopwatch', label: '60+ minute adventures.' },
|
||||||
|
{ icon: 'fas fa-car', label: 'Free pickup & drop-off, every walk.' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||||
|
],
|
||||||
|
faqEyebrow: 'Common questions',
|
||||||
|
planLines: {
|
||||||
|
'1 Walk Per Week': [
|
||||||
|
'A calm weekly outing',
|
||||||
|
'Time to sniff, play, and unwind',
|
||||||
|
'Great for dogs just starting out'
|
||||||
|
],
|
||||||
|
'2-3 Walks Per Week': [
|
||||||
|
'Steady exercise and real social time',
|
||||||
|
'A settled mid-week rhythm',
|
||||||
|
'Our balanced routine'
|
||||||
|
],
|
||||||
|
'4-5 Walks Per Week': [
|
||||||
|
'Best value per walk',
|
||||||
|
'Maximum routine and structure',
|
||||||
|
'For high-energy, social dogs'
|
||||||
|
],
|
||||||
|
'Casual Pack Walk': [
|
||||||
|
'No weekly commitment',
|
||||||
|
'Book only the weeks you need',
|
||||||
|
'For dogs already in the pack'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
testimonialsHeading: 'Owners love the Tiny Gang',
|
||||||
|
areaTitle: 'Where the Tiny Gang walks',
|
||||||
|
areaLead:
|
||||||
|
'Goodwalk covers Auckland Central and the suburbs around it, with free door-to-door pickup and drop-off. You drive nowhere. Tap your suburb for its local parks and routes.'
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
title: 'See if your dog fits our Tiny Gang',
|
title: 'See if your dog fits our Tiny Gang',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ export const privacyPolicyContent: LegalPageContent = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Analytics',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content:
|
||||||
|
'We use website analytics to understand how visitors use our site, such as which pages are most popular and how people find us. Some of this measurement runs in your browser through standard analytics tools, and some happens through our own systems. The browsing record we keep includes the pages you view, the buttons and links you click, the time of each action, and a random identifier stored in your browser. It does not include your name, email address, phone number, or anything else you type into our forms.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content:
|
||||||
|
'If you do not submit an enquiry, this browsing record is deleted automatically within 24 hours. If you do submit an enquiry through our booking or contact forms, we keep your browsing record from that visit and link it to your enquiry so we can understand how you found us and what you were interested in before getting in touch. This information is visible only to the Goodwalk team through our internal dashboard and is used to provide a better response to your enquiry and improve our services. It is not shared with third parties or used for advertising. You can ask us to delete this record at any time by emailing us at info@goodwalk.co.nz.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'How we use your information',
|
title: 'How we use your information',
|
||||||
blocks: [
|
blocks: [
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export const puppyVisitsContent: ServicePageContent = {
|
|||||||
title: '45 Minute Visit',
|
title: '45 Minute Visit',
|
||||||
price: '$49',
|
price: '$49',
|
||||||
period: 'Per Visit',
|
period: 'Per Visit',
|
||||||
popular: true,
|
|
||||||
features: ['Toilet break and feeding if needed', 'Play and enrichment time', 'Early routine-building support', 'Most popular visit length']
|
features: ['Toilet break and feeding if needed', 'Play and enrichment time', 'Early routine-building support', 'Most popular visit length']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,6 +168,71 @@ export const puppyVisitsContent: ServicePageContent = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
testimonialsHeading: 'What our clients say',
|
testimonialsHeading: 'What our clients say',
|
||||||
|
clarity: {
|
||||||
|
diffEyebrow: 'What makes Puppy Visits different',
|
||||||
|
diffHeading: 'Real support at home, while you\'re out.',
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-house',
|
||||||
|
title: 'Care in your own home',
|
||||||
|
body: 'Toilet break, fresh water, a feed if scheduled, and calm company. In the place your puppy already knows.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-clock',
|
||||||
|
title: 'Short and gentle, on purpose',
|
||||||
|
body: 'Low-impact company while growth plates settle. The right stimulation, without the joint stress.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user-check',
|
||||||
|
title: 'The same face, every visit',
|
||||||
|
body: 'One trusted handler your puppy learns early. Consistency matters most at this age.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-paw',
|
||||||
|
title: 'A smoother step into Tiny Gang',
|
||||||
|
body: 'Goodwalk knows your puppy from the start, so moving into walks later is calm, not sudden.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stepsEyebrow: 'How it works',
|
||||||
|
stepsHeading: 'Three simple steps, no pressure.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Free Meet & Greet',
|
||||||
|
body: 'A Goodwalk handler comes to your home, meets your puppy, and sets up access and routine together.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your first visit',
|
||||||
|
body: 'A gentle settle-in visit so your puppy gets used to us in familiar surroundings.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your regular routine',
|
||||||
|
body: 'Dependable midday visits with the same handler, and a photo update every time.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
imageBand: {
|
||||||
|
imageUrl: '/images/goodwalk-puppy-visit-cavalier-king-charles-spaniel-auckland.webp',
|
||||||
|
imageAlt:
|
||||||
|
'A young Cavalier King Charles Spaniel puppy resting calmly at home during a Goodwalk puppy visit in Auckland',
|
||||||
|
caption: 'Fed, played with, and settled by Goodwalk. A calmer day at home while you\'re out.'
|
||||||
|
},
|
||||||
|
fitEyebrow: 'Honest fit check',
|
||||||
|
pricingEyebrow: 'Simple per-visit pricing',
|
||||||
|
priceNotes: [
|
||||||
|
{ icon: 'fas fa-stopwatch', label: '20, 45, or 60 minutes.' },
|
||||||
|
{ icon: 'fas fa-house', label: 'In your own home, every visit.' },
|
||||||
|
{ icon: 'fas fa-camera', label: 'Photo update after every visit.' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||||
|
],
|
||||||
|
faqEyebrow: 'Common questions',
|
||||||
|
areaTitle: 'Where we visit',
|
||||||
|
areaLead:
|
||||||
|
'Goodwalk runs in-home puppy visits across Auckland Central and the suburbs around it. We come to you. Tap your suburb for its local details.',
|
||||||
|
areaStats: [
|
||||||
|
{ strong: '17', label: 'suburbs visited' },
|
||||||
|
{ strong: 'In-home', label: 'one-on-one care' },
|
||||||
|
{ strong: 'Same face', label: 'every visit' }
|
||||||
|
]
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
title: 'See if Puppy Visits are the right start',
|
title: 'See if Puppy Visits are the right start',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const imageMetadata: Record<string, ImageMetadata> = {
|
|||||||
'/images/wallace-goodwalk-dog-walking-review-auckland.webp': { width: 1254, height: 1254 },
|
'/images/wallace-goodwalk-dog-walking-review-auckland.webp': { width: 1254, height: 1254 },
|
||||||
'/images/goodwalk-small-dog-pack-walk-auckland.webp': { width: 640, height: 480 },
|
'/images/goodwalk-small-dog-pack-walk-auckland.webp': { width: 640, height: 480 },
|
||||||
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp': { width: 1469, height: 1071 },
|
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp': { width: 1469, height: 1071 },
|
||||||
|
'/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp': { width: 1457, height: 1079 },
|
||||||
'/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp': { width: 1240, height: 1269 },
|
'/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp': { width: 1240, height: 1269 },
|
||||||
'/images/goodwalk-happy-black-dog-one-on-one-walk-auckland.webp': { width: 1054, height: 1492 },
|
'/images/goodwalk-happy-black-dog-one-on-one-walk-auckland.webp': { width: 1054, height: 1492 },
|
||||||
'/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp': { width: 1091, height: 1441 },
|
'/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp': { width: 1091, height: 1441 },
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ export function buildBreadcrumb(items: BreadcrumbItem[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a schema.org AggregateOffer from a set of priced plans. Used for the
|
||||||
|
* "from $X" pricing on service pages and in the homepage OfferCatalog so both
|
||||||
|
* advertise the same accurate low/high range in NZD. Returns null when no plan
|
||||||
|
* carries a usable price.
|
||||||
|
*/
|
||||||
|
export function buildAggregateOffer(plans: { price: string }[]) {
|
||||||
|
const numericPrices = plans
|
||||||
|
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
if (numericPrices.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowPrice = Math.min(...numericPrices);
|
||||||
|
const highPrice = Math.max(...numericPrices);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@type': 'AggregateOffer',
|
||||||
|
priceCurrency: 'NZD',
|
||||||
|
lowPrice: lowPrice.toFixed(2),
|
||||||
|
highPrice: highPrice.toFixed(2),
|
||||||
|
offerCount: numericPrices.length,
|
||||||
|
availability: 'https://schema.org/InStock'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAreaServed(locations = locationPages) {
|
export function buildAreaServed(locations = locationPages) {
|
||||||
return locations.map((location) => ({
|
return locations.map((location) => ({
|
||||||
'@type': 'Place',
|
'@type': 'Place',
|
||||||
|
|||||||
@@ -260,35 +260,32 @@
|
|||||||
/* ── Full-bleed mobile hero ── */
|
/* ── Full-bleed mobile hero ── */
|
||||||
|
|
||||||
#hero {
|
#hero {
|
||||||
|
--hero-photo-band: 46svh;
|
||||||
min-height: 90svh;
|
min-height: 90svh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Full-section gradient: transparent top → Goodwalk green bottom.
|
/* Two-zone mobile hero: the photo sits in its own clear band at the top so
|
||||||
hero-img is now a sibling of hero-inner (direct child of #hero),
|
Maya's face is never covered, and the copy sits below on solid Goodwalk
|
||||||
so this ::after correctly overlays the full section. */
|
green. This ::after only softens the seam where the photo meets the green
|
||||||
|
text zone; it no longer veils the dog. */
|
||||||
#hero::after {
|
#hero::after {
|
||||||
inset: 0;
|
inset: auto 0 auto 0;
|
||||||
height: auto;
|
top: calc(var(--hero-photo-band) - 72px);
|
||||||
background: linear-gradient(
|
height: 72px;
|
||||||
to bottom,
|
background: linear-gradient(to bottom, transparent, var(--surface-brand));
|
||||||
transparent 22%,
|
|
||||||
rgba(var(--brand-rgb), 0.45) 48%,
|
|
||||||
rgba(var(--brand-rgb), 0.88) 65%,
|
|
||||||
var(--surface-brand) 78%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dog photo fills the entire section as a background layer */
|
/* Dog photo occupies a dedicated band at the top, not a full-bleed layer */
|
||||||
.hero-img {
|
.hero-img {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
inset: auto;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: var(--hero-photo-band);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: none;
|
flex: none;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -306,7 +303,7 @@
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: 37% center;
|
object-position: 35% 30%;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +316,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 var(--space-container-x-mobile) 36px;
|
padding: 24px var(--space-container-x-mobile) 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-text {
|
.hero-text {
|
||||||
|
|||||||
@@ -157,6 +157,68 @@ export interface ServiceDecisionBlock {
|
|||||||
footnote?: string;
|
footnote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceTrait {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceJoinStep {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceImageBand {
|
||||||
|
imageUrl: string;
|
||||||
|
imageAlt: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicePriceNote {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAreaStat {
|
||||||
|
strong: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 "clarity" content: the page-specific copy the shared ServiceClarityPage
|
||||||
|
* needs so all three service pages run on one component. See docs/v5.md.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Optional "what it is" lede that opens a service page: a short, self-contained
|
||||||
|
* definition of the service in Goodwalk's voice. Renders right after the hero.
|
||||||
|
*/
|
||||||
|
export interface ServiceDefinition {
|
||||||
|
eyebrow?: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceClarityContent {
|
||||||
|
definition?: ServiceDefinition;
|
||||||
|
diffEyebrow: string;
|
||||||
|
diffHeading: string;
|
||||||
|
traits: ServiceTrait[];
|
||||||
|
stepsEyebrow: string;
|
||||||
|
stepsHeading: string;
|
||||||
|
steps: ServiceJoinStep[];
|
||||||
|
imageBand?: ServiceImageBand;
|
||||||
|
fitEyebrow?: string;
|
||||||
|
pricingEyebrow?: string;
|
||||||
|
pricingTagline?: string;
|
||||||
|
priceNotes?: ServicePriceNote[];
|
||||||
|
faqEyebrow?: string;
|
||||||
|
planLines?: Record<string, string[]>;
|
||||||
|
testimonialsHeading?: string;
|
||||||
|
areaTitle?: string;
|
||||||
|
areaLead?: string;
|
||||||
|
areaStats?: ServiceAreaStat[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServicePageContent {
|
export interface ServicePageContent {
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
@@ -202,6 +264,7 @@ export interface ServicePageContent {
|
|||||||
};
|
};
|
||||||
testimonialsHeading: string;
|
testimonialsHeading: string;
|
||||||
booking: BookingContent;
|
booking: BookingContent;
|
||||||
|
clarity?: ServiceClarityContent;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { decoratePlans } from './pricing';
|
import { decoratePlans } from './pricing';
|
||||||
|
|
||||||
describe('decoratePlans', () => {
|
describe('decoratePlans', () => {
|
||||||
it('prefers an explicit popular plan over the cheapest plan', () => {
|
it('always highlights the cheapest plan, ignoring any popular flag', () => {
|
||||||
const plans = decoratePlans([
|
const plans = decoratePlans([
|
||||||
{ title: 'Cheapest', price: '$39', period: 'Per Walk' },
|
{ title: 'Cheapest', price: '$39', period: 'Per Walk' },
|
||||||
{ title: 'Recommended', price: '$49', period: 'Per Walk', popular: true },
|
{ title: 'Recommended', price: '$49', period: 'Per Walk', popular: true },
|
||||||
{ title: 'Longest', price: '$55', period: 'Per Walk' }
|
{ title: 'Longest', price: '$55', period: 'Per Walk' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(plans.map((plan) => plan.isPopular)).toEqual([false, true, false]);
|
expect(plans.map((plan) => plan.isPopular)).toEqual([true, false, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the cheapest plan when none is marked popular', () => {
|
it('highlights the cheapest plan regardless of order', () => {
|
||||||
const plans = decoratePlans([
|
const plans = decoratePlans([
|
||||||
{ title: 'Weekly', price: '$58', period: 'Per Walk' },
|
{ title: 'Weekly', price: '$58', period: 'Per Walk' },
|
||||||
{ title: 'Regular', price: '$55', period: 'Per Walk' },
|
{ title: 'Regular', price: '$55', period: 'Per Walk' },
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ export function decoratePlans<T extends { price: string; period: string }>(plans
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const sorted = [...enriched].sort((a, b) => a.value - b.value || a.index - b.index);
|
const sorted = [...enriched].sort((a, b) => a.value - b.value || a.index - b.index);
|
||||||
const explicitPopularIndex = plans.findIndex(
|
// The cheapest plan is always the highlighted one, on desktop and mobile.
|
||||||
(plan) => 'popular' in plan && Boolean((plan as T & { popular?: boolean }).popular)
|
const featuredIndex = sorted[0]?.index ?? -1;
|
||||||
);
|
|
||||||
const featuredIndex = explicitPopularIndex >= 0 ? explicitPopularIndex : (sorted[0]?.index ?? -1);
|
|
||||||
const mobileOrder = new Map(sorted.map((entry, order) => [entry.index, order]));
|
const mobileOrder = new Map(sorted.map((entry, order) => [entry.index, order]));
|
||||||
|
|
||||||
return plans.map((plan, index) => ({
|
return plans.map((plan, index) => ({
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { navigating, page } from '$app/stores';
|
import { navigating, page } from '$app/stores';
|
||||||
import { afterNavigate, disableScrollHandling } from '$app/navigation';
|
import { afterNavigate, disableScrollHandling } from '$app/navigation';
|
||||||
import { initClickTracking, trackPageView } from '$lib/analytics';
|
import { initClickTracking, trackPageView } from '$lib/analytics';
|
||||||
import MobileBookBar from '$lib/components/MobileBookBar.svelte';
|
import MobileBookBar from '$lib/components/ui/MobileBookBar.svelte';
|
||||||
import RouteSkeleton from '$lib/components/RouteSkeleton.svelte';
|
import RouteSkeleton from '$lib/components/ui/RouteSkeleton.svelte';
|
||||||
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
|
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
|
||||||
import readex400Woff2 from '@fontsource/readex-pro/files/readex-pro-latin-400-normal.woff2?url';
|
import readex400Woff2 from '@fontsource/readex-pro/files/readex-pro-latin-400-normal.woff2?url';
|
||||||
import readex700Woff2 from '@fontsource/readex-pro/files/readex-pro-latin-700-normal.woff2?url';
|
import readex700Woff2 from '@fontsource/readex-pro/files/readex-pro-latin-700-normal.woff2?url';
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initClickTracking();
|
const cleanupClickTracking = initClickTracking();
|
||||||
requestAnimationFrame(initReveal);
|
requestAnimationFrame(initReveal);
|
||||||
|
|
||||||
restoreScrollPosition();
|
restoreScrollPosition();
|
||||||
@@ -142,6 +142,7 @@
|
|||||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cleanupClickTracking();
|
||||||
window.removeEventListener('hashchange', onHashChange);
|
window.removeEventListener('hashchange', onHashChange);
|
||||||
window.removeEventListener('pagehide', onPageHide);
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
window.removeEventListener('pageshow', onPageShow);
|
window.removeEventListener('pageshow', onPageShow);
|
||||||
|
|||||||
+34
-62
@@ -1,35 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SeoHead from '$lib/components/SeoHead.svelte';
|
import SeoHead from '$lib/components/ui/SeoHead.svelte';
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import OnboardingPage from '$lib/components/pages/OnboardingPage.svelte';
|
||||||
import Header from '$lib/components/Header.svelte';
|
// ── Homepage design ──────────────────────────────────────────────────
|
||||||
import HeroSection from '$lib/components/HeroSection.svelte';
|
// The live homepage renders HomeClarity. To roll back to the original
|
||||||
import HowItWorksSection from '$lib/components/HowItWorksSection.svelte';
|
// layout, change the two references below to HomeClassic (its preview lives
|
||||||
import InfoSection from '$lib/components/InfoSection.svelte';
|
// at /variants/homepage-original). Both are pure view components; all data
|
||||||
import InstagramSection from '$lib/components/InstagramSection.svelte';
|
// loading, SEO head, and structured data stay here.
|
||||||
import BookingWizard from '$lib/components/BookingWizard.svelte';
|
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
|
||||||
import FounderStorySection from '$lib/components/FounderStorySection.svelte';
|
import { buildAggregateOffer, buildAreaServed } from '$lib/seo';
|
||||||
import ServicesSection from '$lib/components/ServicesSection.svelte';
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
import ValuesSection from '$lib/components/ValuesSection.svelte';
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
import OnboardingPage from '$lib/components/OnboardingPage.svelte';
|
|
||||||
import { buildAreaServed } from '$lib/seo';
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { CallToAction } from '$lib/types';
|
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||||
|
|
||||||
// hero_cta experiment: free_emphasis swaps "Book a Meet & Greet"
|
// Pricing plans keyed by service URL, so the OfferCatalog can advertise the
|
||||||
// for "Book a FREE Meet & Greet". Same href, same destination.
|
// same accurate "from–to" NZD range the individual service pages publish.
|
||||||
$: heroAb = data.ab;
|
const servicePlansByHref: Record<string, { price: string }[]> = {
|
||||||
$: heroOverride =
|
'/pack-walks': packWalksContent.pricing.plans,
|
||||||
data.siteVariant === 'marketing' && data.content && heroAb?.variant === 'free_emphasis'
|
'/dog-walking': dogWalkingContent.pricing.plans,
|
||||||
? ({
|
'/puppy-visits': puppyVisitsContent.pricing.plans
|
||||||
...data.content.hero.primaryCta,
|
};
|
||||||
label: 'Book a FREE Meet & Greet',
|
|
||||||
} satisfies CallToAction)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
function absoluteUrl(value: string) {
|
function absoluteUrl(value: string) {
|
||||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
@@ -80,14 +74,20 @@
|
|||||||
hasOfferCatalog: {
|
hasOfferCatalog: {
|
||||||
'@type': 'OfferCatalog',
|
'@type': 'OfferCatalog',
|
||||||
name: 'Dog Walking Services',
|
name: 'Dog Walking Services',
|
||||||
itemListElement: data.content.services.map((service) => ({
|
itemListElement: data.content.services.map((service) => {
|
||||||
'@type': 'Offer',
|
const offers = buildAggregateOffer(
|
||||||
itemOffered: {
|
service.href ? servicePlansByHref[service.href] ?? [] : []
|
||||||
'@type': 'Service',
|
);
|
||||||
name: service.title,
|
return {
|
||||||
url: `${siteUrl}${service.href}`
|
'@type': 'Offer',
|
||||||
}
|
itemOffered: {
|
||||||
}))
|
'@type': 'Service',
|
||||||
|
name: service.title,
|
||||||
|
url: `${siteUrl}${service.href}`,
|
||||||
|
...(offers ? { offers } : {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
},
|
},
|
||||||
aggregateRating: {
|
aggregateRating: {
|
||||||
'@type': 'AggregateRating',
|
'@type': 'AggregateRating',
|
||||||
@@ -139,19 +139,6 @@
|
|||||||
name: 'Public Liability Insurance'
|
name: 'Public Liability Insurance'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'HowTo',
|
|
||||||
name: 'How to start with Goodwalk',
|
|
||||||
description: data.content.howItWorks.intro,
|
|
||||||
totalTime: 'P14D',
|
|
||||||
step: data.content.howItWorks.steps.map((step, index) => ({
|
|
||||||
'@type': 'HowToStep',
|
|
||||||
position: index + 1,
|
|
||||||
name: step.title,
|
|
||||||
text: step.body
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
@@ -179,20 +166,5 @@
|
|||||||
: ''}
|
: ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Header navigation={content.navigation} />
|
<HomeClarity {content} ab={data.ab} pagePath="/" />
|
||||||
<HeroSection
|
|
||||||
hero={content.hero}
|
|
||||||
reviewCta={content.intro.reviewCta}
|
|
||||||
primaryCtaOverride={heroOverride}
|
|
||||||
ab={heroAb}
|
|
||||||
/>
|
|
||||||
<ValuesSection values={content.values} />
|
|
||||||
<ServicesSection services={content.services} />
|
|
||||||
<HowItWorksSection content={content.howItWorks} />
|
|
||||||
<TestimonialsSection testimonials={content.testimonials} seedKey="/" />
|
|
||||||
<FounderStorySection founderStory={content.founderStory} />
|
|
||||||
<InfoSection info={content.info} />
|
|
||||||
<BookingWizard booking={content.booking} pagePath="/" ab={heroAb} />
|
|
||||||
<InstagramSection instagram={content.instagram} />
|
|
||||||
<Footer footer={content.footer} />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SeoHead from '$lib/components/SeoHead.svelte';
|
import SeoHead from '$lib/components/ui/SeoHead.svelte';
|
||||||
import AboutPage from '$lib/components/AboutPage.svelte';
|
import AboutPage from '$lib/components/pages/AboutPage.svelte';
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import BookingPage from '$lib/components/BookingPage.svelte';
|
import BookingPage from '$lib/components/pages/BookingPage.svelte';
|
||||||
import LegalPage from '$lib/components/LegalPage.svelte';
|
import LegalPage from '$lib/components/pages/LegalPage.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import PricingPage from '$lib/components/PricingPage.svelte';
|
import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
|
||||||
import TestimonialsPage from '$lib/components/TestimonialsPage.svelte';
|
import TestimonialsPage from '$lib/components/pages/TestimonialsPage.svelte';
|
||||||
import { aboutPageContent } from '$lib/content/about';
|
import { aboutPageContent } from '$lib/content/about';
|
||||||
import ServiceLandingPage from '$lib/components/ServiceLandingPage.svelte';
|
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
import { ourPricingContent } from '$lib/content/our-pricing';
|
|
||||||
import { packWalksContent } from '$lib/content/pack-walks';
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
||||||
import { buildAreaServed, buildBreadcrumb, absoluteUrl, goodwalkProviderNode } from '$lib/seo';
|
import {
|
||||||
|
buildAggregateOffer,
|
||||||
|
buildAreaServed,
|
||||||
|
buildBreadcrumb,
|
||||||
|
absoluteUrl,
|
||||||
|
goodwalkProviderNode
|
||||||
|
} from '$lib/seo';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@@ -25,35 +30,13 @@
|
|||||||
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
|
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
|
||||||
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
||||||
|
|
||||||
function aggregateOfferSchema(plans: { price: string }[]) {
|
|
||||||
const numericPrices = plans
|
|
||||||
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0);
|
|
||||||
|
|
||||||
if (numericPrices.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowPrice = Math.min(...numericPrices);
|
|
||||||
const highPrice = Math.max(...numericPrices);
|
|
||||||
|
|
||||||
return {
|
|
||||||
'@type': 'AggregateOffer',
|
|
||||||
priceCurrency: 'NZD',
|
|
||||||
lowPrice: lowPrice.toFixed(2),
|
|
||||||
highPrice: highPrice.toFixed(2),
|
|
||||||
offerCount: numericPrices.length,
|
|
||||||
availability: 'https://schema.org/InStock'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildServiceNode(base: {
|
function buildServiceNode(base: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
image: string;
|
image: string;
|
||||||
url: string;
|
url: string;
|
||||||
offers: ReturnType<typeof aggregateOfferSchema>;
|
offers: ReturnType<typeof buildAggregateOffer>;
|
||||||
}) {
|
}) {
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -108,7 +91,7 @@
|
|||||||
serviceType: 'Pack walks for small and medium dogs',
|
serviceType: 'Pack walks for small and medium dogs',
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
offers: buildAggregateOffer(packWalksContent.pricing.plans)
|
||||||
}),
|
}),
|
||||||
buildBreadcrumb([
|
buildBreadcrumb([
|
||||||
{ name: 'Home', url: siteUrl },
|
{ name: 'Home', url: siteUrl },
|
||||||
@@ -126,7 +109,7 @@
|
|||||||
serviceType: 'One-on-one dog walking',
|
serviceType: 'One-on-one dog walking',
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
offers: buildAggregateOffer(dogWalkingContent.pricing.plans)
|
||||||
}),
|
}),
|
||||||
buildBreadcrumb([
|
buildBreadcrumb([
|
||||||
{ name: 'Home', url: siteUrl },
|
{ name: 'Home', url: siteUrl },
|
||||||
@@ -144,7 +127,7 @@
|
|||||||
serviceType: 'In-home puppy visits',
|
serviceType: 'In-home puppy visits',
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
offers: buildAggregateOffer(puppyVisitsContent.pricing.plans)
|
||||||
}),
|
}),
|
||||||
buildBreadcrumb([
|
buildBreadcrumb([
|
||||||
{ name: 'Home', url: siteUrl },
|
{ name: 'Home', url: siteUrl },
|
||||||
@@ -228,13 +211,13 @@
|
|||||||
<Header navigation={data.content.navigation} />
|
<Header navigation={data.content.navigation} />
|
||||||
|
|
||||||
{#if data.slug === 'pack-walks'}
|
{#if data.slug === 'pack-walks'}
|
||||||
<ServiceLandingPage content={data.content} pageContent={packWalksContent} currentPath={data.page.canonicalPath} />
|
<ServiceClarityPage content={data.content} pageContent={packWalksContent} currentPath={data.page.canonicalPath} />
|
||||||
{:else if data.slug === 'dog-walking'}
|
{:else if data.slug === 'dog-walking'}
|
||||||
<ServiceLandingPage content={data.content} pageContent={dogWalkingContent} currentPath={data.page.canonicalPath} />
|
<ServiceClarityPage content={data.content} pageContent={dogWalkingContent} currentPath={data.page.canonicalPath} />
|
||||||
{:else if data.slug === 'puppy-visits'}
|
{:else if data.slug === 'puppy-visits'}
|
||||||
<ServiceLandingPage content={data.content} pageContent={puppyVisitsContent} currentPath={data.page.canonicalPath} />
|
<ServiceClarityPage content={data.content} pageContent={puppyVisitsContent} currentPath={data.page.canonicalPath} />
|
||||||
{:else if data.slug === 'our-pricing'}
|
{:else if data.slug === 'our-pricing'}
|
||||||
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
<PricingClarityPage />
|
||||||
{:else if data.slug === 'about'}
|
{:else if data.slug === 'about'}
|
||||||
<AboutPage pageContent={aboutPageContent} />
|
<AboutPage pageContent={aboutPageContent} />
|
||||||
{:else if data.slug === 'testimonials'}
|
{:else if data.slug === 'testimonials'}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getPool } from '$lib/server/db';
|
||||||
|
|
||||||
|
// First-party analytics ingest. Two jobs:
|
||||||
|
//
|
||||||
|
// 1. Persist every event into `session_events` keyed by the anon_id cookie
|
||||||
|
// set in hooks.server.ts. That table powers visitor-journey
|
||||||
|
// reconstruction when someone submits the booking form. Old rows are
|
||||||
|
// pruned after 24h (see PRUNE_TTL_HOURS).
|
||||||
|
//
|
||||||
|
// 2. When the client signals that gtag.js failed to load (`forward_ga4`),
|
||||||
|
// forward the event server-to-server to GA4 via the Measurement
|
||||||
|
// Protocol so we don't lose analytics for ad-blocked visitors.
|
||||||
|
//
|
||||||
|
// Disclosure for this pipeline lives in the Privacy Policy under
|
||||||
|
// "Analytics". Keep the two in sync if you change what we send or store.
|
||||||
|
|
||||||
|
const ALLOWED_EVENT_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
||||||
|
const MAX_PARAM_KEY = 40;
|
||||||
|
const MAX_PARAM_VALUE = 200;
|
||||||
|
const MAX_PARAMS = 25;
|
||||||
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
const MAX_PAGE_PATH = 512;
|
||||||
|
const BOT_UA_RE = /bot|spider|crawl|slurp|facebookexternalhit|whatsapp|telegram|preview|monitor|pingdom|uptime|lighthouse|headlesschrome|axios|curl|wget|python-requests/i;
|
||||||
|
|
||||||
|
const PRUNE_TTL_HOURS = 24;
|
||||||
|
const PRUNE_PROBABILITY = 0.005; // ~1 in 200 inserts triggers a cleanup pass
|
||||||
|
|
||||||
|
const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
|
||||||
|
|
||||||
|
let schemaReady: Promise<void> | null = null;
|
||||||
|
function ensureSchema(pool: ReturnType<typeof getPool>): Promise<void> {
|
||||||
|
if (!pool) return Promise.resolve();
|
||||||
|
if (!schemaReady) {
|
||||||
|
schemaReady = pool
|
||||||
|
.query(
|
||||||
|
`create table if not exists session_events (
|
||||||
|
id bigserial primary key,
|
||||||
|
anon_id text not null,
|
||||||
|
event_name text not null,
|
||||||
|
page_path text,
|
||||||
|
params jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists session_events_anon_idx
|
||||||
|
on session_events (anon_id, created_at);
|
||||||
|
create index if not exists session_events_created_idx
|
||||||
|
on session_events (created_at);`
|
||||||
|
)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch((err) => {
|
||||||
|
schemaReady = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return schemaReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGaClientId(cookieHeader: string | null): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader.match(/(?:^|;\s*)_ga=([^;]+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const parts = match[1].split('.');
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
return `${parts[2]}.${parts[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitiseParams(input: unknown): Record<string, string | number | boolean> {
|
||||||
|
if (!input || typeof input !== 'object') return {};
|
||||||
|
const out: Record<string, string | number | boolean> = {};
|
||||||
|
let count = 0;
|
||||||
|
for (const [rawKey, rawVal] of Object.entries(input as Record<string, unknown>)) {
|
||||||
|
if (count >= MAX_PARAMS) break;
|
||||||
|
const key = rawKey.slice(0, MAX_PARAM_KEY).replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
if (!key) continue;
|
||||||
|
if (typeof rawVal === 'string') out[key] = rawVal.slice(0, MAX_PARAM_VALUE);
|
||||||
|
else if (typeof rawVal === 'number' && Number.isFinite(rawVal)) out[key] = rawVal;
|
||||||
|
else if (typeof rawVal === 'boolean') out[key] = rawVal;
|
||||||
|
else continue;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardToGa4(
|
||||||
|
ua: string,
|
||||||
|
ip: string,
|
||||||
|
clientId: string,
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const measurementId = process.env.GA4_MEASUREMENT_ID;
|
||||||
|
const apiSecret = process.env.GA4_API_SECRET;
|
||||||
|
if (!measurementId || !apiSecret) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
client_id: clientId,
|
||||||
|
events: [{ name, params: { engagement_time_msec: 1, ...params } }]
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${GA4_ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'user-agent': ua, 'x-forwarded-for': ip },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('GA4 MP forward failed', res.status, await res.text().catch(() => ''));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GA4 MP forward threw', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePrune(pool: ReturnType<typeof getPool>): Promise<void> {
|
||||||
|
if (!pool) return;
|
||||||
|
if (Math.random() > PRUNE_PROBABILITY) return;
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`delete from session_events where created_at < now() - ($1 || ' hours')::interval`,
|
||||||
|
[String(PRUNE_TTL_HOURS)]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('session_events prune failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request, locals, getClientAddress }) {
|
||||||
|
const ua = request.headers.get('user-agent') ?? '';
|
||||||
|
if (!ua || BOT_UA_RE.test(ua)) {
|
||||||
|
return json({ ok: true, recorded: false, reason: 'ua' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(request.headers.get('content-length') ?? '0');
|
||||||
|
if (contentLength > MAX_BODY_BYTES) throw error(413, 'payload too large');
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
throw error(400, 'invalid json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
|
||||||
|
if (!ALLOWED_EVENT_NAME.test(name)) throw error(400, 'bad event name');
|
||||||
|
|
||||||
|
const params = sanitiseParams(body.params);
|
||||||
|
const pagePath =
|
||||||
|
typeof params.page_path === 'string' ? params.page_path.slice(0, MAX_PAGE_PATH) : null;
|
||||||
|
const forwardGa4 = body.forward_ga4 === true;
|
||||||
|
|
||||||
|
const anonId = locals.anonId;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Persist to session_events when we have both an anonId (marketing surface)
|
||||||
|
// and a DB. Either being missing is fine — we still respond OK.
|
||||||
|
if (anonId && pool) {
|
||||||
|
try {
|
||||||
|
await ensureSchema(pool);
|
||||||
|
await pool.query(
|
||||||
|
`insert into session_events (anon_id, event_name, page_path, params)
|
||||||
|
values ($1, $2, $3, $4::jsonb)`,
|
||||||
|
[anonId, name, pagePath, JSON.stringify(params)]
|
||||||
|
);
|
||||||
|
// Fire-and-forget probabilistic cleanup. Run after insert so a failure
|
||||||
|
// in prune never blocks the recorded event.
|
||||||
|
maybePrune(pool);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('session_events insert failed', err);
|
||||||
|
// Don't 500 the client — analytics must never break the page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forwardGa4) {
|
||||||
|
const ip = getClientAddress();
|
||||||
|
const clientId =
|
||||||
|
parseGaClientId(request.headers.get('cookie')) ?? anonId ?? crypto.randomUUID();
|
||||||
|
await forwardToGa4(ua, ip, clientId, name, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getPool } from '$lib/server/db';
|
||||||
|
|
||||||
|
// Promote a visitor's recent session_events into submission_journeys so the
|
||||||
|
// owner can review the full pre-submission journey from the CP dashboard.
|
||||||
|
// Called by the booking form on submit success. Without this call, the
|
||||||
|
// session_events rows expire after 24h and the journey is gone forever.
|
||||||
|
//
|
||||||
|
// This endpoint is the moment an anonymous browsing record becomes linked
|
||||||
|
// to a named person. The Privacy Policy must disclose it; do not call it
|
||||||
|
// from anywhere other than a form submission the user explicitly made.
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const MAX_CLIENT_EVENTS = 60;
|
||||||
|
const MAX_BODY_BYTES = 32 * 1024;
|
||||||
|
const LOOKBACK_HOURS = 6;
|
||||||
|
const BOT_UA_RE = /bot|spider|crawl|slurp|facebookexternalhit|whatsapp|telegram|preview|monitor|pingdom|uptime|lighthouse|headlesschrome|axios|curl|wget|python-requests/i;
|
||||||
|
|
||||||
|
let schemaReady: Promise<void> | null = null;
|
||||||
|
function ensureSchema(pool: ReturnType<typeof getPool>): Promise<void> {
|
||||||
|
if (!pool) return Promise.resolve();
|
||||||
|
if (!schemaReady) {
|
||||||
|
schemaReady = pool
|
||||||
|
.query(
|
||||||
|
`create table if not exists submission_journeys (
|
||||||
|
id bigserial primary key,
|
||||||
|
email text not null,
|
||||||
|
anon_id text,
|
||||||
|
events jsonb not null default '[]'::jsonb,
|
||||||
|
client_events jsonb not null default '[]'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists submission_journeys_email_idx
|
||||||
|
on submission_journeys (email, created_at desc);`
|
||||||
|
)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch((err) => {
|
||||||
|
schemaReady = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return schemaReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitiseClientEvents(input: unknown): unknown[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
return input
|
||||||
|
.slice(0, MAX_CLIENT_EVENTS)
|
||||||
|
.filter((entry) => entry && typeof entry === 'object')
|
||||||
|
.map((entry) => {
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: typeof e.name === 'string' ? e.name.slice(0, 40) : '',
|
||||||
|
page_path: typeof e.page_path === 'string' ? e.page_path.slice(0, 512) : '',
|
||||||
|
label: typeof e.label === 'string' ? e.label.slice(0, 200) : '',
|
||||||
|
ts: typeof e.ts === 'number' && Number.isFinite(e.ts) ? e.ts : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
const ua = request.headers.get('user-agent') ?? '';
|
||||||
|
if (!ua || BOT_UA_RE.test(ua)) {
|
||||||
|
return json({ ok: true, promoted: false, reason: 'ua' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(request.headers.get('content-length') ?? '0');
|
||||||
|
if (contentLength > MAX_BODY_BYTES) throw error(413, 'payload too large');
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
throw error(400, 'invalid json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
|
||||||
|
if (!email || !EMAIL_RE.test(email) || email.length > 320) {
|
||||||
|
throw error(400, 'bad email');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientEvents = sanitiseClientEvents(body.client_events);
|
||||||
|
const anonId = locals.anonId ?? null;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Without a DB we can't persist, but we still return OK so the form's
|
||||||
|
// success path is unaffected. Local dev without Postgres should not break
|
||||||
|
// booking submissions.
|
||||||
|
if (!pool) {
|
||||||
|
return json({ ok: true, promoted: false, reason: 'no_db' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverEvents: unknown[] = [];
|
||||||
|
if (anonId) {
|
||||||
|
try {
|
||||||
|
// Lookback window guards against picking up a stale session that
|
||||||
|
// happens to share the anon_id cookie from days earlier.
|
||||||
|
const result = await pool.query(
|
||||||
|
`select event_name, page_path, params, created_at
|
||||||
|
from session_events
|
||||||
|
where anon_id = $1
|
||||||
|
and created_at >= now() - ($2 || ' hours')::interval
|
||||||
|
order by created_at asc
|
||||||
|
limit 500`,
|
||||||
|
[anonId, String(LOOKBACK_HOURS)]
|
||||||
|
);
|
||||||
|
serverEvents = result.rows.map((row: Record<string, unknown>) => ({
|
||||||
|
name: row.event_name,
|
||||||
|
page_path: row.page_path,
|
||||||
|
params: row.params,
|
||||||
|
ts: row.created_at
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('session_events read failed during promote', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSchema(pool);
|
||||||
|
await pool.query(
|
||||||
|
`insert into submission_journeys (email, anon_id, events, client_events)
|
||||||
|
values ($1, $2, $3::jsonb, $4::jsonb)`,
|
||||||
|
[email, anonId, JSON.stringify(serverEvents), JSON.stringify(clientEvents)]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('submission_journeys insert failed', err);
|
||||||
|
throw error(500, 'insert failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
promoted: true,
|
||||||
|
server_events: serverEvents.length,
|
||||||
|
client_events: clientEvents.length
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ContractPage from '$lib/components/ContractPage.svelte';
|
import ContractPage from '$lib/components/pages/ContractPage.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user