2 Commits
Author SHA1 Message Date
admin 018024510b v4.0.6 0 Simplicity focus 2026-07-04 10:00:16 +12:00
admin 8eb20813c8 v4.0.6 - Simplicity focus 2026-07-04 09:59:57 +12:00
45 changed files with 13758 additions and 5411 deletions
+160
View File
@@ -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 **134167 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 134167 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.
+134
View File
@@ -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.
Binary file not shown.
+31 -1
View File
@@ -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",
+72 -72
View File
@@ -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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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-06-15T21:24:42.302Z", "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.
+55 -3
View File
@@ -61,11 +61,37 @@ class WelcomePackEmailRequest(BaseModel):
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when # Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
# blank (resolved server-side so scheduled sends keep a sensible subject too). # blank (resolved server-side so scheduled sends keep a sensible subject too).
subject: str = "" subject: str = ""
serviceType: str # Structured offer details rendered in the details table. Now fully optional —
priceDetails: str # the owner can hide the table entirely (showDetails=False) and write a free
startDate: str # 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] = [] 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 # 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 # request, the welcome email is queued for reliable delivery at that time
# instead of being sent immediately. # instead of being sent immediately.
@@ -83,12 +109,17 @@ class ScheduledEmailRescheduleRequest(BaseModel):
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] = [] previewRecipients: list[EmailStr] = []
class BirthdayAutoSendRequest(BaseModel): class BirthdayAutoSendRequest(BaseModel):
email: EmailStr email: EmailStr
dogId: str | None = None
enabled: bool enabled: bool
@@ -110,6 +141,8 @@ class ClientProfileUpdate(BaseModel):
phone: str = "" phone: str = ""
address: str = "" address: str = ""
dogName: str dogName: str
dogBreed: str = ""
dogAge: str = ""
class ResetOnboardingRequest(BaseModel): class ResetOnboardingRequest(BaseModel):
@@ -120,6 +153,12 @@ class ResetOnboardingRequest(BaseModel):
email: EmailStr email: EmailStr
class DeleteClientRequest(BaseModel):
"""Owner-initiated permanent deletion of a client and their access."""
email: EmailStr
class NewClientRequest(BaseModel): class NewClientRequest(BaseModel):
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook """Owner-initiated client creation (e.g. leads from Instagram / Facebook
that never came through the public enquiry form). Registers the email so that never came through the public enquiry form). Registers the email so
@@ -131,14 +170,27 @@ class NewClientRequest(BaseModel):
address: str = "" address: str = ""
dogName: str = "" dogName: str = ""
dogBreed: str = "" dogBreed: str = ""
dogAge: str = ""
# Where the client came from, e.g. "Instagram", "Facebook", "Referral". # Where the client came from, e.g. "Instagram", "Facebook", "Referral".
source: str = "" 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 # 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 # as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
# local client from being created. # local client from being created.
createInMyob: bool = False 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
+949 -98
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -9,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.
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(/&nbsp;/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 -1
View File
@@ -847,7 +847,7 @@
</div> </div>
</section> </section>
<OnboardingFooter email={userEmail} on:logout={handleLogout} /> <OnboardingFooter on:logout={handleLogout} />
{/if} {/if}
</main> </main>
+49 -21
View File
@@ -27,10 +27,40 @@
export let content: HomePageContent; export let content: HomePageContent;
export let ab: AbContext | undefined = undefined; export let ab: AbContext | undefined = undefined;
export let pagePath = '/'; 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 reviewCta = content.intro.reviewCta;
const heroImage = '/images/smiling-happy-dog-maya.webp'; const heroImage = '/images/smiling-happy-dog-maya.webp';
const stars = Array.from({ length: 5 }); 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> </script>
<Header navigation={content.navigation} /> <Header navigation={content.navigation} />
@@ -40,14 +70,10 @@
<div class="chero-copy"> <div class="chero-copy">
<p class="chero-eyebrow">Goodwalk · Auckland dog walking</p> <p class="chero-eyebrow">Goodwalk · Auckland dog walking</p>
<h1 class="chero-title"> <h1 class="chero-title">
<span class="chero-title-lead">All we do is</span> <span class="chero-title-lead">Specialist walks for small &amp; medium dogs</span>
<span class="chero-title-main">small &amp; medium dogs</span> <span class="chero-title-main">Meet the Tiny Gang.</span>
</h1> </h1>
<p class="chero-lead"> <p class="chero-lead">{heroLead}</p>
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.
</p>
</div> </div>
<div class="chero-media"> <div class="chero-media">
@@ -107,10 +133,7 @@
<figure class="cfounder-quote"> <figure class="cfounder-quote">
<blockquote class="cfounder-blockquote"> <blockquote class="cfounder-blockquote">
<p class="cfounder-text"> <p class="cfounder-text">
Every dog gets a walk built around them, not a one-size pack: the right {founderQuote}
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.
</p> </p>
</blockquote> </blockquote>
<figcaption class="cfounder-attr"> <figcaption class="cfounder-attr">
@@ -133,19 +156,24 @@
</section> </section>
<!-- Offer first: what can I actually book, and roughly what does it cost? --> <!-- Offer first: what can I actually book, and roughly what does it cost? -->
<ServicesSection services={content.services} /> <ServicesSection services={content.services} heading={servicesHeading} intro={servicesIntro} />
<!-- Then the process: what happens after I enquire? Lowers the unknown. --> <!-- Then the process: what happens after I enquire? Lowers the unknown. -->
<HowItWorksSection content={content.howItWorks} /> <HowItWorksSection content={howItWorksContent} />
<!-- Only now the reasons to choose us. --> {#if isMeetGreetVariant}
<ValuesSection values={content.values} /> <TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
<InfoSection info={content.info} />
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} /> <svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
<FounderStorySection founderStory={content.founderStory} /> {:else}
<InfoSection info={content.info} /> <!-- Only now the reasons to choose us. -->
<BookingWizard booking={content.booking} {pagePath} {ab} /> <ValuesSection values={content.values} />
<InstagramSection instagram={content.instagram} /> <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} /> <Footer footer={content.footer} />
<style> <style>
@@ -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 &amp; 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>
+4 -55
View File
@@ -123,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;
@@ -703,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"
@@ -749,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}
@@ -1399,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>
@@ -1504,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;
@@ -1575,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;
} }
@@ -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 48 dogs',
length: '6075 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&rsquo;ll ever make is in your dog&rsquo;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 &amp; Greet. We will tell you what fits, and what does not.</p>
<a class="btn btn-yellow" href="/contact-us">Book a free Meet &amp; 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>
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,12 @@
<script lang="ts"> <script lang="ts">
/* /*
* Tiny Gang pack walks page — clarity redesign. * 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.
* *
* One design system, one message. Replaces the multi-card / multi-gradient * Drives all three service pages (Tiny Gang, Solo Walks, Puppy Visits) from
* ServiceLandingPage treatment (for this page) with a single green hero + * content. Page-specific copy lives in each content file's `clarity` block;
* light body that shares the homepage's visual language. The message is said * see docs/v5.md. View component only: the route owns Header/Footer/SEO.
* once, in order: what it is -> what's different -> how joining works -> fit
* -> proof -> price -> FAQ -> book.
*
* View component only: the route owns Header/Footer, SEO, and data.
* Preview at /variants/tiny-gang. To go live, render this instead of
* ServiceLandingPage for pack-walks in src/routes/[slug]/+page.svelte.
*/ */
import Icon from '$lib/components/ui/Icon.svelte'; import Icon from '$lib/components/ui/Icon.svelte';
import ServiceHero from '$lib/components/sections/ServiceHero.svelte'; import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
@@ -19,82 +15,19 @@
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte'; import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte'; import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
import FaqSection from '$lib/components/sections/FaqSection.svelte'; import FaqSection from '$lib/components/sections/FaqSection.svelte';
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
import { decoratePlans } from '$lib/utils/pricing'; import { decoratePlans } from '$lib/utils/pricing';
import type { ServicePageContent, SiteSharedContent } from '$lib/types'; import type { ServicePageContent, SiteSharedContent } from '$lib/types';
export let content: SiteSharedContent; export let content: SiteSharedContent;
export let pageContent: ServicePageContent; export let pageContent: ServicePageContent;
export let currentPath = '/pack-walks'; export let currentPath = '/';
// What makes Tiny Gang different. Said once here, not spread across a
// 3-point highlight and a 6-card benefit grid that repeated each other.
const 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 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.'
}
];
// How joining works. The process, made explicit instead of buried in a
// paragraph and an FAQ.
const steps = [
{
title: 'Free Meet & Greet',
body: 'We come to your home, meet your dog, and talk 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.'
}
];
// Distilled pricing message. The title already says the routine and the
// price says the cost, so each card keeps only the one or two lines that
// actually differ. The universal "free pickup" promise is stated once under
// the grid instead of repeated on every card.
const planLines: Record<string, string[]> = {
'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'
]
};
$: clarity = pageContent.clarity;
$: traits = clarity?.traits ?? [];
$: steps = clarity?.steps ?? [];
$: planLines = clarity?.planLines ?? {};
$: priceNotes = clarity?.priceNotes ?? [];
$: pricingPlans = decoratePlans( $: pricingPlans = decoratePlans(
pageContent.pricing.plans.map((plan) => ({ pageContent.pricing.plans.map((plan) => ({
...plan, ...plan,
@@ -105,8 +38,7 @@
</script> </script>
<main class="tg"> <main class="tg">
<!-- Hero: the shared service-page hero (green copy left, photo bleeds <!-- Hero: shared service-page hero (green copy left, photo bleeds right). -->
right), identical to /dog-walking and /puppy-visits. -->
<ServiceHero <ServiceHero
eyebrow={pageContent.hero.eyebrow} eyebrow={pageContent.hero.eyebrow}
title={pageContent.hero.title} title={pageContent.hero.title}
@@ -117,53 +49,86 @@
cta={pageContent.hero.cta} cta={pageContent.hero.cta}
/> />
<!-- What makes it different (said once) --> <!-- What it is: a plain, self-contained definition that opens the page. -->
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading"> {#if clarity?.definition}
<div class="tg-inner"> <section class="tg-section tg-section-def" use:reveal={{ distance: 0 }} aria-labelledby="tg-def-heading">
<div class="tg-head"> <div class="tg-inner tg-inner-narrow">
<p class="tg-eyebrow">What makes Tiny Gang different</p> <div class="tg-head">
<h2 id="tg-diff-heading" class="tg-h2">Not a big anonymous pack. A small, matched crew.</h2> <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> </div>
<ul class="tg-traits"> </section>
{#each traits as trait} {/if}
<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>
<!-- How joining works (the process, explicit) --> <!-- What makes it different (said once) -->
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading"> {#if traits.length}
<div class="tg-inner"> <section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
<div class="tg-head"> <div class="tg-inner">
<p class="tg-eyebrow">How joining works</p> <div class="tg-head">
<h2 id="tg-steps-heading" class="tg-h2">Three simple steps, no pressure.</h2> <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> </div>
<ol class="tg-steps"> </section>
{#each steps as step, i} {/if}
<li class="tg-step">
<span class="tg-step-num" aria-hidden="true">{i + 1}</span> <!-- How it works (the process, explicit) -->
<h3>{step.title}</h3> {#if steps.length}
<p>{step.body}</p> <section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
</li> <div class="tg-inner">
{/each} <div class="tg-head">
</ol> <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> </div>
</section> {/if}
<!-- Is it right for your dog? --> <!-- Is it right for your dog? -->
{#if pageContent.decision} {#if pageContent.decision}
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading"> <section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
<div class="tg-inner"> <div class="tg-inner">
<div class="tg-head"> <div class="tg-head">
<p class="tg-eyebrow">Honest fit check</p> <p class="tg-eyebrow">{clarity?.fitEyebrow ?? 'Honest fit check'}</p>
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is Tiny Gang right for your dog?'}</h2> <h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is this right for your dog?'}</h2>
</div> </div>
<div class="tg-fit"> <div class="tg-fit">
<div class="tg-fit-col"> <div class="tg-fit-col">
@@ -178,7 +143,7 @@
</ul> </ul>
</div> </div>
<div class="tg-fit-col"> <div class="tg-fit-col">
<h3>{pageContent.decision.notFitTitle ?? 'Better on a solo walk if your dog:'}</h3> <h3>{pageContent.decision.notFitTitle ?? 'Better with another option if your dog:'}</h3>
<ul> <ul>
{#each pageContent.decision.notFitItems as item} {#each pageContent.decision.notFitItems as item}
<li> <li>
@@ -196,9 +161,9 @@
</section> </section>
{/if} {/if}
<!-- Proof: owners love it --> <!-- Proof -->
<TestimonialsSection <TestimonialsSection
heading="Owners love the Tiny Gang" heading={clarity?.testimonialsHeading ?? pageContent.testimonialsHeading}
testimonials={content.testimonials} testimonials={content.testimonials}
seedKey={currentPath} seedKey={currentPath}
/> />
@@ -207,28 +172,32 @@
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading"> <section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
<div class="tg-inner tg-inner-wide"> <div class="tg-inner tg-inner-wide">
<div class="tg-head"> <div class="tg-head">
<p class="tg-eyebrow">Simple weekly pricing</p> <p class="tg-eyebrow">{clarity?.pricingEyebrow ?? 'Simple pricing'}</p>
<h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2> <h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2>
<p class="tg-price-tagline">The best investment you'll ever make is in your dog's happiness.</p> {#if clarity?.pricingTagline}
<p class="tg-price-tagline">{clarity.pricingTagline}</p>
{/if}
{#if pageContent.pricing.intro} {#if pageContent.pricing.intro}
<p class="tg-head-sub">{pageContent.pricing.intro}</p> <p class="tg-head-sub">{pageContent.pricing.intro}</p>
{/if} {/if}
</div> </div>
<div class="tg-plans"> <div class="tg-plans" class:tg-plans-three={pricingPlans.length === 3}>
{#each pricingPlans as plan} {#each pricingPlans as plan}
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} /> <PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
{/each} {/each}
</div> </div>
<div class="tg-price-notes"> {#if pageContent.pricing.scarcityNote || priceNotes.length}
{#if pageContent.pricing.scarcityNote} <div class="tg-price-notes">
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p> {#if pageContent.pricing.scarcityNote}
{/if} <p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
<p class="tg-note"><Icon name="fas fa-stopwatch" />60+ minute adventures.</p> {/if}
<p class="tg-note"><Icon name="fas fa-car" />Free pickup &amp; drop-off, every walk.</p> {#each priceNotes as note}
<p class="tg-note"><Icon name="fas fa-shield-heart" />Starts with a free Meet &amp; Greet.</p> <p class="tg-note"><Icon name={note.icon} />{note.label}</p>
</div> {/each}
</div>
{/if}
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet &amp; Greet</a> <a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet &amp; Greet</a>
</div> </div>
@@ -236,17 +205,32 @@
<!-- FAQ --> <!-- FAQ -->
{#if faqItems.length} {#if faqItems.length}
<section class="tg-section" use:reveal={{ distance: 0 }}> <section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-faq-heading">
<div class="tg-inner tg-inner-narrow"> <div class="tg-inner tg-inner-narrow">
<FaqSection <div class="tg-head">
title={pageContent.faq?.title ?? 'Tiny Gang FAQs'} <p class="tg-eyebrow">{clarity?.faqEyebrow ?? 'Common questions'}</p>
intro={pageContent.faq?.intro} <h2 id="tg-faq-heading" class="tg-h2">{pageContent.faq?.title ?? 'FAQs'}</h2>
faqs={faqItems} {#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> </div>
</section> </section>
{/if} {/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} /> <BookingWizard booking={pageContent.booking} pagePath={currentPath} />
</main> </main>
@@ -280,7 +264,8 @@
} }
/* Alternating surface for rhythm, not a new "system": just a tint. */ /* Alternating surface for rhythm, not a new "system": just a tint. */
.tg-section-steps { .tg-section-steps,
.tg-section-area {
background: var(--surface-panel-cream); background: var(--surface-panel-cream);
border-block: 1px solid var(--border-soft); border-block: 1px solid var(--border-soft);
} }
@@ -332,6 +317,21 @@
line-height: 1.6; 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 { .tg-price-tagline {
margin: 14px auto 0; margin: 14px auto 0;
max-width: 32rem; max-width: 32rem;
@@ -503,6 +503,50 @@
text-align: center; 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 ───────────────────────────────────────────────────────── */ /* ── Pricing ───────────────────────────────────────────────────────── */
.tg-plans { .tg-plans {
display: grid; display: grid;
@@ -510,6 +554,13 @@
gap: 24px; 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 /* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
PricingPlanCard and other pages keep their own sizing. */ PricingPlanCard and other pages keep their own sizing. */
.tg-plans :global(.plan-card) { .tg-plans :global(.plan-card) {
@@ -523,7 +574,7 @@
padding-top: 62px; padding-top: 62px;
} }
/* Full-width CTA: never squished, and consistent across all four cards. /* Full-width CTA: never squished, and consistent across all cards.
display:flex (block-level) overrides the component's inline-flex + display:flex (block-level) overrides the component's inline-flex +
align-self:center, so the button always spans the full card width. */ align-self:center, so the button always spans the full card width. */
.tg-plans :global(.plan-card__cta) { .tg-plans :global(.plan-card__cta) {
@@ -531,7 +582,14 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
align-self: stretch; align-self: stretch;
padding-inline: 15px; 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 { .tg-price-notes {
@@ -573,9 +631,58 @@
display: none; 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 ────────────────────────────────────────────────────── */ /* ── Responsive ────────────────────────────────────────────────────── */
@media (max-width: 980px) { @media (max-width: 980px) {
.tg-plans { .tg-plans,
.tg-plans-three {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@@ -594,7 +701,12 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.tg-plans { .tg-band-img {
aspect-ratio: 3 / 2;
}
.tg-plans,
.tg-plans-three {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
File diff suppressed because it is too large Load Diff
@@ -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();
}); });
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -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}
@@ -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,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>
+61 -1
View File
@@ -4,6 +4,8 @@
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 data-track-location="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
@@ -125,3 +127,61 @@
</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>
@@ -2,8 +2,6 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/ui/Icon.svelte'; import Icon from '$lib/components/ui/Icon.svelte';
export let email = '';
const dispatch = createEventDispatcher<{ logout: void }>(); const dispatch = createEventDispatcher<{ logout: void }>();
let loggingOut = false; let loggingOut = false;
@@ -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 {
+60
View File
@@ -183,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.',
+83
View File
@@ -206,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: '',
+65
View File
@@ -168,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: '',
+1
View File
@@ -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 },
+28
View File
@@ -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',
+63
View File
@@ -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;
} }
+26 -9
View File
@@ -7,13 +7,24 @@
// at /variants/homepage-original). Both are pure view components; all data // at /variants/homepage-original). Both are pure view components; all data
// loading, SEO head, and structured data stay here. // loading, SEO head, and structured data stay here.
import HomeClarity from '$lib/components/pages/HomeClarity.svelte'; import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
import { buildAreaServed } from '$lib/seo'; import { buildAggregateOffer, buildAreaServed } from '$lib/seo';
import { packWalksContent } from '$lib/content/pack-walks';
import { dogWalkingContent } from '$lib/content/dog-walking';
import { puppyVisitsContent } from '$lib/content/puppy-visits';
import type { PageData } from './$types'; import type { PageData } from './$types';
export let data: PageData; export let data: PageData;
const siteUrl = 'https://www.goodwalk.co.nz'; const siteUrl = 'https://www.goodwalk.co.nz';
// Pricing plans keyed by service URL, so the OfferCatalog can advertise the
// same accurate "fromto" NZD range the individual service pages publish.
const servicePlansByHref: Record<string, { price: string }[]> = {
'/pack-walks': packWalksContent.pricing.plans,
'/dog-walking': dogWalkingContent.pricing.plans,
'/puppy-visits': puppyVisitsContent.pricing.plans
};
function absoluteUrl(value: string) { function absoluteUrl(value: string) {
if (value.startsWith('http://') || value.startsWith('https://')) { if (value.startsWith('http://') || value.startsWith('https://')) {
return value; return value;
@@ -63,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',
+17 -34
View File
@@ -6,17 +6,22 @@
import BookingPage from '$lib/components/pages/BookingPage.svelte'; import BookingPage from '$lib/components/pages/BookingPage.svelte';
import LegalPage from '$lib/components/pages/LegalPage.svelte'; import LegalPage from '$lib/components/pages/LegalPage.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import PricingPage from '$lib/components/pages/PricingPage.svelte'; import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
import TestimonialsPage from '$lib/components/pages/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/pages/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'}
+4 -1
View File
@@ -20,7 +20,10 @@ function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
get: () => undefined, get: () => undefined,
set: () => {}, set: () => {},
delete: () => {} delete: () => {}
} },
// The loader reads `locals.abHero`/`locals.anonId` to attach A/B context.
// Without an A/B assignment it omits `ab`, matching the expectations below.
locals: {}
} as unknown as Parameters<typeof load>[0]; } as unknown as Parameters<typeof load>[0];
} }
+50 -18
View File
@@ -10,34 +10,66 @@ describe('home page route', () => {
data: createHomepageRouteData() data: createHomepageRouteData()
}); });
const hero = document.getElementById('hero'); // HomeClarity is the live homepage. Its hero is purpose-built copy rather
const intro = document.getElementById('intro'); // than the generic content.hero fields, leading with the specialism.
const values = document.getElementById('values'); expect(document.querySelector('.chero')).not.toBeNull();
expect(screen.getByText('Meet the Tiny Gang.')).toBeInTheDocument();
expect(
screen.getByText('Specialist walks for small & medium dogs')
).toBeInTheDocument();
// Offer-first section order: services -> how it works -> values ->
// testimonials -> founder story -> info -> booking.
const services = document.getElementById('services'); const services = document.getElementById('services');
const howItWorks = document.getElementById('how-it-works'); const howItWorks = document.getElementById('how-it-works');
const values = document.getElementById('values');
const testimonials = document.getElementById('testimonials'); const testimonials = document.getElementById('testimonials');
const founderStory = document.getElementById('promise'); const founderStory = document.getElementById('promise');
const info = document.getElementById('info'); const info = document.getElementById('info');
const booking = document.getElementById('newlead'); const booking = document.getElementById('newlead');
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0); for (const section of [
const introHeadlineText = homepageContent.intro.text.replace(/\.$/, ''); services,
const introHeading = document.querySelector('#intro .intro-headline'); howItWorks,
expect(introHeading?.textContent?.replace(/\s+/g, ' ').trim()).toBe(introHeadlineText); values,
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument(); testimonials,
founderStory,
info,
booking
]) {
expect(section).not.toBeNull();
}
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument(); expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument(); expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
expect(hero?.compareDocumentPosition(intro as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(intro?.compareDocumentPosition(values as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(
expect(values?.compareDocumentPosition(services as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); Node.DOCUMENT_POSITION_FOLLOWING
expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); );
expect(howItWorks?.compareDocumentPosition(testimonials as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(howItWorks?.compareDocumentPosition(values as Node)).toBe(
expect(testimonials?.compareDocumentPosition(founderStory as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); Node.DOCUMENT_POSITION_FOLLOWING
expect(founderStory?.compareDocumentPosition(info as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); );
expect(info?.compareDocumentPosition(booking as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(values?.compareDocumentPosition(testimonials as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(testimonials?.compareDocumentPosition(founderStory as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(founderStory?.compareDocumentPosition(info as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(info?.compareDocumentPosition(booking as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
// The homepage booking wizard never exposes the general-enquiry toggle.
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
// SEO head: title, structured data, and the preloaded hero image.
expect(document.title).toBe(homepageContent.seo.title); expect(document.title).toBe(homepageContent.seo.title);
expect(document.head.innerHTML).toContain('FAQPage'); expect(document.head.innerHTML).toContain('PetCareService');
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/goodwalk-auckland-happy-dog-hero.webp'); expect(document.head.innerHTML).toContain(
'https://www.goodwalk.co.nz/images/smiling-happy-dog-maya.webp'
);
}); });
}); });
@@ -0,0 +1,22 @@
<script lang="ts">
/*
* Preview of the homepage design with the simplified Meet & Greet form.
* Reuses HomeClarity (the live homepage) and injects BookingWizardSimple in
* place of the live BookingWizard via the BookingComponent prop. noindex so
* it does not compete with the real homepage in search.
*/
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
import BookingWizardSimple from '$lib/components/sections/BookingWizardSimple.svelte';
import { homepageContent } from '$lib/content/homepage';
</script>
<svelte:head>
<title>Meet & Greet form (preview) | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<HomeClarity
content={homepageContent}
pagePath="/variants/meet-greet"
BookingComponent={BookingWizardSimple}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
/*
* Preview of the redesigned pricing page in the v5 clarity system. noindex so
* it does not compete with the live /our-pricing page in search.
*/
import Header from '$lib/components/ui/Header.svelte';
import Footer from '$lib/components/ui/Footer.svelte';
import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
import { homepageContent } from '$lib/content/homepage';
</script>
<svelte:head>
<title>Pricing (redesign preview) | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<Header navigation={homepageContent.navigation} />
<PricingClarityPage />
<Footer footer={homepageContent.footer} variant="minimal" />
@@ -0,0 +1,23 @@
<script lang="ts">
/*
* Preview of the redesigned Puppy Visits page on the shared v5 clarity
* component. noindex so it does not compete with the live /puppy-visits page.
*
* To go live: render ServiceClarityPage in the `data.slug === 'puppy-visits'`
* branch of src/routes/[slug]/+page.svelte.
*/
import Header from '$lib/components/ui/Header.svelte';
import Footer from '$lib/components/ui/Footer.svelte';
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
import { homepageContent } from '$lib/content/homepage';
import { puppyVisitsContent } from '$lib/content/puppy-visits';
</script>
<svelte:head>
<title>Puppy visits (redesign preview) | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<Header navigation={homepageContent.navigation} />
<ServiceClarityPage content={homepageContent} pageContent={puppyVisitsContent} currentPath="/variants/puppy-visits" />
<Footer footer={homepageContent.footer} variant="minimal" />
@@ -0,0 +1,21 @@
<script lang="ts">
/*
* Preview of the services-focus homepage variant.
* Single source of truth is HomeServicesFocus.svelte. noindex so it does not
* compete with the real homepage in search.
*
* Purpose: the site gets traffic but few leads. This variant keeps the design
* language but reorients the whole page around clicking through to a service
* page, dropping the homepage booking form in favour of lower-friction service
* choices in the hero, the cards, and a closing band.
*/
import HomeServicesFocus from '$lib/components/pages/HomeServicesFocus.svelte';
import { homepageContent } from '$lib/content/homepage';
</script>
<svelte:head>
<title>Services-focus homepage (preview) | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<HomeServicesFocus content={homepageContent} pagePath="/variants/services-first" />
@@ -0,0 +1,23 @@
<script lang="ts">
/*
* Preview of the redesigned Solo Walks page on the shared v5 clarity
* component. noindex so it does not compete with the live /dog-walking page.
*
* To go live: render ServiceClarityPage in the `data.slug === 'dog-walking'`
* branch of src/routes/[slug]/+page.svelte.
*/
import Header from '$lib/components/ui/Header.svelte';
import Footer from '$lib/components/ui/Footer.svelte';
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
import { homepageContent } from '$lib/content/homepage';
import { dogWalkingContent } from '$lib/content/dog-walking';
</script>
<svelte:head>
<title>Solo dog walks (redesign preview) | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<Header navigation={homepageContent.navigation} />
<ServiceClarityPage content={homepageContent} pageContent={dogWalkingContent} currentPath="/variants/solo-walks" />
<Footer footer={homepageContent.footer} variant="minimal" />
+8 -7
View File
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
/* /*
* Preview of the redesigned Tiny Gang pack walks page. * Preview of the redesigned Tiny Gang pack walks page.
* Single source of truth is TinyGangPage.svelte. noindex so it does not * Single source of truth is ServiceClarityPage.svelte (shared across all
* compete with the live /pack-walks page in search. * three service variants). noindex so it does not compete with the live
* /pack-walks page in search.
* *
* To go live: in src/routes/[slug]/+page.svelte, render TinyGangPage instead * To go live: in src/routes/[slug]/+page.svelte, render ServiceClarityPage
* of ServiceLandingPage in the `data.slug === 'pack-walks'` branch. * instead of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
*/ */
import Header from '$lib/components/ui/Header.svelte'; import Header from '$lib/components/ui/Header.svelte';
import Footer from '$lib/components/ui/Footer.svelte'; import Footer from '$lib/components/ui/Footer.svelte';
import TinyGangPage from '$lib/components/pages/TinyGangPage.svelte'; import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
import { homepageContent } from '$lib/content/homepage'; import { homepageContent } from '$lib/content/homepage';
import { packWalksContent } from '$lib/content/pack-walks'; import { packWalksContent } from '$lib/content/pack-walks';
</script> </script>
@@ -20,5 +21,5 @@
</svelte:head> </svelte:head>
<Header navigation={homepageContent.navigation} /> <Header navigation={homepageContent.navigation} />
<TinyGangPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" /> <ServiceClarityPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
<Footer footer={homepageContent.footer} /> <Footer footer={homepageContent.footer} variant="minimal" />