Compare commits
2
Commits
bfd96c677f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018024510b | ||
|
|
8eb20813c8 |
+160
@@ -0,0 +1,160 @@
|
||||
# GEO Analysis — Goodwalk (goodwalk.co.nz)
|
||||
|
||||
_Generative Engine Optimization audit · February 2026 criteria · analyzed 2026-06-23_
|
||||
|
||||
Scope: SvelteKit codebase at `C:\Users\mattc\gw-svelte\gw-svelte` (SSR via `@sveltejs/adapter-node`). This is a single-location Auckland dog-walking service — a local SAB (service-area business), so GEO leverage skews toward local AI answers ("dog walker in Mt Eden", "Auckland pack walks", "puppy visit cost Auckland").
|
||||
|
||||
---
|
||||
|
||||
## 1. GEO Readiness Score: 73 / 100
|
||||
|
||||
A strong technical foundation already in place. The on-site work is close to best-practice; the gap is **off-site brand presence** and **passage-level citability** of the marketing copy.
|
||||
|
||||
| Criterion | Weight | Score | Notes |
|
||||
|-----------|--------|-------|-------|
|
||||
| Citability | 25% | 16/25 | Excellent `llms.txt` facts; on-page copy is punchy brand fragments, not extractable answer blocks |
|
||||
| Structural Readability | 20% | 15/20 | Clean H1→H2, FAQ `<details>`; few tables/comparison blocks |
|
||||
| Multi-Modal | 15% | 9/15 | Rich images + alt text everywhere; no video, infographics, or tools |
|
||||
| Authority & Brand | 20% | 14/20 | Strong on-site E-E-A-T (Person/founder schema, reviews); thin off-site (no Wikipedia/Reddit/YouTube) |
|
||||
| Technical Accessibility | 20% | 19/20 | SSR, AI crawlers explicitly allowed, `llms.txt` present — near perfect |
|
||||
|
||||
---
|
||||
|
||||
## 2. Platform Breakdown
|
||||
|
||||
| Platform | Est. Score | Why |
|
||||
|----------|-----------|-----|
|
||||
| **Google AI Overviews** | 80 | Best-aligned — SSR + rich LocalBusiness/Person/FAQ schema + local entity signals. Citations come from top-ranking pages, so this rides your existing SEO. |
|
||||
| **Bing Copilot** | 68 | Schema and SSR help; needs Bing index coverage + IndexNow. |
|
||||
| **ChatGPT (web search)** | 61 | Leans on Wikipedia (47.9%) and Reddit (11.3%). No entity presence on either → low. |
|
||||
| **Perplexity** | 57 | Reddit-driven (46.7%). No community validation present → weakest channel. |
|
||||
|
||||
**Implication:** you are well-positioned for Google AIO and should focus net-new effort on the off-site signals that feed ChatGPT/Perplexity.
|
||||
|
||||
---
|
||||
|
||||
## 3. AI Crawler Access Status ✅
|
||||
|
||||
`src/routes/robots.txt/+server.ts` explicitly allows the key search crawlers — this is correctly done:
|
||||
|
||||
| Crawler | Status |
|
||||
|---------|--------|
|
||||
| GPTBot (OpenAI) | ✅ Allowed |
|
||||
| OAI-SearchBot (OpenAI) | ✅ Allowed |
|
||||
| ClaudeBot (Anthropic) | ✅ Allowed |
|
||||
| PerplexityBot | ✅ Allowed |
|
||||
| Bingbot / others (`*`) | ✅ Allowed (with sensible `/api/`, `/variants/`, `/contract` disallows) |
|
||||
|
||||
No action required. One optional addition: `ChatGPT-User` (live browsing fetches) is not named but is covered by `User-agent: *`.
|
||||
|
||||
---
|
||||
|
||||
## 4. llms.txt Status ✅
|
||||
|
||||
Present at `static/llms.txt` and well-structured — better than most sites. It already includes founder, contact, hours, pricing summary, service URLs, suburb list, and location-page index.
|
||||
|
||||
Minor improvements:
|
||||
- **Update `Last updated`** (currently `2026-05-18`) on each meaningful content change.
|
||||
- Consider adding a one-line **"What is Goodwalk"** definitional sentence at the very top under `> ` (the quotable blockquote) — AI models lift this verbatim.
|
||||
- Add the **Meet & Greet is free** fact and **free pickup/drop-off** as explicit bullet facts (they're a differentiator AI answers love to surface).
|
||||
|
||||
---
|
||||
|
||||
## 5. Brand Mention Analysis — biggest opportunity
|
||||
|
||||
Per Ahrefs (Dec 2025), **brand mentions correlate ~3× more strongly with AI visibility than backlinks**. This is your largest lever and it is entirely off-site.
|
||||
|
||||
| Platform | Presence | Priority |
|
||||
|----------|----------|----------|
|
||||
| Instagram | ✅ `@goodwalk.nz` (linked, in `sameAs`) | Maintained |
|
||||
| Facebook | ✅ Linked in footer | Maintained |
|
||||
| Google Business Profile | ✅ `g.page` link + 30× 5-star reviews in schema | Strong |
|
||||
| **Reddit** | ❌ None | **High** — drives Perplexity (46.7%) + ChatGPT (11.3%) |
|
||||
| **YouTube** | ❌ None | **High** — strongest single citation correlation (~0.737) |
|
||||
| **Wikipedia/Wikidata** | ❌ None | Medium (hard for a small local business; skip unless notable) |
|
||||
| LinkedIn (Alessandra) | ❌ Not in Person `sameAs` | Medium — easy entity win |
|
||||
|
||||
**Realistic actions for a local business:**
|
||||
1. Get genuine mentions in Auckland subreddits (r/auckland, r/newzealand) — answer dog-care questions helpfully; don't spam.
|
||||
2. Post a few short YouTube clips (pack walk footage, "what is a Meet & Greet") — even low view counts create the entity association.
|
||||
3. List on NZ pet directories and local-business aggregators (consistent NAP).
|
||||
4. Add Alessandra's LinkedIn (if any) to the `Person` schema `sameAs`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Passage-Level Citability
|
||||
|
||||
Optimal AI-citation block length is **134–167 words, self-contained**. Your on-page copy is deliberately fragmented brand voice — great for humans, weak for extraction:
|
||||
|
||||
> "Built for dogs who do better one-on-one. Their pace. Their walk. Same walker every time." _(dog-walking.ts)_
|
||||
|
||||
These single-line fragments rarely get quoted because they don't answer a question standalone. **Recommendation: keep the brand hero copy, but add one extractable answer block per service page**, e.g.:
|
||||
|
||||
> **What is a solo dog walk?** A solo dog walk is a one-on-one outing where a single dog is walked by the same walker every time, at the dog's own pace. Goodwalk offers solo walks across Auckland Central for dogs who do better without a group — reactive dogs, seniors, post-surgery recovery, and anxious rescues. Walks run 30 minutes (seniors/lower energy), 45 minutes (most dogs), or 60 minutes (longer outings), with free door-to-door pickup and drop-off and a photo update after every walk. Solo walks start from $45 per walk. _(≈70 words — expand toward ~140 with a sentence on when to choose solo vs pack.)_
|
||||
|
||||
Your **`llms.txt` already does this well** — the "Key facts" and "Pricing summary" blocks are exactly the quotable format. Mirror that density into visible page HTML.
|
||||
|
||||
---
|
||||
|
||||
## 7. Server-Side Rendering Check ✅
|
||||
|
||||
- SSR is **on** (adapter-node, no `export const ssr = false` anywhere). AI crawlers, which do not execute JS, will see full content.
|
||||
- All structured data is emitted server-side in `<svelte:head>` (SeoHead.svelte, FaqSection.svelte) — good.
|
||||
- ⚠️ One caveat: the `<noscript>` overlay says "Please enable JavaScript to use this site." The rendered HTML is fine for crawlers, but make sure the **primary content is in the SSR payload and not gated behind hydration** — the booking/contact form needing JS is fine; the service descriptions and FAQs must be in source HTML (they are, via the content modules — confirmed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Top 5 Highest-Impact Changes
|
||||
|
||||
1. **Build off-site brand mentions (Reddit + YouTube + NZ directories).** Highest correlation with AI citation; the single biggest unlock for ChatGPT/Perplexity. _(High effort, high impact)_
|
||||
2. **Add a 134–167 word "What is [service]?" answer block** to each service page (pack-walks, dog-walking, puppy-visits) and an "How much does dog walking cost in Auckland?" block to `/our-pricing`. _(Medium effort, high impact)_
|
||||
3. **Add price + priceCurrency to the `Offer` nodes** in the homepage `hasOfferCatalog` (currently name/url only). Lets AI quote exact prices with confidence. _(Low effort, medium impact)_
|
||||
4. **Create an Auckland dog-care FAQ / short guide page** to capture informational AI queries ("how often should I walk my dog", "pack walk vs solo walk"). You have zero informational content today — purely commercial. _(Medium effort, high impact)_
|
||||
5. **Add `sameAs` to the Person (Alessandra) schema** and a visible author/founder byline with credentials (pet first-aid cert, insured) near service content. Strengthens E-E-A-T entity. _(Low effort, medium impact)_
|
||||
|
||||
---
|
||||
|
||||
## 9. Schema Recommendations
|
||||
|
||||
Already excellent: `WebSite`, `LocalBusiness`+`PetCareService` (with `aggregateRating`, `review`, `openingHoursSpecification`, `areaServed`, `hasOfferCatalog`), `Person` (founder, `knowsAbout`), `FAQPage`, `BreadcrumbList`, `Service` (location pages). Gaps to close:
|
||||
|
||||
```jsonc
|
||||
// In hasOfferCatalog → each Offer, add price:
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": { "@type": "Service", "name": "Tiny Gang Pack Walks", "url": "https://www.goodwalk.co.nz/pack-walks" },
|
||||
"price": "49.50",
|
||||
"priceCurrency": "NZD"
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// Person (Alessandra) — add credentials + external entity links:
|
||||
"hasCredential": [
|
||||
{ "@type": "EducationalOccupationalCredential", "credentialCategory": "certificate", "name": "Pet First Aid Certificate" }
|
||||
],
|
||||
"sameAs": ["https://www.linkedin.com/in/..."] // if available
|
||||
```
|
||||
|
||||
- Add **`priceRange`** (e.g. `"$$"`) and a **`geo`** node (`latitude`/`longitude`) to the LocalBusiness — both are standard LocalBusiness AI signals currently absent.
|
||||
- Optionally model each service page as its own `Service` with an `OfferCatalog` so service pages stand alone as cited entities (location pages already do this well).
|
||||
|
||||
---
|
||||
|
||||
## 10. Content Reformatting Suggestions
|
||||
|
||||
| Page | Current | Suggested GEO addition |
|
||||
|------|---------|------------------------|
|
||||
| `/dog-walking` (solo) | Brand fragments, no definition | Add "What is a solo dog walk?" 140-word block (see §6) |
|
||||
| `/pack-walks` | — | Add "What is a Tiny Gang pack walk?" + a **pack vs solo comparison table** (dogs per group, best for, price) |
|
||||
| `/puppy-visits` | — | Add "What happens in a puppy visit?" + duration/price block |
|
||||
| `/our-pricing` | Pricing copy | Add "How much does a dog walker cost in Auckland?" answer block with a clean price table (service / duration / price NZD) |
|
||||
| New: `/guide` or FAQ hub | none | Informational content to win non-commercial AI queries and earn citations |
|
||||
|
||||
**Tables are under-used.** AI Overviews and Perplexity disproportionately cite comparative tables. A single "Pack vs Solo vs Puppy Visit" comparison table would be high-value and on-brand.
|
||||
|
||||
---
|
||||
|
||||
### Bottom line
|
||||
|
||||
Technically this is a **top-decile small-business GEO setup** — SSR, AI crawler allow-list, `llms.txt`, and comprehensive schema are already done and done well. The remaining wins are **content shape** (add extractable answer blocks + tables, prices in schema) and, most of all, **off-site brand presence** (Reddit, YouTube, directories), which the data says matters more than anything on the page itself.
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# v5: Simplify the message on service pages
|
||||
|
||||
## Goal
|
||||
|
||||
Every service page should say one clear message, once, in a fixed order, using the
|
||||
homepage's single visual language. No restated sections, no card-grid sprawl. Lead
|
||||
with trust, show the emotional outcome, let the page breathe (PRODUCT.md principles).
|
||||
|
||||
The Tiny Gang page is the finished reference. The rest of this task is applying the
|
||||
same treatment to the other two service pages.
|
||||
|
||||
## The problem we are fixing
|
||||
|
||||
The current `ServiceLandingPage.svelte` stacks ~10 sections that repeat each other:
|
||||
hero, highlight collage, a 6-card benefit pager, an intro badge block, a decision
|
||||
card, a pricing block with an extras grid, an areas card, a FAQ card, and a related
|
||||
services grid. The same promise (matched groups, safe, free pickup) is said three or
|
||||
four times in three or four different card styles. It reads as pages of text and
|
||||
boxes, not one calm argument.
|
||||
|
||||
## The v5 rule
|
||||
|
||||
**Say it once, in order. One design system.**
|
||||
|
||||
Canonical section spine for every service page (this is the order):
|
||||
|
||||
1. **Hero** - what it is (shared `ServiceHero`: green copy left, photo right).
|
||||
2. **What's different** - the 3-4 things that actually set this service apart.
|
||||
3. **How it works** - the steps to get started, made explicit.
|
||||
4. **Honest fit check** - who it suits, who it does not (two columns, one card).
|
||||
5. **Proof** - `TestimonialsSection`.
|
||||
6. **One deliberate image** - a real photo that breaks the text and carries meaning.
|
||||
7. **Price** - even plan cards, plus a short row of promise notes.
|
||||
8. **FAQ** - a calm ruled list, not stacked cards.
|
||||
9. **Service area** - `ServiceAreaCoverage`: an Auckland hub with all suburbs as
|
||||
tappable links. Robust on every screen (no pin map). Per-page stats, so puppy-visits
|
||||
reads "in-home", not "pickup & drop-off".
|
||||
10. **Book** - `BookingWizard`.
|
||||
|
||||
If a piece of content does not fit one of those ten slots, it is probably a repeat.
|
||||
Cut it.
|
||||
|
||||
## Design rules (non-negotiable, taken from the Tiny Gang build)
|
||||
|
||||
- **Lists, not cards.** "What's different" is an icon + text list. "How it works" is
|
||||
numbered items. Reserve real cards for pricing only. Never nest cards.
|
||||
- **One heading grammar.** Every section opens with the same centered eyebrow + H2
|
||||
(`.tg-eyebrow` + `.tg-h2`). No per-section badges, marks, or collages.
|
||||
- **One token set.** Use the shared CSS variables (colours, radius, shadow, spacing).
|
||||
No new gradients or per-section colour systems. Goodwalk green `#213021`, yellow
|
||||
`#FFD100` for buttons only.
|
||||
- **Pricing cards stay even.** Four equal cards in a row on desktop, 2-up at 980px,
|
||||
1-up on mobile. Do not put images inside or beside the cards.
|
||||
- **One image, deliberate.** A single full-width photo band between sections, with a
|
||||
short keyword-bearing caption. It must mean something (the pack, the pickup, the
|
||||
outcome), not decorate. See SEO rules below.
|
||||
- **FAQ is a ruled list.** Hairline dividers between questions, no card chrome.
|
||||
- **Copy: half it, then half it again.** Active voice. No em dashes. No heading that
|
||||
restates the one above it.
|
||||
|
||||
## Image and SEO rules
|
||||
|
||||
- Use real Goodwalk photos from `static/images/` (keyword-rich filenames already).
|
||||
- Register width + height in `src/lib/image-metadata.ts` so there is zero layout shift.
|
||||
- Set explicit `width`, `height`, `loading="lazy"`, `decoding="async"` on every `<img>`.
|
||||
- Alt text is a full, natural, location-aware sentence (e.g. "Four small Tiny Gang dogs
|
||||
looking up together in the long grass at an Auckland park"), not "dogs".
|
||||
- Keep the `FaqSection` schema (`emitSchema`) and the `Service` + `AggregateOffer`
|
||||
structured data in `src/routes/[slug]/+page.svelte` intact.
|
||||
|
||||
## Reference implementation (copy this)
|
||||
|
||||
- Component: `src/lib/components/pages/ServiceClarityPage.svelte` (shared by all three
|
||||
service pages, content-driven)
|
||||
- Pricing variant: `src/lib/components/pages/PricingClarityPage.svelte`
|
||||
- Coverage section: `src/lib/components/sections/ServiceAreaCoverage.svelte`
|
||||
- Preview routes (noindex): `/variants/tiny-gang`, `/variants/solo-walks`,
|
||||
`/variants/puppy-visits`, `/variants/our-pricing`
|
||||
- Content: the `clarity` block in `pack-walks.ts`, `dog-walking.ts`, `puppy-visits.ts`
|
||||
- Variant switcher (preview only): `src/lib/components/ui/VariantNav.svelte`, mounted in
|
||||
`src/routes/+layout.svelte`
|
||||
|
||||
Read the header comment in `ServiceClarityPage.svelte` first. It states the philosophy
|
||||
and the section order in one place.
|
||||
|
||||
## Status
|
||||
|
||||
Done (built, svelte-check clean, server-rendered):
|
||||
|
||||
- `ServiceClarityPage` generalised and content-driven; `ServicePageContent.clarity`
|
||||
added in `src/lib/types.ts`. Old `TinyGangPage.svelte` deleted.
|
||||
- `clarity` content written for pack-walks, dog-walking, puppy-visits. Tiny Gang renders
|
||||
identically to before.
|
||||
- Coverage spine slot added (`ServiceAreaCoverage`), with per-page stats so puppy-visits
|
||||
reads "in-home" not "pickup & drop-off".
|
||||
- Preview routes for all four variants, plus a global `VariantNav` to compare them.
|
||||
|
||||
## Live now
|
||||
|
||||
The clarity pages are the live pages. `src/routes/[slug]/+page.svelte` renders
|
||||
`ServiceClarityPage` for pack-walks / dog-walking / puppy-visits and `PricingClarityPage`
|
||||
for our-pricing. The old `ServiceLandingPage.svelte`, `PricingPage.svelte`, and
|
||||
`ServiceAreaMap.svelte` have been deleted.
|
||||
|
||||
The `/variants/*` routes still exist as a sandbox (the minimal-footer test lives there),
|
||||
but are **dev/localhost only**: `src/hooks.server.ts` returns 500 for the `/variants`
|
||||
namespace off-localhost in production, and `VariantNav` is hidden the same way in
|
||||
`+layout.svelte`. Verified on a real `node build`: public host → 500 for any
|
||||
`/variants/*` and no `VariantNav`; localhost → variants load.
|
||||
|
||||
Footer note: the live pages keep the default footer. The `minimal` footer is still only
|
||||
on the `/variants/*` pages, a separate decision from the page-design swap.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [x] One `ServiceClarityPage` component drives all three service pages.
|
||||
- [x] Page-specific copy lives in content files, not the component.
|
||||
- [x] All three pages follow the spine, say each point once, share one heading grammar
|
||||
and one token set.
|
||||
- [x] Pricing cards are even on every page. No image in the pricing block.
|
||||
- [x] Exactly one deliberate, captioned, fully-SEO'd image band per service page.
|
||||
- [x] FAQ is a ruled list. Schema preserved.
|
||||
- [x] `npx svelte-check` clean (no new errors).
|
||||
- [x] Live `[slug]` branches swapped to the clarity pages.
|
||||
- [x] Old `ServiceLandingPage.svelte` / `PricingPage.svelte` / `ServiceAreaMap.svelte` deleted.
|
||||
- [x] `/variants/*` blocked in production (500) and `VariantNav` hidden there.
|
||||
- [ ] Decide whether to promote the `minimal` footer to the live pages.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not redesign the brand or introduce new colours, fonts, or gradients.
|
||||
- Do not add card grids, badges, collages, or mobile pagers back in.
|
||||
- Do not change wording on a page unless it is a repeat being removed or copy being
|
||||
shortened. Keep the meaning.
|
||||
Binary file not shown.
@@ -9,7 +9,37 @@
|
||||
"priceDetails": "45",
|
||||
"startDate": "2026-05-18",
|
||||
"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": {
|
||||
"fullName": "Natalie Parke",
|
||||
|
||||
@@ -207,12 +207,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"27"
|
||||
],
|
||||
@@ -432,12 +432,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"45"
|
||||
],
|
||||
@@ -657,12 +657,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"95"
|
||||
],
|
||||
@@ -882,12 +882,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"103"
|
||||
],
|
||||
@@ -1107,12 +1107,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"39"
|
||||
],
|
||||
@@ -1332,12 +1332,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"41"
|
||||
],
|
||||
@@ -1557,12 +1557,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"36"
|
||||
],
|
||||
@@ -1782,12 +1782,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"83"
|
||||
],
|
||||
@@ -2007,12 +2007,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"76",
|
||||
"49"
|
||||
@@ -2233,12 +2233,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"99"
|
||||
],
|
||||
@@ -2458,12 +2458,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"84"
|
||||
],
|
||||
@@ -2683,12 +2683,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"51"
|
||||
],
|
||||
@@ -2908,12 +2908,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"87"
|
||||
],
|
||||
@@ -3134,12 +3134,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"88"
|
||||
],
|
||||
@@ -3359,12 +3359,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"34"
|
||||
],
|
||||
@@ -3584,12 +3584,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"61",
|
||||
"47"
|
||||
@@ -3811,12 +3811,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"57"
|
||||
],
|
||||
@@ -4036,12 +4036,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"32"
|
||||
],
|
||||
@@ -4261,12 +4261,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"22"
|
||||
],
|
||||
@@ -4486,12 +4486,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"64"
|
||||
],
|
||||
@@ -4711,12 +4711,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"74"
|
||||
],
|
||||
@@ -4936,12 +4936,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"56"
|
||||
],
|
||||
@@ -5161,12 +5161,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"116",
|
||||
"93"
|
||||
@@ -5387,12 +5387,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"59"
|
||||
],
|
||||
@@ -5612,12 +5612,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"30"
|
||||
],
|
||||
@@ -5837,12 +5837,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"53"
|
||||
],
|
||||
@@ -6062,12 +6062,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"107"
|
||||
],
|
||||
@@ -6287,12 +6287,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"24"
|
||||
],
|
||||
@@ -6512,12 +6512,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"43"
|
||||
],
|
||||
@@ -6737,12 +6737,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"97"
|
||||
],
|
||||
@@ -6962,12 +6962,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"90"
|
||||
],
|
||||
@@ -7188,12 +7188,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"105"
|
||||
],
|
||||
@@ -7413,12 +7413,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"101"
|
||||
],
|
||||
@@ -7638,12 +7638,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"78"
|
||||
],
|
||||
@@ -7664,12 +7664,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [],
|
||||
"contractEntryIds": [
|
||||
"29"
|
||||
@@ -7887,12 +7887,12 @@
|
||||
"lifecycle": {
|
||||
"status": "archived",
|
||||
"reason": "Imported from legacy Gravity Forms data",
|
||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
||||
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||
"changedBy": "legacy-import"
|
||||
},
|
||||
"legacy": {
|
||||
"source": "gravity-forms-csv",
|
||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
||||
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||
"onboardingEntryIds": [
|
||||
"80"
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -61,11 +61,37 @@ class WelcomePackEmailRequest(BaseModel):
|
||||
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
|
||||
# blank (resolved server-side so scheduled sends keep a sensible subject too).
|
||||
subject: str = ""
|
||||
serviceType: str
|
||||
priceDetails: str
|
||||
startDate: str
|
||||
# Structured offer details rendered in the details table. Now fully optional —
|
||||
# the owner can hide the table entirely (showDetails=False) and write a free
|
||||
# email instead. Left blank, a row is simply omitted from the table.
|
||||
serviceType: str = ""
|
||||
priceDetails: str = ""
|
||||
startDate: str = ""
|
||||
# Free-text customisation. All blank => the email falls back to the original
|
||||
# default copy, so existing callers keep working unchanged. `{first_name}`
|
||||
# and `{dog_name}` tokens are substituted server-side.
|
||||
heading: str = ""
|
||||
intro: str = ""
|
||||
outro: str = ""
|
||||
tagLabel: str = ""
|
||||
# Customisable button block. Defaults to "Complete onboarding" pointing at the
|
||||
# client onboarding portal. Clearing either field hides the button.
|
||||
ctaLabel: str = ""
|
||||
ctaUrl: str = ""
|
||||
# Whether to render the Service / Price / Start date table at all.
|
||||
showDetails: bool = True
|
||||
# Rich-text (WYSIWYG) message body. When present it replaces the
|
||||
# heading/intro/outro copy above and is sanitised to an email-safe subset of
|
||||
# HTML server-side. `includeButton` toggles the onboarding button beneath it.
|
||||
bodyHtml: str = ""
|
||||
includeButton: bool = True
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
# When sending for real (not a preview), also copy the owner so they keep a
|
||||
# record of exactly what the client received. CC is visible to the client;
|
||||
# BCC is hidden. Independent. Ignored on previews and scheduled sends.
|
||||
ccOwner: bool = False
|
||||
bccOwner: bool = False
|
||||
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
|
||||
# request, the welcome email is queued for reliable delivery at that time
|
||||
# instead of being sent immediately.
|
||||
@@ -83,12 +109,17 @@ class ScheduledEmailRescheduleRequest(BaseModel):
|
||||
|
||||
class BirthdayEmailRequest(BaseModel):
|
||||
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
|
||||
previewRecipients: list[EmailStr] = []
|
||||
|
||||
|
||||
class BirthdayAutoSendRequest(BaseModel):
|
||||
email: EmailStr
|
||||
dogId: str | None = None
|
||||
enabled: bool
|
||||
|
||||
|
||||
@@ -110,6 +141,8 @@ class ClientProfileUpdate(BaseModel):
|
||||
phone: str = ""
|
||||
address: str = ""
|
||||
dogName: str
|
||||
dogBreed: str = ""
|
||||
dogAge: str = ""
|
||||
|
||||
|
||||
class ResetOnboardingRequest(BaseModel):
|
||||
@@ -120,6 +153,12 @@ class ResetOnboardingRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class DeleteClientRequest(BaseModel):
|
||||
"""Owner-initiated permanent deletion of a client and their access."""
|
||||
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class NewClientRequest(BaseModel):
|
||||
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
|
||||
that never came through the public enquiry form). Registers the email so
|
||||
@@ -131,14 +170,27 @@ class NewClientRequest(BaseModel):
|
||||
address: str = ""
|
||||
dogName: str = ""
|
||||
dogBreed: str = ""
|
||||
dogAge: str = ""
|
||||
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
|
||||
source: str = ""
|
||||
# The date the client joined Goodwalk (ISO yyyy-mm-dd). Owner-supplied, since
|
||||
# only the owner knows it. Used to surface joining anniversaries.
|
||||
joiningDate: str = ""
|
||||
# When true (and the MYOB integration is configured), also create the client
|
||||
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
|
||||
# local client from being created.
|
||||
createInMyob: bool = False
|
||||
|
||||
|
||||
class ClientDogUpsertRequest(BaseModel):
|
||||
email: EmailStr
|
||||
dogId: str | None = None
|
||||
dogName: str
|
||||
dogBreed: str = ""
|
||||
dogAge: str = ""
|
||||
birthdayAutoSend: bool = False
|
||||
|
||||
|
||||
class ContractSubmission(BaseSubmission):
|
||||
address: str
|
||||
dogName: str
|
||||
|
||||
+949
-98
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { surface } = resolveSurface(event.url, event.cookies);
|
||||
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
|
||||
// owner/clients hosts with marketing-experiment cookies, and it skews
|
||||
// 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(/ /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>
|
||||
@@ -847,7 +847,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
||||
<OnboardingFooter on:logout={handleLogout} />
|
||||
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -27,10 +27,40 @@
|
||||
export let content: HomePageContent;
|
||||
export let ab: AbContext | undefined = undefined;
|
||||
export let pagePath = '/';
|
||||
// The booking form is swappable so variants can preview an alternate Meet &
|
||||
// Greet form on the same homepage design. Defaults to the live wizard, so the
|
||||
// homepage route renders unchanged. See /variants/meet-greet.
|
||||
export let BookingComponent: typeof BookingWizard = BookingWizard;
|
||||
|
||||
const meetGreetVariantPath = '/variants/meet-greet';
|
||||
$: isMeetGreetVariant = pagePath === meetGreetVariantPath;
|
||||
const reviewCta = content.intro.reviewCta;
|
||||
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||
const stars = Array.from({ length: 5 });
|
||||
$: heroLead = isMeetGreetVariant
|
||||
? 'Small group walks, solo support, and puppy visits for Auckland dogs who do better with a calm, consistent routine.'
|
||||
: 'Real adventures with the Tiny Gang, in small, matched groups across Auckland. Same walker every time, so your dog comes home happy, tired, and ready to settle.';
|
||||
$: founderQuote = isMeetGreetVariant
|
||||
? "Small groups, careful matching, and the same walker each time. That's how a walk feels safe, social, and worth looking forward to."
|
||||
: "Every dog gets a walk built around them, not a one-size pack: the right small group, a walker who knows their name and their quirks, and adventures that grow with their confidence. That's how a walk becomes the best part of a dog's day.";
|
||||
$: servicesHeading = isMeetGreetVariant
|
||||
? 'Choose the setup that fits your dog.'
|
||||
: undefined;
|
||||
$: servicesIntro = isMeetGreetVariant
|
||||
? 'Pack walks for social dogs, solo walks for dogs who need more space, and puppy visits for the early routine.'
|
||||
: undefined;
|
||||
$: howItWorksContent = isMeetGreetVariant
|
||||
? {
|
||||
...content.howItWorks,
|
||||
intro: 'Start with a free Meet & Greet. We assess the fit, then build the right routine for your dog.'
|
||||
}
|
||||
: content.howItWorks;
|
||||
$: bookingContent = isMeetGreetVariant
|
||||
? {
|
||||
...content.booking,
|
||||
subtitle: 'Tell us a little about your dog. We reply within 24 hours to arrange your free Meet & Greet.'
|
||||
}
|
||||
: content.booking;
|
||||
</script>
|
||||
|
||||
<Header navigation={content.navigation} />
|
||||
@@ -40,14 +70,10 @@
|
||||
<div class="chero-copy">
|
||||
<p class="chero-eyebrow">Goodwalk · Auckland dog walking</p>
|
||||
<h1 class="chero-title">
|
||||
<span class="chero-title-lead">All we do is</span>
|
||||
<span class="chero-title-main">small & medium dogs</span>
|
||||
<span class="chero-title-lead">Specialist walks for small & medium dogs</span>
|
||||
<span class="chero-title-main">Meet the Tiny Gang.</span>
|
||||
</h1>
|
||||
<p class="chero-lead">
|
||||
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>
|
||||
<p class="chero-lead">{heroLead}</p>
|
||||
</div>
|
||||
|
||||
<div class="chero-media">
|
||||
@@ -107,10 +133,7 @@
|
||||
<figure class="cfounder-quote">
|
||||
<blockquote class="cfounder-blockquote">
|
||||
<p class="cfounder-text">
|
||||
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.
|
||||
{founderQuote}
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption class="cfounder-attr">
|
||||
@@ -133,19 +156,24 @@
|
||||
</section>
|
||||
|
||||
<!-- 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. -->
|
||||
<HowItWorksSection content={content.howItWorks} />
|
||||
<HowItWorksSection content={howItWorksContent} />
|
||||
|
||||
<!-- Only now the reasons to choose us. -->
|
||||
<ValuesSection values={content.values} />
|
||||
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
<FounderStorySection founderStory={content.founderStory} />
|
||||
<InfoSection info={content.info} />
|
||||
<BookingWizard booking={content.booking} {pagePath} {ab} />
|
||||
<InstagramSection instagram={content.instagram} />
|
||||
{#if isMeetGreetVariant}
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
<InfoSection info={content.info} />
|
||||
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||
{:else}
|
||||
<!-- Only now the reasons to choose us. -->
|
||||
<ValuesSection values={content.values} />
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
<FounderStorySection founderStory={content.founderStory} />
|
||||
<InfoSection info={content.info} />
|
||||
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||
<InstagramSection instagram={content.instagram} />
|
||||
{/if}
|
||||
<Footer footer={content.footer} />
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Services-focus homepage variant.
|
||||
*
|
||||
* Same design language as HomeClarity (green hero, yellow CTAs, the shared
|
||||
* ServicesSection / ValuesSection / TestimonialsSection), but every section is
|
||||
* pointed at one job: get the visitor to click through into a service page.
|
||||
*
|
||||
* The site gets traffic but few leads, so this variant removes the big-commitment
|
||||
* booking form from the homepage and lowers the first ask to "pick the walk that
|
||||
* fits your dog". The three services are offered as quick-picks in the hero
|
||||
* itself, repeated as the main ServicesSection cards, reinforced with proof, and
|
||||
* offered a final time in a closing band. A view component only: the route that
|
||||
* renders it owns data loading, SEO head, and structured data.
|
||||
*
|
||||
* Preview route: /variants/services-first (noindex).
|
||||
*/
|
||||
import Footer from '$lib/components/ui/Footer.svelte';
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ServicesSection from '$lib/components/sections/ServicesSection.svelte';
|
||||
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||
import type { HomePageContent } from '$lib/types';
|
||||
|
||||
export let content: HomePageContent;
|
||||
export let pagePath = '/variants/services-first';
|
||||
|
||||
const reviewCta = content.intro.reviewCta;
|
||||
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||
const stars = Array.from({ length: 5 });
|
||||
|
||||
// The assurance bar replaces the old grid of value cards: four plain facts,
|
||||
// stated once. Each is a reason to trust the click, not a feature to skim.
|
||||
const assurances = [
|
||||
{ icon: 'fas fa-user-check', label: 'Same walker, every time' },
|
||||
{ icon: 'fas fa-users', label: 'Small, matched groups' },
|
||||
{ icon: 'fas fa-shield-heart', label: 'Pet first-aid certified & insured' },
|
||||
{ icon: 'fas fa-star', label: '5.0 from 30+ owners' }
|
||||
];
|
||||
|
||||
// The hero quick-picks and the closing band both draw from the same services
|
||||
// data the main ServicesSection uses, so the click targets never drift apart.
|
||||
$: services = content.services;
|
||||
</script>
|
||||
|
||||
<Header navigation={content.navigation} />
|
||||
|
||||
<section class="sfhero" data-track-location="hero" aria-label="Goodwalk dog walking">
|
||||
<div class="sfhero-inner">
|
||||
<div class="sfhero-copy">
|
||||
<p class="sfhero-eyebrow">Professional dog walkers</p>
|
||||
<h1 class="sfhero-title">
|
||||
<span class="sfhero-title-main">Come home to a calmer dog.</span>
|
||||
</h1>
|
||||
<p class="sfhero-lead">
|
||||
Small, matched groups for small & medium dogs. The same walker, every time.
|
||||
</p>
|
||||
|
||||
<div class="sfhero-actions">
|
||||
<a
|
||||
href="#services"
|
||||
class="btn btn-yellow btn-with-arrow"
|
||||
data-track-event="cta_click"
|
||||
data-track-type="hero_primary"
|
||||
data-track-label="See our walks"
|
||||
>
|
||||
See our walks
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="sfhero-reviews"
|
||||
href={reviewCta.href}
|
||||
target={reviewCta.external ? '_blank' : undefined}
|
||||
rel={reviewCta.external ? 'noopener' : undefined}
|
||||
aria-label="30+ five-star Google reviews"
|
||||
data-track-event="social_proof_click"
|
||||
data-track-type="hero_reviews"
|
||||
>
|
||||
<span class="sfhero-reviews-mark" aria-hidden="true">
|
||||
<img src="/images/google-g-logo.svg" alt="" width="15" height="16" />
|
||||
</span>
|
||||
<span class="sfhero-reviews-stars" aria-hidden="true">
|
||||
{#each stars as _}
|
||||
<Icon name="fas fa-star" />
|
||||
{/each}
|
||||
</span>
|
||||
<span class="sfhero-reviews-label">30+ five-star reviews</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sfhero-media">
|
||||
<div class="sfhero-photo">
|
||||
<img
|
||||
src={heroImage}
|
||||
alt="Maya, a happy small dog, after a Goodwalk walk in Auckland"
|
||||
width="1536"
|
||||
height="1024"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-picks: the three services as clickable choices, in the hero, above
|
||||
the fold. This is the conversion move — the visitor's first decision is
|
||||
"which walk", not "fill in a form". -->
|
||||
<nav class="sfhero-picks" aria-label="Choose a service">
|
||||
{#each services as service}
|
||||
<a
|
||||
class="sfhero-pick"
|
||||
href={service.href}
|
||||
data-track-event="service_quickpick_click"
|
||||
data-track-type="hero_quickpick"
|
||||
data-track-label={service.title}
|
||||
>
|
||||
<span class="sfhero-pick-glyph" aria-hidden="true">
|
||||
<Icon name={service.icon} />
|
||||
</span>
|
||||
<span class="sfhero-pick-text">
|
||||
<span class="sfhero-pick-title">{service.title}</span>
|
||||
{#if service.priceFrom}
|
||||
<span class="sfhero-pick-price">{service.priceFrom}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Icon name="fas fa-arrow-right" className="sfhero-pick-arrow" />
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The conversion engine: the service cards themselves link straight through to
|
||||
each service page. -->
|
||||
<ServicesSection
|
||||
services={content.services}
|
||||
heading="Choose your dog's walk."
|
||||
intro="Three ways to walk — pack, solo, or puppy visits. Same walker, small groups, built around your dog."
|
||||
/>
|
||||
|
||||
<!-- One calm assurance beat. Replaces the old wall of value cards: four plain
|
||||
facts, stated once, then straight on to proof. No competing form. -->
|
||||
<section class="sfproof" aria-label="What every Goodwalk includes">
|
||||
<ul class="sfproof-row">
|
||||
{#each assurances as item}
|
||||
<li class="sfproof-item">
|
||||
<Icon name={item.icon} className="sfproof-icon" />
|
||||
<span>{item.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
|
||||
<!-- Closing band: offer the same three choices one more time, on brand green. -->
|
||||
<section class="sfclose" aria-label="Choose your dog's walk">
|
||||
<div class="sfclose-inner">
|
||||
<div class="sfclose-head">
|
||||
<h2 class="sfclose-title">Find the right walk for your dog.</h2>
|
||||
<p class="sfclose-lead">
|
||||
Three options. No commitment. Just pick the one that sounds like your dog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sfclose-grid">
|
||||
{#each services as service}
|
||||
<a
|
||||
class="sfclose-card"
|
||||
href={service.href}
|
||||
data-track-event="service_card_click"
|
||||
data-track-type="closing_band"
|
||||
data-track-label={service.title}
|
||||
>
|
||||
<span class="sfclose-card-glyph" aria-hidden="true">
|
||||
<Icon name={service.icon} />
|
||||
</span>
|
||||
<span class="sfclose-card-title">{service.title}</span>
|
||||
<span class="sfclose-card-body">{service.body}</span>
|
||||
{#if service.priceFrom}
|
||||
<span class="sfclose-card-price">{service.priceFrom}</span>
|
||||
{/if}
|
||||
<span class="sfclose-card-cta">
|
||||
View this walk
|
||||
<Icon name="fas fa-arrow-right" className="sfclose-card-arrow" />
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer footer={content.footer} />
|
||||
|
||||
<style>
|
||||
/* ── Hero ──────────────────────────────────────────────────────────────
|
||||
Mirrors HomeClarity's .chero so the variant reads as the same site:
|
||||
green field, one soft yellow light, identical type scale. The layout
|
||||
adds a third row for the service quick-picks. */
|
||||
|
||||
.sfhero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gw-green);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.sfhero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -28%;
|
||||
right: -10%;
|
||||
width: 60%;
|
||||
height: 90%;
|
||||
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.16), transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sfhero-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 5.5vw, 80px) var(--space-container-x);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
grid-template-areas:
|
||||
'copy media'
|
||||
'picks picks';
|
||||
column-gap: clamp(32px, 5vw, 76px);
|
||||
row-gap: clamp(32px, 4vw, 48px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sfhero-copy {
|
||||
grid-area: copy;
|
||||
}
|
||||
|
||||
.sfhero-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 18px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(var(--white-rgb), 0.7);
|
||||
}
|
||||
|
||||
.sfhero-eyebrow::before {
|
||||
content: '';
|
||||
width: 26px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.sfhero-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
}
|
||||
|
||||
.sfhero-title-main {
|
||||
display: block;
|
||||
font-size: clamp(42px, 6.4vw, 78px);
|
||||
font-weight: 800;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--yellow);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.sfhero-lead {
|
||||
margin: 22px 0 0;
|
||||
max-width: 42ch;
|
||||
font-size: clamp(16px, 1.3vw, 19px);
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--white-rgb), 0.82);
|
||||
}
|
||||
|
||||
.sfhero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px 26px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.sfhero-reviews {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-decoration: none;
|
||||
color: rgba(var(--white-rgb), 0.9);
|
||||
transition: color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sfhero-reviews-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-page);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.sfhero-reviews-mark img {
|
||||
display: block;
|
||||
width: 15px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sfhero-reviews-stars {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
color: var(--yellow);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sfhero-reviews-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--white-rgb), 0.9);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sfhero-reviews:hover {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
}
|
||||
|
||||
.sfhero-reviews:focus-visible {
|
||||
outline: 2px solid var(--yellow);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Media */
|
||||
.sfhero-media {
|
||||
grid-area: media;
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.sfhero-media::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 8% -6% -10% 10%;
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(var(--accent-rgb), 0.22);
|
||||
filter: blur(46px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sfhero-photo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
aspect-ratio: 4 / 5;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--white-rgb), 0.12),
|
||||
var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.sfhero-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 28%;
|
||||
}
|
||||
|
||||
/* ── Hero quick-picks ────────────────────────────────────────────────── */
|
||||
|
||||
.sfhero-picks {
|
||||
grid-area: picks;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding-top: clamp(8px, 1.5vw, 16px);
|
||||
border-top: 1px solid rgba(var(--white-rgb), 0.12);
|
||||
}
|
||||
|
||||
.sfhero-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(var(--white-rgb), 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.1);
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sfhero-pick-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 13px;
|
||||
background: rgba(var(--accent-rgb), 0.16);
|
||||
color: var(--yellow);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sfhero-pick-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sfhero-pick-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sfhero-pick-price {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--white-rgb), 0.66);
|
||||
}
|
||||
|
||||
:global(.sfhero-pick-arrow) {
|
||||
font-size: 12px;
|
||||
color: var(--yellow);
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sfhero-pick:hover {
|
||||
background: rgba(var(--white-rgb), 0.09);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.22);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.sfhero-pick:hover :global(.sfhero-pick-arrow) {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.sfhero-pick:focus-visible {
|
||||
outline: 2px solid var(--yellow);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Entrance — same choreography as the clarity hero. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sfhero-copy,
|
||||
.sfhero-picks {
|
||||
animation: sfheroRise 0.6s var(--ease-out-soft) both;
|
||||
}
|
||||
|
||||
.sfhero-picks {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sfhero-photo {
|
||||
animation: sfheroPhoto 0.8s var(--ease-out-soft) both;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sfheroRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sfheroPhoto {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Assurance bar ─────────────────────────────────────────────────────
|
||||
A single calm strip of facts — the "say it plainly" beat that replaces a
|
||||
long grid of value cards. Warm cream divider between services and proof. */
|
||||
|
||||
.sfproof {
|
||||
background: var(--surface-panel-cream);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.sfproof-row {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-w);
|
||||
padding: clamp(18px, 2.4vw, 26px) var(--space-container-x);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px clamp(24px, 4vw, 52px);
|
||||
}
|
||||
|
||||
.sfproof-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
:global(.sfproof-icon) {
|
||||
font-size: 15px;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
/* ── Closing band ──────────────────────────────────────────────────────
|
||||
Brand-green field that repeats the three service choices. Mirrors the
|
||||
service-card styling so the page closes on the same call it opened with. */
|
||||
|
||||
.sfclose {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gw-green);
|
||||
color: var(--text-inverse);
|
||||
padding: var(--space-section-support-y) 0;
|
||||
}
|
||||
|
||||
.sfclose::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30%;
|
||||
left: -8%;
|
||||
width: 55%;
|
||||
height: 90%;
|
||||
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.14), transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sfclose-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: min(1180px, calc(var(--max-w) - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-container-x);
|
||||
}
|
||||
|
||||
.sfclose-head {
|
||||
max-width: 46ch;
|
||||
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sfclose-title {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 4vw, 44px);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-inverse);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.sfclose-lead {
|
||||
margin: 0;
|
||||
font-size: clamp(15px, 1.3vw, 18px);
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--white-rgb), 0.82);
|
||||
}
|
||||
|
||||
.sfclose-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sfclose-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 28px 24px;
|
||||
border-radius: 22px;
|
||||
background: rgba(var(--white-rgb), 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.1);
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.24s ease,
|
||||
box-shadow 0.24s ease,
|
||||
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sfclose-card-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 15px;
|
||||
background: rgba(var(--accent-rgb), 0.16);
|
||||
color: var(--yellow);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sfclose-card-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sfclose-card-body {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--white-rgb), 0.78);
|
||||
}
|
||||
|
||||
.sfclose-card-price {
|
||||
margin-top: 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.sfclose-card-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding-top: 18px;
|
||||
width: 100%;
|
||||
border-top: 1px solid rgba(var(--white-rgb), 0.12);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
:global(.sfclose-card-arrow) {
|
||||
font-size: 11px;
|
||||
color: var(--yellow);
|
||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sfclose-card:hover {
|
||||
background: rgba(var(--white-rgb), 0.09);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.22);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.sfclose-card:hover :global(.sfclose-card-arrow) {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.sfclose-card:focus-visible {
|
||||
outline: 2px solid var(--yellow);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* ── Mobile ────────────────────────────────────────────────────────────
|
||||
Same single-column logic as the clarity hero: message, photo, action,
|
||||
then the picks stack full width. */
|
||||
@media (max-width: 900px) {
|
||||
.sfhero-picks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sfhero-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px var(--space-container-x-mobile) 36px;
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.sfhero-copy {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.sfhero-media {
|
||||
order: 2;
|
||||
justify-self: stretch;
|
||||
max-width: none;
|
||||
margin: 24px 0 26px;
|
||||
}
|
||||
|
||||
.sfhero-picks {
|
||||
order: 3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sfhero-photo {
|
||||
aspect-ratio: 5 / 4;
|
||||
}
|
||||
|
||||
.sfhero-lead {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sfhero-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sfclose-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -123,17 +123,6 @@
|
||||
// Computed legacy field for downstream consumers expecting a single full name.
|
||||
$: 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 visitStartedAt = 0;
|
||||
let pageEnteredAt = 0;
|
||||
@@ -703,12 +692,10 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
</a>
|
||||
</div>
|
||||
<div class="onboarding-topbar-right">
|
||||
{#if isAuthenticated && displayName}
|
||||
{#if isAuthenticated}
|
||||
<div class="onboarding-account" aria-label="Account">
|
||||
<span class="onboarding-account-avatar" aria-hidden="true">{accountInitials}</span>
|
||||
<span class="onboarding-account-text">
|
||||
<span class="onboarding-account-prefix">Logged in as</span>
|
||||
<span class="onboarding-account-name">{displayName}</span>
|
||||
<span class="onboarding-account-avatar" aria-hidden="true">
|
||||
<Icon name="fas fa-user" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -749,8 +736,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
<h1>
|
||||
{#if onboardingCompleted}
|
||||
Your submitted onboarding details.
|
||||
{:else if displayName}
|
||||
Hi {displayName}, let's get your dog onboarded.
|
||||
{:else}
|
||||
A few details so we can care for your dog properly.
|
||||
{/if}
|
||||
@@ -1399,7 +1384,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
||||
<OnboardingFooter on:logout={handleLogout} />
|
||||
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1504,33 +1489,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1575,15 +1533,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.onboarding-account-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-account-name {
|
||||
font-size: 12px;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.onboarding-account-logout-label {
|
||||
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 4–8 dogs',
|
||||
length: '60–75 min'
|
||||
},
|
||||
{
|
||||
href: '/dog-walking',
|
||||
service: 'Solo Walks',
|
||||
bestFor: 'Reactive, anxious, senior or recovering dogs',
|
||||
format: 'One dog, one-on-one',
|
||||
length: '30 / 45 / 60 min'
|
||||
},
|
||||
{
|
||||
href: '/puppy-visits',
|
||||
service: 'Puppy Visits',
|
||||
bestFor: 'Puppies too young for full walks',
|
||||
format: 'In-home, one-on-one',
|
||||
length: '20 / 45 / 60 min'
|
||||
}
|
||||
];
|
||||
|
||||
function fromFor(href: string): string {
|
||||
const block = blocks.find((b) => b.href === href);
|
||||
return block ? fromValue(block.section) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="pc">
|
||||
<!-- Standard service hero (green copy left, photo right). -->
|
||||
<ServiceHero
|
||||
eyebrow={ourPricingContent.eyebrow ?? 'Pricing'}
|
||||
title={ourPricingContent.title}
|
||||
subtitle={ourPricingContent.subtitle}
|
||||
imageUrl={ourPricingContent.heroImageUrl}
|
||||
imageAlt={ourPricingContent.heroImageAlt}
|
||||
chips={ourPricingContent.heroChips ?? []}
|
||||
cta={{ label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'yellow' }}
|
||||
/>
|
||||
|
||||
<!-- Value before price: one line, given room to land. -->
|
||||
<section class="pc-value" use:reveal={{ distance: 0 }} aria-labelledby="pc-value-quote">
|
||||
<div class="pc-inner">
|
||||
<span class="pc-value-rule" aria-hidden="true"></span>
|
||||
<p id="pc-value-quote" class="pc-value-quote">
|
||||
The best investment you’ll ever make is in your dog’s happiness.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- At a glance: a compact comparison of the three services before the
|
||||
detailed per-service blocks. A real table, so it reads cleanly and is
|
||||
easy to lift as a unit. -->
|
||||
<section class="pc-compare" use:reveal={{ distance: 0 }} aria-labelledby="pc-compare-heading">
|
||||
<div class="pc-inner">
|
||||
<div class="pc-compare-head">
|
||||
<p class="pc-eyebrow">Compare at a glance</p>
|
||||
<h2 id="pc-compare-heading" class="pc-h2">Three ways Goodwalk fits your dog</h2>
|
||||
</div>
|
||||
<div class="pc-table-wrap">
|
||||
<table class="pc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Service</th>
|
||||
<th scope="col">Best for</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Length</th>
|
||||
<th scope="col">From</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each comparisonRows as row}
|
||||
<tr>
|
||||
<th scope="row"><a href={row.href}>{row.service}</a></th>
|
||||
<td>{row.bestFor}</td>
|
||||
<td>{row.format}</td>
|
||||
<td>{row.length}</td>
|
||||
<td class="pc-table-from">{fromFor(row.href)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- One prominent block per service: photo + simplified message, then cards. -->
|
||||
{#each blocks as block, i}
|
||||
<section
|
||||
class="pc-service"
|
||||
class:pc-service-tint={i % 2 === 1}
|
||||
use:reveal={{ distance: 0 }}
|
||||
aria-labelledby={`pc-svc-${i}`}
|
||||
>
|
||||
<div class="pc-inner">
|
||||
<div class="pc-row" class:pc-row-reverse={i % 2 === 1}>
|
||||
{#if block.section.imageUrl}
|
||||
<figure class="pc-media">
|
||||
<img
|
||||
src={block.section.imageUrl}
|
||||
alt={block.section.imageAlt ?? block.section.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</figure>
|
||||
{/if}
|
||||
|
||||
<div class="pc-copy">
|
||||
{#if block.section.eyebrow}
|
||||
<p class="pc-eyebrow">{block.section.eyebrow}</p>
|
||||
{/if}
|
||||
<h2 id={`pc-svc-${i}`} class="pc-h2">{block.section.title}</h2>
|
||||
{#if block.section.outcome}
|
||||
<p class="pc-outcome">{block.section.outcome}</p>
|
||||
{/if}
|
||||
{#if fromValue(block.section)}
|
||||
<p class="pc-from"><span>From</span> <strong>{fromValue(block.section)}</strong></p>
|
||||
{/if}
|
||||
<a class="pc-link" href={block.href}>
|
||||
See {block.section.title}
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-plans" class:pc-plans-three={block.section.plans.length === 3}>
|
||||
{#each plansFor(block) as plan}
|
||||
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<!-- Coverage -->
|
||||
<div class="pc-area" use:reveal={{ distance: 0 }}>
|
||||
<ServiceAreaCoverage
|
||||
title="Where we work across Auckland Central"
|
||||
lead="Free door-to-door pickup and drop-off on walks across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Close -->
|
||||
<section class="pc-cta" use:reveal={{ distance: 0 }}>
|
||||
<div class="pc-inner">
|
||||
<h2 class="pc-cta-title">Not sure which one fits?</h2>
|
||||
<p class="pc-cta-sub">Every Goodwalk routine starts with a free Meet & Greet. We will tell you what fits, and what does not.</p>
|
||||
<a class="btn btn-yellow" href="/contact-us">Book a free Meet & Greet</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.pc {
|
||||
--pc-max: 1180px;
|
||||
background: var(--off-white);
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.pc-inner {
|
||||
max-width: var(--pc-max);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--space-container-x);
|
||||
}
|
||||
|
||||
.pc-service {
|
||||
padding-block: clamp(48px, 6vw, 88px);
|
||||
}
|
||||
|
||||
/* ── Value statement: one line, given room to land ── */
|
||||
.pc-value {
|
||||
padding-block: clamp(64px, 9vw, 128px);
|
||||
background: var(--surface-panel-cream);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pc-value-rule {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
margin: 0 auto 30px;
|
||||
border-radius: 3px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.pc-value-quote {
|
||||
margin: 0 auto;
|
||||
max-width: 16ch;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(30px, 4.8vw, 56px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--gw-green);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── At-a-glance comparison table ── */
|
||||
.pc-compare {
|
||||
padding-block: clamp(48px, 6vw, 88px);
|
||||
}
|
||||
|
||||
.pc-compare-head {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pc-compare-head .pc-eyebrow {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Scrolls on narrow screens rather than cramping five columns. */
|
||||
.pc-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.pc-table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface-panel);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pc-table thead th {
|
||||
padding: 16px 20px;
|
||||
background: var(--gw-green);
|
||||
color: rgba(255, 248, 230, 0.96);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pc-table tbody th,
|
||||
.pc-table tbody td {
|
||||
padding: 18px 20px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-heading-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pc-table tbody th {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.pc-table tbody th a {
|
||||
color: var(--gw-green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pc-table tbody th a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pc-table-from {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
color: var(--gw-green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alternating tint for rhythm, the same trick the service pages use. */
|
||||
.pc-service-tint,
|
||||
.pc-area {
|
||||
background: var(--surface-panel-cream);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* ── Prominent service row: photo + simplified message ── */
|
||||
.pc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: clamp(28px, 5vw, 64px);
|
||||
align-items: center;
|
||||
margin-bottom: clamp(32px, 4.5vw, 56px);
|
||||
}
|
||||
|
||||
.pc-media {
|
||||
order: 0;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--surface-brand);
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.pc-row-reverse .pc-media {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pc-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pc-copy {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pc-row-reverse .pc-copy {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.pc-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.pc-eyebrow::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.pc-h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3.4vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-heading);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* The simplified message: one line, carried with weight. */
|
||||
.pc-outcome {
|
||||
margin: 16px 0 0;
|
||||
max-width: 34ch;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(18px, 1.9vw, 23px);
|
||||
font-weight: 600;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--gw-green);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.pc-from {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin: 22px 0 0;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-brand-soft);
|
||||
color: var(--gw-green);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.pc-from span {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pc-from strong {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--gw-green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pc-from + .pc-link {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.pc-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Plans ── */
|
||||
.pc-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.pc-plans-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
max-width: 1040px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card) {
|
||||
padding: 40px 28px 34px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card--featured) {
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card__cta) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card__cta .icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Close CTA ── */
|
||||
.pc-cta {
|
||||
padding-block: clamp(48px, 6vw, 88px);
|
||||
background: var(--gw-green);
|
||||
color: rgba(255, 248, 230, 0.96);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pc-cta-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(26px, 3.2vw, 40px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--yellow);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.pc-cta-sub {
|
||||
margin: 14px auto 24px;
|
||||
max-width: 38rem;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 248, 230, 0.9);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 860px) {
|
||||
.pc-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(20px, 5vw, 32px);
|
||||
}
|
||||
|
||||
.pc-media,
|
||||
.pc-row-reverse .pc-media {
|
||||
order: 0;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.pc-copy,
|
||||
.pc-row-reverse .pc-copy {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.pc-plans,
|
||||
.pc-plans-three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pc-plans,
|
||||
.pc-plans-three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card) {
|
||||
padding: 30px 24px 26px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card--featured) {
|
||||
padding-top: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
+256
-144
@@ -1,16 +1,12 @@
|
||||
<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
|
||||
* ServiceLandingPage treatment (for this page) with a single green hero +
|
||||
* light body that shares the homepage's visual language. The message is said
|
||||
* 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.
|
||||
* Drives all three service pages (Tiny Gang, Solo Walks, Puppy Visits) from
|
||||
* content. Page-specific copy lives in each content file's `clarity` block;
|
||||
* see docs/v5.md. View component only: the route owns Header/Footer/SEO.
|
||||
*/
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||
@@ -19,82 +15,19 @@
|
||||
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||
import { decoratePlans } from '$lib/utils/pricing';
|
||||
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: ServicePageContent;
|
||||
export let currentPath = '/pack-walks';
|
||||
|
||||
// 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'
|
||||
]
|
||||
};
|
||||
export let currentPath = '/';
|
||||
|
||||
$: clarity = pageContent.clarity;
|
||||
$: traits = clarity?.traits ?? [];
|
||||
$: steps = clarity?.steps ?? [];
|
||||
$: planLines = clarity?.planLines ?? {};
|
||||
$: priceNotes = clarity?.priceNotes ?? [];
|
||||
$: pricingPlans = decoratePlans(
|
||||
pageContent.pricing.plans.map((plan) => ({
|
||||
...plan,
|
||||
@@ -105,8 +38,7 @@
|
||||
</script>
|
||||
|
||||
<main class="tg">
|
||||
<!-- Hero: the shared service-page hero (green copy left, photo bleeds
|
||||
right), identical to /dog-walking and /puppy-visits. -->
|
||||
<!-- Hero: shared service-page hero (green copy left, photo bleeds right). -->
|
||||
<ServiceHero
|
||||
eyebrow={pageContent.hero.eyebrow}
|
||||
title={pageContent.hero.title}
|
||||
@@ -117,53 +49,86 @@
|
||||
cta={pageContent.hero.cta}
|
||||
/>
|
||||
|
||||
<!-- What makes it different (said once) -->
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">What makes Tiny Gang different</p>
|
||||
<h2 id="tg-diff-heading" class="tg-h2">Not a big anonymous pack. A small, matched crew.</h2>
|
||||
<!-- What it is: a plain, self-contained definition that opens the page. -->
|
||||
{#if clarity?.definition}
|
||||
<section class="tg-section tg-section-def" use:reveal={{ distance: 0 }} aria-labelledby="tg-def-heading">
|
||||
<div class="tg-inner tg-inner-narrow">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity.definition.eyebrow ?? 'In plain terms'}</p>
|
||||
<h2 id="tg-def-heading" class="tg-h2">{clarity.definition.heading}</h2>
|
||||
</div>
|
||||
<p class="tg-def-body">{clarity.definition.body}</p>
|
||||
</div>
|
||||
<ul class="tg-traits">
|
||||
{#each traits as trait}
|
||||
<li class="tg-trait">
|
||||
<span class="tg-trait-icon" aria-hidden="true"><Icon name={trait.icon} /></span>
|
||||
<div>
|
||||
<h3>{trait.title}</h3>
|
||||
<p>{trait.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- How joining works (the process, explicit) -->
|
||||
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">How joining works</p>
|
||||
<h2 id="tg-steps-heading" class="tg-h2">Three simple steps, no pressure.</h2>
|
||||
<!-- What makes it different (said once) -->
|
||||
{#if traits.length}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.diffEyebrow ?? 'What makes it different'}</p>
|
||||
<h2 id="tg-diff-heading" class="tg-h2">{clarity?.diffHeading ?? ''}</h2>
|
||||
</div>
|
||||
<ul class="tg-traits">
|
||||
{#each traits as trait}
|
||||
<li class="tg-trait">
|
||||
<span class="tg-trait-icon" aria-hidden="true"><Icon name={trait.icon} /></span>
|
||||
<div>
|
||||
<h3>{trait.title}</h3>
|
||||
<p>{trait.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- How it works (the process, explicit) -->
|
||||
{#if steps.length}
|
||||
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.stepsEyebrow ?? 'How it works'}</p>
|
||||
<h2 id="tg-steps-heading" class="tg-h2">{clarity?.stepsHeading ?? ''}</h2>
|
||||
</div>
|
||||
<ol class="tg-steps">
|
||||
{#each steps as step, i}
|
||||
<li class="tg-step">
|
||||
<span class="tg-step-num" aria-hidden="true">{i + 1}</span>
|
||||
<h3>{step.title}</h3>
|
||||
<p>{step.body}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Image beat: one deliberate photo that breaks the longest text run. -->
|
||||
{#if clarity?.imageBand}
|
||||
<div class="tg-band-wrap" use:reveal={{ distance: 0 }}>
|
||||
<figure class="tg-band">
|
||||
<img
|
||||
class="tg-band-img"
|
||||
src={clarity.imageBand.imageUrl}
|
||||
alt={clarity.imageBand.imageAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption class="tg-band-cap">{clarity.imageBand.caption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Is it right for your dog? -->
|
||||
{#if pageContent.decision}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">Honest fit check</p>
|
||||
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is Tiny Gang right for your dog?'}</h2>
|
||||
<p class="tg-eyebrow">{clarity?.fitEyebrow ?? 'Honest fit check'}</p>
|
||||
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is this right for your dog?'}</h2>
|
||||
</div>
|
||||
<div class="tg-fit">
|
||||
<div class="tg-fit-col">
|
||||
@@ -178,7 +143,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<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>
|
||||
{#each pageContent.decision.notFitItems as item}
|
||||
<li>
|
||||
@@ -196,9 +161,9 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Proof: owners love it -->
|
||||
<!-- Proof -->
|
||||
<TestimonialsSection
|
||||
heading="Owners love the Tiny Gang"
|
||||
heading={clarity?.testimonialsHeading ?? pageContent.testimonialsHeading}
|
||||
testimonials={content.testimonials}
|
||||
seedKey={currentPath}
|
||||
/>
|
||||
@@ -207,28 +172,32 @@
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
|
||||
<div class="tg-inner tg-inner-wide">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">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>
|
||||
<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}
|
||||
<p class="tg-head-sub">{pageContent.pricing.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="tg-plans">
|
||||
<div class="tg-plans" class:tg-plans-three={pricingPlans.length === 3}>
|
||||
{#each pricingPlans as plan}
|
||||
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="tg-price-notes">
|
||||
{#if pageContent.pricing.scarcityNote}
|
||||
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
||||
{/if}
|
||||
<p class="tg-note"><Icon name="fas fa-stopwatch" />60+ minute adventures.</p>
|
||||
<p class="tg-note"><Icon name="fas fa-car" />Free pickup & drop-off, every walk.</p>
|
||||
<p class="tg-note"><Icon name="fas fa-shield-heart" />Starts with a free Meet & Greet.</p>
|
||||
</div>
|
||||
{#if pageContent.pricing.scarcityNote || priceNotes.length}
|
||||
<div class="tg-price-notes">
|
||||
{#if pageContent.pricing.scarcityNote}
|
||||
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
||||
{/if}
|
||||
{#each priceNotes as note}
|
||||
<p class="tg-note"><Icon name={note.icon} />{note.label}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet & Greet</a>
|
||||
</div>
|
||||
@@ -236,17 +205,32 @@
|
||||
|
||||
<!-- FAQ -->
|
||||
{#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">
|
||||
<FaqSection
|
||||
title={pageContent.faq?.title ?? 'Tiny Gang FAQs'}
|
||||
intro={pageContent.faq?.intro}
|
||||
faqs={faqItems}
|
||||
/>
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.faqEyebrow ?? 'Common questions'}</p>
|
||||
<h2 id="tg-faq-heading" class="tg-h2">{pageContent.faq?.title ?? 'FAQs'}</h2>
|
||||
{#if pageContent.faq?.intro}
|
||||
<p class="tg-head-sub">{pageContent.faq.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tg-faq">
|
||||
<FaqSection title={pageContent.faq?.title ?? 'FAQs'} faqs={faqItems} showHeading={false} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Service area: a coverage field that works on every screen. -->
|
||||
<div class="tg-section-area" use:reveal={{ distance: 0 }}>
|
||||
<ServiceAreaCoverage
|
||||
title={clarity?.areaTitle ?? 'Where we walk across Auckland Central'}
|
||||
lead={clarity?.areaLead ??
|
||||
'Free door-to-door pickup and drop-off across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes.'}
|
||||
stats={clarity?.areaStats ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BookingWizard booking={pageContent.booking} pagePath={currentPath} />
|
||||
</main>
|
||||
|
||||
@@ -280,7 +264,8 @@
|
||||
}
|
||||
|
||||
/* 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);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
}
|
||||
@@ -332,6 +317,21 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Definition lede: one calm, readable paragraph under the opening head ── */
|
||||
.tg-section-def {
|
||||
padding-bottom: clamp(8px, 2vw, 24px);
|
||||
}
|
||||
|
||||
.tg-def-body {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
color: var(--text-heading-soft);
|
||||
font-size: clamp(17px, 1.7vw, 19px);
|
||||
line-height: 1.7;
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tg-price-tagline {
|
||||
margin: 14px auto 0;
|
||||
max-width: 32rem;
|
||||
@@ -503,6 +503,50 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Image beat: a contained cinematic band between sections ───────────── */
|
||||
.tg-band-wrap {
|
||||
max-width: var(--tg-max);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--space-container-x);
|
||||
padding-block: clamp(8px, 2vw, 28px);
|
||||
}
|
||||
|
||||
.tg-band {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--surface-brand);
|
||||
}
|
||||
|
||||
.tg-band-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 7;
|
||||
object-fit: cover;
|
||||
object-position: center 42%;
|
||||
}
|
||||
|
||||
/* Caption rides on the photo: legible over the image, keyword-bearing copy
|
||||
that reads as a moment, not a label. */
|
||||
.tg-band-cap {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: clamp(28px, 5vw, 44px) clamp(18px, 4vw, 32px) clamp(16px, 2.4vw, 22px);
|
||||
background: linear-gradient(to top, rgba(33, 48, 33, 0.78), rgba(33, 48, 33, 0));
|
||||
color: rgba(255, 248, 230, 0.96);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(15px, 1.8vw, 19px);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Pricing ───────────────────────────────────────────────────────── */
|
||||
.tg-plans {
|
||||
display: grid;
|
||||
@@ -510,6 +554,13 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Three-plan pages (solo walks, puppy visits) centre nicely at three-up. */
|
||||
.tg-plans-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
max-width: 1040px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
|
||||
PricingPlanCard and other pages keep their own sizing. */
|
||||
.tg-plans :global(.plan-card) {
|
||||
@@ -523,7 +574,7 @@
|
||||
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 +
|
||||
align-self:center, so the button always spans the full card width. */
|
||||
.tg-plans :global(.plan-card__cta) {
|
||||
@@ -531,7 +582,14 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
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 {
|
||||
@@ -573,9 +631,58 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── FAQ: a calm ruled list, not a stack of cards ──────────────────────
|
||||
The shared FaqSection renders each question as a bordered, shadowed white
|
||||
card. On this off-white page that reads as visual noise. Here we strip the
|
||||
card chrome to hairline dividers so the questions sit as one quiet column,
|
||||
matching the page's restraint. Scoped via .tg-faq; every other page keeps
|
||||
the card treatment. */
|
||||
.tg-faq :global(.faq details) {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details[open]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Full-bleed rows: no card inset, generous vertical breathing room. */
|
||||
.tg-faq :global(.faq summary) {
|
||||
padding: 22px 4px;
|
||||
border-radius: 0;
|
||||
font-size: 17px;
|
||||
color: var(--text-heading);
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
/* Hover lifts the question to brand green instead of filling a card. */
|
||||
.tg-faq :global(.faq summary:hover) {
|
||||
background: transparent;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details[open] summary) {
|
||||
border-bottom: 0;
|
||||
border-radius: 0;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details p) {
|
||||
padding: 0 44px 24px 4px;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────── */
|
||||
@media (max-width: 980px) {
|
||||
.tg-plans {
|
||||
.tg-plans,
|
||||
.tg-plans-three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -594,7 +701,12 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-plans {
|
||||
.tg-band-img {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
|
||||
.tg-plans,
|
||||
.tg-plans-three {
|
||||
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.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();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||
@@ -167,7 +169,8 @@ describe('BookingSection', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,9 @@
|
||||
export let faqs: FaqItem[];
|
||||
export let emitSchema = true;
|
||||
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({
|
||||
'@context': 'https://schema.org',
|
||||
@@ -30,12 +33,14 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
||||
<h2 class="faq-section-heading">
|
||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||
{title}
|
||||
</h2>
|
||||
{#if intro}
|
||||
<p class="faq-section-intro">{intro}</p>
|
||||
{#if showHeading}
|
||||
<h2 class="faq-section-heading">
|
||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||
{title}
|
||||
</h2>
|
||||
{#if intro}
|
||||
<p class="faq-section-intro">{intro}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<div use:accordion class="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>
|
||||
@@ -4,6 +4,8 @@
|
||||
import { locationPages } from '$lib/content/locations';
|
||||
|
||||
export let footer: FooterContent;
|
||||
// 'minimal' is a typography test running on the /variants/* pages.
|
||||
export let variant: 'default' | 'minimal' = 'default';
|
||||
|
||||
|
||||
const socialLinks: LinkItem[] = [
|
||||
@@ -35,7 +37,7 @@
|
||||
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
||||
</script>
|
||||
|
||||
<footer data-track-location="footer">
|
||||
<footer data-track-location="footer" class:footer--min={variant === 'minimal'}>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<enhanced:img
|
||||
@@ -125,3 +127,61 @@
|
||||
</button>
|
||||
</div>
|
||||
</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 Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
export let email = '';
|
||||
|
||||
const dispatch = createEventDispatcher<{ logout: void }>();
|
||||
|
||||
let loggingOut = false;
|
||||
@@ -32,9 +30,6 @@
|
||||
<Icon name="fas fa-arrow-left" />
|
||||
Back to main site
|
||||
</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}>
|
||||
<Icon name="fas fa-right-from-bracket" />
|
||||
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||
@@ -75,16 +70,6 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -116,16 +101,6 @@
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.ob-footer-email {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ob-footer-back,
|
||||
.ob-footer-logout {
|
||||
|
||||
@@ -183,6 +183,66 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
]
|
||||
},
|
||||
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: {
|
||||
title: 'See if solo walks are the right fit',
|
||||
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
|
||||
|
||||
@@ -206,6 +206,89 @@ export const packWalksContent: ServicePageContent = {
|
||||
]
|
||||
},
|
||||
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: {
|
||||
title: 'See if your dog fits our Tiny Gang',
|
||||
subtitle: '',
|
||||
|
||||
@@ -168,6 +168,71 @@ export const puppyVisitsContent: ServicePageContent = {
|
||||
]
|
||||
},
|
||||
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: {
|
||||
title: 'See if Puppy Visits are the right start',
|
||||
subtitle: '',
|
||||
|
||||
@@ -15,6 +15,7 @@ const imageMetadata: Record<string, ImageMetadata> = {
|
||||
'/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-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-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 },
|
||||
|
||||
@@ -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) {
|
||||
return locations.map((location) => ({
|
||||
'@type': 'Place',
|
||||
|
||||
@@ -157,6 +157,68 @@ export interface ServiceDecisionBlock {
|
||||
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 {
|
||||
hero: {
|
||||
eyebrow: string;
|
||||
@@ -202,6 +264,7 @@ export interface ServicePageContent {
|
||||
};
|
||||
testimonialsHeading: string;
|
||||
booking: BookingContent;
|
||||
clarity?: ServiceClarityContent;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
|
||||
+26
-9
@@ -7,13 +7,24 @@
|
||||
// at /variants/homepage-original). Both are pure view components; all data
|
||||
// loading, SEO head, and structured data stay here.
|
||||
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';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
|
||||
// Pricing plans keyed by service URL, so the OfferCatalog can advertise the
|
||||
// same accurate "from–to" 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) {
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return value;
|
||||
@@ -63,14 +74,20 @@
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'Dog Walking Services',
|
||||
itemListElement: data.content.services.map((service) => ({
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: service.title,
|
||||
url: `${siteUrl}${service.href}`
|
||||
}
|
||||
}))
|
||||
itemListElement: data.content.services.map((service) => {
|
||||
const offers = buildAggregateOffer(
|
||||
service.href ? servicePlansByHref[service.href] ?? [] : []
|
||||
);
|
||||
return {
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: service.title,
|
||||
url: `${siteUrl}${service.href}`,
|
||||
...(offers ? { offers } : {})
|
||||
}
|
||||
};
|
||||
})
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
|
||||
@@ -6,17 +6,22 @@
|
||||
import BookingPage from '$lib/components/pages/BookingPage.svelte';
|
||||
import LegalPage from '$lib/components/pages/LegalPage.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 { 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 { ourPricingContent } from '$lib/content/our-pricing';
|
||||
import { packWalksContent } from '$lib/content/pack-walks';
|
||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
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';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -25,35 +30,13 @@
|
||||
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
|
||||
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: {
|
||||
name: string;
|
||||
description: string;
|
||||
serviceType: string;
|
||||
image: string;
|
||||
url: string;
|
||||
offers: ReturnType<typeof aggregateOfferSchema>;
|
||||
offers: ReturnType<typeof buildAggregateOffer>;
|
||||
}) {
|
||||
const node: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -108,7 +91,7 @@
|
||||
serviceType: 'Pack walks for small and medium dogs',
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
||||
offers: buildAggregateOffer(packWalksContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
@@ -126,7 +109,7 @@
|
||||
serviceType: 'One-on-one dog walking',
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
||||
offers: buildAggregateOffer(dogWalkingContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
@@ -144,7 +127,7 @@
|
||||
serviceType: 'In-home puppy visits',
|
||||
image: absoluteUrl(seoImage),
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
||||
offers: buildAggregateOffer(puppyVisitsContent.pricing.plans)
|
||||
}),
|
||||
buildBreadcrumb([
|
||||
{ name: 'Home', url: siteUrl },
|
||||
@@ -228,13 +211,13 @@
|
||||
<Header navigation={data.content.navigation} />
|
||||
|
||||
{#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'}
|
||||
<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'}
|
||||
<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'}
|
||||
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
||||
<PricingClarityPage />
|
||||
{:else if data.slug === 'about'}
|
||||
<AboutPage pageContent={aboutPageContent} />
|
||||
{:else if data.slug === 'testimonials'}
|
||||
|
||||
@@ -20,7 +20,10 @@ function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,34 +10,66 @@ describe('home page route', () => {
|
||||
data: createHomepageRouteData()
|
||||
});
|
||||
|
||||
const hero = document.getElementById('hero');
|
||||
const intro = document.getElementById('intro');
|
||||
const values = document.getElementById('values');
|
||||
// HomeClarity is the live homepage. Its hero is purpose-built copy rather
|
||||
// than the generic content.hero fields, leading with the specialism.
|
||||
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 howItWorks = document.getElementById('how-it-works');
|
||||
const values = document.getElementById('values');
|
||||
const testimonials = document.getElementById('testimonials');
|
||||
const founderStory = document.getElementById('promise');
|
||||
const info = document.getElementById('info');
|
||||
const booking = document.getElementById('newlead');
|
||||
|
||||
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0);
|
||||
const introHeadlineText = homepageContent.intro.text.replace(/\.$/, '');
|
||||
const introHeading = document.querySelector('#intro .intro-headline');
|
||||
expect(introHeading?.textContent?.replace(/\s+/g, ' ').trim()).toBe(introHeadlineText);
|
||||
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument();
|
||||
for (const section of [
|
||||
services,
|
||||
howItWorks,
|
||||
values,
|
||||
testimonials,
|
||||
founderStory,
|
||||
info,
|
||||
booking
|
||||
]) {
|
||||
expect(section).not.toBeNull();
|
||||
}
|
||||
|
||||
expect(screen.getByText(homepageContent.howItWorks.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(values?.compareDocumentPosition(services as Node)).toBe(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(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);
|
||||
|
||||
expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(howItWorks?.compareDocumentPosition(values 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();
|
||||
|
||||
// SEO head: title, structured data, and the preloaded hero image.
|
||||
expect(document.title).toBe(homepageContent.seo.title);
|
||||
expect(document.head.innerHTML).toContain('FAQPage');
|
||||
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/goodwalk-auckland-happy-dog-hero.webp');
|
||||
expect(document.head.innerHTML).toContain('PetCareService');
|
||||
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" />
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the redesigned Tiny Gang pack walks page.
|
||||
* Single source of truth is TinyGangPage.svelte. noindex so it does not
|
||||
* compete with the live /pack-walks page in search.
|
||||
* Single source of truth is ServiceClarityPage.svelte (shared across all
|
||||
* 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
|
||||
* of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
|
||||
* To go live: in src/routes/[slug]/+page.svelte, render ServiceClarityPage
|
||||
* instead of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
|
||||
*/
|
||||
import Header from '$lib/components/ui/Header.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 { packWalksContent } from '$lib/content/pack-walks';
|
||||
</script>
|
||||
@@ -20,5 +21,5 @@
|
||||
</svelte:head>
|
||||
|
||||
<Header navigation={homepageContent.navigation} />
|
||||
<TinyGangPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
|
||||
<Footer footer={homepageContent.footer} />
|
||||
<ServiceClarityPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
|
||||
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||
|
||||
Reference in New Issue
Block a user