Compare commits
2
Commits
bfd96c677f
...
018024510b
| 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",
|
"priceDetails": "45",
|
||||||
"startDate": "2026-05-18",
|
"startDate": "2026-05-18",
|
||||||
"sentAt": "2026-05-19T22:59:53"
|
"sentAt": "2026-05-19T22:59:53"
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"status": "active",
|
||||||
|
"reason": "",
|
||||||
|
"changedAt": "2026-06-29T20:39:59",
|
||||||
|
"changedBy": "info@goodwalk.co.nz"
|
||||||
|
},
|
||||||
|
"lifecycleHistory": [
|
||||||
|
{
|
||||||
|
"status": "active",
|
||||||
|
"reason": "",
|
||||||
|
"changedAt": "2026-06-29T20:39:59",
|
||||||
|
"changedBy": "info@goodwalk.co.nz"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"dogs": [
|
||||||
|
{
|
||||||
|
"id": "primary",
|
||||||
|
"name": "Geoffrey",
|
||||||
|
"breed": "",
|
||||||
|
"birthDate": "",
|
||||||
|
"birthdayAutoSend": false,
|
||||||
|
"birthdayEmailLastSentAt": "",
|
||||||
|
"birthdayEmailLastSentYear": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dogBreed": "",
|
||||||
|
"dogAge": "",
|
||||||
|
"birthdayAutoSend": false,
|
||||||
|
"birthdayEmailLastSentAt": "",
|
||||||
|
"birthdayEmailLastSentYear": ""
|
||||||
},
|
},
|
||||||
"natalie@desseinparke.com": {
|
"natalie@desseinparke.com": {
|
||||||
"fullName": "Natalie Parke",
|
"fullName": "Natalie Parke",
|
||||||
|
|||||||
@@ -207,12 +207,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"27"
|
"27"
|
||||||
],
|
],
|
||||||
@@ -432,12 +432,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"45"
|
"45"
|
||||||
],
|
],
|
||||||
@@ -657,12 +657,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"95"
|
"95"
|
||||||
],
|
],
|
||||||
@@ -882,12 +882,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"103"
|
"103"
|
||||||
],
|
],
|
||||||
@@ -1107,12 +1107,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"39"
|
"39"
|
||||||
],
|
],
|
||||||
@@ -1332,12 +1332,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"41"
|
"41"
|
||||||
],
|
],
|
||||||
@@ -1557,12 +1557,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"36"
|
"36"
|
||||||
],
|
],
|
||||||
@@ -1782,12 +1782,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"83"
|
"83"
|
||||||
],
|
],
|
||||||
@@ -2007,12 +2007,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"76",
|
"76",
|
||||||
"49"
|
"49"
|
||||||
@@ -2233,12 +2233,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"99"
|
"99"
|
||||||
],
|
],
|
||||||
@@ -2458,12 +2458,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"84"
|
"84"
|
||||||
],
|
],
|
||||||
@@ -2683,12 +2683,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"51"
|
"51"
|
||||||
],
|
],
|
||||||
@@ -2908,12 +2908,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"87"
|
"87"
|
||||||
],
|
],
|
||||||
@@ -3134,12 +3134,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"88"
|
"88"
|
||||||
],
|
],
|
||||||
@@ -3359,12 +3359,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"34"
|
"34"
|
||||||
],
|
],
|
||||||
@@ -3584,12 +3584,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"61",
|
"61",
|
||||||
"47"
|
"47"
|
||||||
@@ -3811,12 +3811,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"57"
|
"57"
|
||||||
],
|
],
|
||||||
@@ -4036,12 +4036,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"32"
|
"32"
|
||||||
],
|
],
|
||||||
@@ -4261,12 +4261,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"22"
|
"22"
|
||||||
],
|
],
|
||||||
@@ -4486,12 +4486,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"64"
|
"64"
|
||||||
],
|
],
|
||||||
@@ -4711,12 +4711,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"74"
|
"74"
|
||||||
],
|
],
|
||||||
@@ -4936,12 +4936,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"56"
|
"56"
|
||||||
],
|
],
|
||||||
@@ -5161,12 +5161,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"116",
|
"116",
|
||||||
"93"
|
"93"
|
||||||
@@ -5387,12 +5387,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"59"
|
"59"
|
||||||
],
|
],
|
||||||
@@ -5612,12 +5612,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"30"
|
"30"
|
||||||
],
|
],
|
||||||
@@ -5837,12 +5837,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"53"
|
"53"
|
||||||
],
|
],
|
||||||
@@ -6062,12 +6062,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"107"
|
"107"
|
||||||
],
|
],
|
||||||
@@ -6287,12 +6287,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"24"
|
"24"
|
||||||
],
|
],
|
||||||
@@ -6512,12 +6512,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"43"
|
"43"
|
||||||
],
|
],
|
||||||
@@ -6737,12 +6737,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"97"
|
"97"
|
||||||
],
|
],
|
||||||
@@ -6962,12 +6962,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"90"
|
"90"
|
||||||
],
|
],
|
||||||
@@ -7188,12 +7188,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"105"
|
"105"
|
||||||
],
|
],
|
||||||
@@ -7413,12 +7413,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"101"
|
"101"
|
||||||
],
|
],
|
||||||
@@ -7638,12 +7638,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"78"
|
"78"
|
||||||
],
|
],
|
||||||
@@ -7664,12 +7664,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [],
|
"onboardingEntryIds": [],
|
||||||
"contractEntryIds": [
|
"contractEntryIds": [
|
||||||
"29"
|
"29"
|
||||||
@@ -7887,12 +7887,12 @@
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"reason": "Imported from legacy Gravity Forms data",
|
"reason": "Imported from legacy Gravity Forms data",
|
||||||
"changedAt": "2026-06-15T21:24:42.302Z",
|
"changedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"changedBy": "legacy-import"
|
"changedBy": "legacy-import"
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
"source": "gravity-forms-csv",
|
"source": "gravity-forms-csv",
|
||||||
"importedAt": "2026-06-15T21:24:42.302Z",
|
"importedAt": "2026-06-22T22:12:10.334Z",
|
||||||
"onboardingEntryIds": [
|
"onboardingEntryIds": [
|
||||||
"80"
|
"80"
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -61,11 +61,37 @@ class WelcomePackEmailRequest(BaseModel):
|
|||||||
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
|
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
|
||||||
# blank (resolved server-side so scheduled sends keep a sensible subject too).
|
# blank (resolved server-side so scheduled sends keep a sensible subject too).
|
||||||
subject: str = ""
|
subject: str = ""
|
||||||
serviceType: str
|
# Structured offer details rendered in the details table. Now fully optional —
|
||||||
priceDetails: str
|
# the owner can hide the table entirely (showDetails=False) and write a free
|
||||||
startDate: str
|
# email instead. Left blank, a row is simply omitted from the table.
|
||||||
|
serviceType: str = ""
|
||||||
|
priceDetails: str = ""
|
||||||
|
startDate: str = ""
|
||||||
|
# Free-text customisation. All blank => the email falls back to the original
|
||||||
|
# default copy, so existing callers keep working unchanged. `{first_name}`
|
||||||
|
# and `{dog_name}` tokens are substituted server-side.
|
||||||
|
heading: str = ""
|
||||||
|
intro: str = ""
|
||||||
|
outro: str = ""
|
||||||
|
tagLabel: str = ""
|
||||||
|
# Customisable button block. Defaults to "Complete onboarding" pointing at the
|
||||||
|
# client onboarding portal. Clearing either field hides the button.
|
||||||
|
ctaLabel: str = ""
|
||||||
|
ctaUrl: str = ""
|
||||||
|
# Whether to render the Service / Price / Start date table at all.
|
||||||
|
showDetails: bool = True
|
||||||
|
# Rich-text (WYSIWYG) message body. When present it replaces the
|
||||||
|
# heading/intro/outro copy above and is sanitised to an email-safe subset of
|
||||||
|
# HTML server-side. `includeButton` toggles the onboarding button beneath it.
|
||||||
|
bodyHtml: str = ""
|
||||||
|
includeButton: bool = True
|
||||||
preview: bool = False
|
preview: bool = False
|
||||||
previewRecipients: list[EmailStr] = []
|
previewRecipients: list[EmailStr] = []
|
||||||
|
# When sending for real (not a preview), also copy the owner so they keep a
|
||||||
|
# record of exactly what the client received. CC is visible to the client;
|
||||||
|
# BCC is hidden. Independent. Ignored on previews and scheduled sends.
|
||||||
|
ccOwner: bool = False
|
||||||
|
bccOwner: bool = False
|
||||||
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
|
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
|
||||||
# request, the welcome email is queued for reliable delivery at that time
|
# request, the welcome email is queued for reliable delivery at that time
|
||||||
# instead of being sent immediately.
|
# instead of being sent immediately.
|
||||||
@@ -83,12 +109,17 @@ class ScheduledEmailRescheduleRequest(BaseModel):
|
|||||||
|
|
||||||
class BirthdayEmailRequest(BaseModel):
|
class BirthdayEmailRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
dogId: str | None = None
|
||||||
|
# Owner-editable subject line. Blank falls back to the default
|
||||||
|
# "Happy birthday <dog>" subject.
|
||||||
|
subject: str = ""
|
||||||
preview: bool = False
|
preview: bool = False
|
||||||
previewRecipients: list[EmailStr] = []
|
previewRecipients: list[EmailStr] = []
|
||||||
|
|
||||||
|
|
||||||
class BirthdayAutoSendRequest(BaseModel):
|
class BirthdayAutoSendRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
dogId: str | None = None
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +141,8 @@ class ClientProfileUpdate(BaseModel):
|
|||||||
phone: str = ""
|
phone: str = ""
|
||||||
address: str = ""
|
address: str = ""
|
||||||
dogName: str
|
dogName: str
|
||||||
|
dogBreed: str = ""
|
||||||
|
dogAge: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ResetOnboardingRequest(BaseModel):
|
class ResetOnboardingRequest(BaseModel):
|
||||||
@@ -120,6 +153,12 @@ class ResetOnboardingRequest(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteClientRequest(BaseModel):
|
||||||
|
"""Owner-initiated permanent deletion of a client and their access."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class NewClientRequest(BaseModel):
|
class NewClientRequest(BaseModel):
|
||||||
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
|
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
|
||||||
that never came through the public enquiry form). Registers the email so
|
that never came through the public enquiry form). Registers the email so
|
||||||
@@ -131,14 +170,27 @@ class NewClientRequest(BaseModel):
|
|||||||
address: str = ""
|
address: str = ""
|
||||||
dogName: str = ""
|
dogName: str = ""
|
||||||
dogBreed: str = ""
|
dogBreed: str = ""
|
||||||
|
dogAge: str = ""
|
||||||
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
|
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
|
||||||
source: str = ""
|
source: str = ""
|
||||||
|
# The date the client joined Goodwalk (ISO yyyy-mm-dd). Owner-supplied, since
|
||||||
|
# only the owner knows it. Used to surface joining anniversaries.
|
||||||
|
joiningDate: str = ""
|
||||||
# When true (and the MYOB integration is configured), also create the client
|
# When true (and the MYOB integration is configured), also create the client
|
||||||
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
|
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
|
||||||
# local client from being created.
|
# local client from being created.
|
||||||
createInMyob: bool = False
|
createInMyob: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ClientDogUpsertRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
dogId: str | None = None
|
||||||
|
dogName: str
|
||||||
|
dogBreed: str = ""
|
||||||
|
dogAge: str = ""
|
||||||
|
birthdayAutoSend: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ContractSubmission(BaseSubmission):
|
class ContractSubmission(BaseSubmission):
|
||||||
address: str
|
address: str
|
||||||
dogName: str
|
dogName: str
|
||||||
|
|||||||
+932
-81
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 { surface } = resolveSurface(event.url, event.cookies);
|
||||||
const path = event.url.pathname;
|
const path = event.url.pathname;
|
||||||
|
|
||||||
|
// Design variant previews are not for production. The whole /variants
|
||||||
|
// namespace returns 500 off-localhost so unfinished work never leaks to the
|
||||||
|
// public site. Localhost (the dev server, or a local prod build) is allowed.
|
||||||
|
const onLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(event.url.hostname);
|
||||||
|
if (!dev && !onLocalhost && (path === '/variants' || path.startsWith('/variants/'))) {
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
// Sticky A/B assignment, marketing surface only — no point polluting the
|
// Sticky A/B assignment, marketing surface only — no point polluting the
|
||||||
// owner/clients hosts with marketing-experiment cookies, and it skews
|
// owner/clients hosts with marketing-experiment cookies, and it skews
|
||||||
// exposure counts when staff hit the public site from the dashboard.
|
// exposure counts when staff hit the public site from the dashboard.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let disabled = false;
|
||||||
|
export let placeholder = 'Write your message…';
|
||||||
|
// Optional one-tap insert chips, e.g. the client's first name / dog name.
|
||||||
|
export let tokens: { label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ input: string }>();
|
||||||
|
|
||||||
|
let editor: HTMLDivElement;
|
||||||
|
let focused = false;
|
||||||
|
// Remember the HTML we last pushed into the DOM so external `value` changes
|
||||||
|
// (presets, reopening the dialog) update the editor without fighting the
|
||||||
|
// caret while the owner is actively typing.
|
||||||
|
let lastSet = '';
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const html = editor?.innerHTML ?? '';
|
||||||
|
value = html;
|
||||||
|
lastSet = html;
|
||||||
|
dispatch('input', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (editor && value !== lastSet && !focused) {
|
||||||
|
editor.innerHTML = value || '';
|
||||||
|
lastSet = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (editor) {
|
||||||
|
editor.innerHTML = value || '';
|
||||||
|
lastSet = value || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function exec(command: string, arg?: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
document.execCommand(command, false, arg);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn the selected line(s) into a heading (or back to a normal paragraph).
|
||||||
|
// The bracketed tag form is the one browsers accept consistently for formatBlock.
|
||||||
|
function setBlock(tag: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
document.execCommand('formatBlock', false, `<${tag}>`);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLink() {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
const url = window.prompt('Link address (e.g. https://goodwalk.co.nz)');
|
||||||
|
if (!url) return;
|
||||||
|
const trimmed = url.trim();
|
||||||
|
const href = /^(https?:|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
document.execCommand('createLink', false, href);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertToken(token: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
editor?.focus();
|
||||||
|
document.execCommand('insertText', false, token);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text view of the content drives the placeholder + lets the parent
|
||||||
|
// validate that the message isn't empty.
|
||||||
|
$: plainText = (value || '').replace(/<[^>]*>/g, '').replace(/ /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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
<OnboardingFooter on:logout={handleLogout} />
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -27,10 +27,40 @@
|
|||||||
export let content: HomePageContent;
|
export let content: HomePageContent;
|
||||||
export let ab: AbContext | undefined = undefined;
|
export let ab: AbContext | undefined = undefined;
|
||||||
export let pagePath = '/';
|
export let pagePath = '/';
|
||||||
|
// The booking form is swappable so variants can preview an alternate Meet &
|
||||||
|
// Greet form on the same homepage design. Defaults to the live wizard, so the
|
||||||
|
// homepage route renders unchanged. See /variants/meet-greet.
|
||||||
|
export let BookingComponent: typeof BookingWizard = BookingWizard;
|
||||||
|
|
||||||
|
const meetGreetVariantPath = '/variants/meet-greet';
|
||||||
|
$: isMeetGreetVariant = pagePath === meetGreetVariantPath;
|
||||||
const reviewCta = content.intro.reviewCta;
|
const reviewCta = content.intro.reviewCta;
|
||||||
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||||
const stars = Array.from({ length: 5 });
|
const stars = Array.from({ length: 5 });
|
||||||
|
$: heroLead = isMeetGreetVariant
|
||||||
|
? 'Small group walks, solo support, and puppy visits for Auckland dogs who do better with a calm, consistent routine.'
|
||||||
|
: 'Real adventures with the Tiny Gang, in small, matched groups across Auckland. Same walker every time, so your dog comes home happy, tired, and ready to settle.';
|
||||||
|
$: founderQuote = isMeetGreetVariant
|
||||||
|
? "Small groups, careful matching, and the same walker each time. That's how a walk feels safe, social, and worth looking forward to."
|
||||||
|
: "Every dog gets a walk built around them, not a one-size pack: the right small group, a walker who knows their name and their quirks, and adventures that grow with their confidence. That's how a walk becomes the best part of a dog's day.";
|
||||||
|
$: servicesHeading = isMeetGreetVariant
|
||||||
|
? 'Choose the setup that fits your dog.'
|
||||||
|
: undefined;
|
||||||
|
$: servicesIntro = isMeetGreetVariant
|
||||||
|
? 'Pack walks for social dogs, solo walks for dogs who need more space, and puppy visits for the early routine.'
|
||||||
|
: undefined;
|
||||||
|
$: howItWorksContent = isMeetGreetVariant
|
||||||
|
? {
|
||||||
|
...content.howItWorks,
|
||||||
|
intro: 'Start with a free Meet & Greet. We assess the fit, then build the right routine for your dog.'
|
||||||
|
}
|
||||||
|
: content.howItWorks;
|
||||||
|
$: bookingContent = isMeetGreetVariant
|
||||||
|
? {
|
||||||
|
...content.booking,
|
||||||
|
subtitle: 'Tell us a little about your dog. We reply within 24 hours to arrange your free Meet & Greet.'
|
||||||
|
}
|
||||||
|
: content.booking;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header navigation={content.navigation} />
|
<Header navigation={content.navigation} />
|
||||||
@@ -40,14 +70,10 @@
|
|||||||
<div class="chero-copy">
|
<div class="chero-copy">
|
||||||
<p class="chero-eyebrow">Goodwalk · Auckland dog walking</p>
|
<p class="chero-eyebrow">Goodwalk · Auckland dog walking</p>
|
||||||
<h1 class="chero-title">
|
<h1 class="chero-title">
|
||||||
<span class="chero-title-lead">All we do is</span>
|
<span class="chero-title-lead">Specialist walks for small & medium dogs</span>
|
||||||
<span class="chero-title-main">small & medium dogs</span>
|
<span class="chero-title-main">Meet the Tiny Gang.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="chero-lead">
|
<p class="chero-lead">{heroLead}</p>
|
||||||
Real adventures with the Tiny Gang, in small, matched groups across
|
|
||||||
Auckland. Same walker every time, so your dog comes home happy, tired,
|
|
||||||
and ready to settle.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chero-media">
|
<div class="chero-media">
|
||||||
@@ -107,10 +133,7 @@
|
|||||||
<figure class="cfounder-quote">
|
<figure class="cfounder-quote">
|
||||||
<blockquote class="cfounder-blockquote">
|
<blockquote class="cfounder-blockquote">
|
||||||
<p class="cfounder-text">
|
<p class="cfounder-text">
|
||||||
Every dog gets a walk built around them, not a one-size pack: the right
|
{founderQuote}
|
||||||
small group, a walker who knows their name and their quirks, and
|
|
||||||
adventures that grow with their confidence. That's how a walk becomes
|
|
||||||
the best part of a dog's day.
|
|
||||||
</p>
|
</p>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<figcaption class="cfounder-attr">
|
<figcaption class="cfounder-attr">
|
||||||
@@ -133,19 +156,24 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Offer first: what can I actually book, and roughly what does it cost? -->
|
<!-- Offer first: what can I actually book, and roughly what does it cost? -->
|
||||||
<ServicesSection services={content.services} />
|
<ServicesSection services={content.services} heading={servicesHeading} intro={servicesIntro} />
|
||||||
|
|
||||||
<!-- Then the process: what happens after I enquire? Lowers the unknown. -->
|
<!-- Then the process: what happens after I enquire? Lowers the unknown. -->
|
||||||
<HowItWorksSection content={content.howItWorks} />
|
<HowItWorksSection content={howItWorksContent} />
|
||||||
|
|
||||||
<!-- Only now the reasons to choose us. -->
|
{#if isMeetGreetVariant}
|
||||||
<ValuesSection values={content.values} />
|
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||||
|
<InfoSection info={content.info} />
|
||||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||||
<FounderStorySection founderStory={content.founderStory} />
|
{:else}
|
||||||
<InfoSection info={content.info} />
|
<!-- Only now the reasons to choose us. -->
|
||||||
<BookingWizard booking={content.booking} {pagePath} {ab} />
|
<ValuesSection values={content.values} />
|
||||||
<InstagramSection instagram={content.instagram} />
|
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||||
|
<FounderStorySection founderStory={content.founderStory} />
|
||||||
|
<InfoSection info={content.info} />
|
||||||
|
<svelte:component this={BookingComponent} booking={bookingContent} {pagePath} {ab} />
|
||||||
|
<InstagramSection instagram={content.instagram} />
|
||||||
|
{/if}
|
||||||
<Footer footer={content.footer} />
|
<Footer footer={content.footer} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,737 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Services-focus homepage variant.
|
||||||
|
*
|
||||||
|
* Same design language as HomeClarity (green hero, yellow CTAs, the shared
|
||||||
|
* ServicesSection / ValuesSection / TestimonialsSection), but every section is
|
||||||
|
* pointed at one job: get the visitor to click through into a service page.
|
||||||
|
*
|
||||||
|
* The site gets traffic but few leads, so this variant removes the big-commitment
|
||||||
|
* booking form from the homepage and lowers the first ask to "pick the walk that
|
||||||
|
* fits your dog". The three services are offered as quick-picks in the hero
|
||||||
|
* itself, repeated as the main ServicesSection cards, reinforced with proof, and
|
||||||
|
* offered a final time in a closing band. A view component only: the route that
|
||||||
|
* renders it owns data loading, SEO head, and structured data.
|
||||||
|
*
|
||||||
|
* Preview route: /variants/services-first (noindex).
|
||||||
|
*/
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
import ServicesSection from '$lib/components/sections/ServicesSection.svelte';
|
||||||
|
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||||
|
import type { HomePageContent } from '$lib/types';
|
||||||
|
|
||||||
|
export let content: HomePageContent;
|
||||||
|
export let pagePath = '/variants/services-first';
|
||||||
|
|
||||||
|
const reviewCta = content.intro.reviewCta;
|
||||||
|
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||||
|
const stars = Array.from({ length: 5 });
|
||||||
|
|
||||||
|
// The assurance bar replaces the old grid of value cards: four plain facts,
|
||||||
|
// stated once. Each is a reason to trust the click, not a feature to skim.
|
||||||
|
const assurances = [
|
||||||
|
{ icon: 'fas fa-user-check', label: 'Same walker, every time' },
|
||||||
|
{ icon: 'fas fa-users', label: 'Small, matched groups' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Pet first-aid certified & insured' },
|
||||||
|
{ icon: 'fas fa-star', label: '5.0 from 30+ owners' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// The hero quick-picks and the closing band both draw from the same services
|
||||||
|
// data the main ServicesSection uses, so the click targets never drift apart.
|
||||||
|
$: services = content.services;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header navigation={content.navigation} />
|
||||||
|
|
||||||
|
<section class="sfhero" data-track-location="hero" aria-label="Goodwalk dog walking">
|
||||||
|
<div class="sfhero-inner">
|
||||||
|
<div class="sfhero-copy">
|
||||||
|
<p class="sfhero-eyebrow">Professional dog walkers</p>
|
||||||
|
<h1 class="sfhero-title">
|
||||||
|
<span class="sfhero-title-main">Come home to a calmer dog.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="sfhero-lead">
|
||||||
|
Small, matched groups for small & 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.
|
// Computed legacy field for downstream consumers expecting a single full name.
|
||||||
$: fullName = `${ownerFirstName.trim()} ${ownerLastName.trim()}`.trim();
|
$: fullName = `${ownerFirstName.trim()} ${ownerLastName.trim()}`.trim();
|
||||||
|
|
||||||
$: displayName = ownerFirstName.trim() || (userEmail ? userEmail.split('@')[0] : '');
|
|
||||||
|
|
||||||
$: accountInitials = (() => {
|
|
||||||
const first = ownerFirstName.trim();
|
|
||||||
const last = ownerLastName.trim();
|
|
||||||
if (first || last) {
|
|
||||||
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase() || first.charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
return (userEmail.charAt(0) || '?').toUpperCase();
|
|
||||||
})();
|
|
||||||
|
|
||||||
let formStartedAt = 0;
|
let formStartedAt = 0;
|
||||||
let visitStartedAt = 0;
|
let visitStartedAt = 0;
|
||||||
let pageEnteredAt = 0;
|
let pageEnteredAt = 0;
|
||||||
@@ -703,12 +692,10 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-topbar-right">
|
<div class="onboarding-topbar-right">
|
||||||
{#if isAuthenticated && displayName}
|
{#if isAuthenticated}
|
||||||
<div class="onboarding-account" aria-label="Account">
|
<div class="onboarding-account" aria-label="Account">
|
||||||
<span class="onboarding-account-avatar" aria-hidden="true">{accountInitials}</span>
|
<span class="onboarding-account-avatar" aria-hidden="true">
|
||||||
<span class="onboarding-account-text">
|
<Icon name="fas fa-user" />
|
||||||
<span class="onboarding-account-prefix">Logged in as</span>
|
|
||||||
<span class="onboarding-account-name">{displayName}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -749,8 +736,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
<h1>
|
<h1>
|
||||||
{#if onboardingCompleted}
|
{#if onboardingCompleted}
|
||||||
Your submitted onboarding details.
|
Your submitted onboarding details.
|
||||||
{:else if displayName}
|
|
||||||
Hi {displayName}, let's get your dog onboarded.
|
|
||||||
{:else}
|
{:else}
|
||||||
A few details so we can care for your dog properly.
|
A few details so we can care for your dog properly.
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1399,7 +1384,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
<OnboardingFooter on:logout={handleLogout} />
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1504,33 +1489,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.12);
|
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-account-text {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1.15;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-prefix {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-name {
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.005em;
|
|
||||||
color: #fff;
|
|
||||||
max-width: 22ch;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-logout {
|
.onboarding-account-logout {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1575,15 +1533,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-account-prefix {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-name {
|
|
||||||
font-size: 12px;
|
|
||||||
max-width: 12ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-account-logout-label {
|
.onboarding-account-logout-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* v5 clarity treatment for the pricing page. Keeps each service prominent,
|
||||||
|
* like the live PricingPage, but strips the message right back: one photo,
|
||||||
|
* the name, a single outcome line, the "from" price, then the even plan
|
||||||
|
* cards. No chooser grid, lede, assurance, fit lists, or meta rows.
|
||||||
|
*
|
||||||
|
* View component only. Preview at /variants/our-pricing.
|
||||||
|
*/
|
||||||
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
import { reveal } from '$lib/actions/reveal';
|
||||||
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
|
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||||
|
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||||
|
import { decoratePlans } from '$lib/utils/pricing';
|
||||||
|
import { ourPricingContent } from '$lib/content/our-pricing';
|
||||||
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
|
import type { PricingPageSection, ServicePageContent } from '$lib/types';
|
||||||
|
|
||||||
|
type Block = { section: PricingPageSection; service: ServicePageContent; href: string };
|
||||||
|
|
||||||
|
// Pair each pricing section with its service (for the distilled per-plan copy)
|
||||||
|
// and its variant page. Order matches ourPricingContent.sections.
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{ section: ourPricingContent.sections[0], service: packWalksContent, href: '/pack-walks' },
|
||||||
|
{ section: ourPricingContent.sections[1], service: dogWalkingContent, href: '/dog-walking' },
|
||||||
|
{ section: ourPricingContent.sections[2], service: puppyVisitsContent, href: '/puppy-visits' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function fromValue(section: PricingPageSection): string {
|
||||||
|
return section.meta?.find((m) => m.label === 'From')?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function plansFor(block: Block) {
|
||||||
|
const lines = block.service.clarity?.planLines ?? {};
|
||||||
|
return decoratePlans(
|
||||||
|
block.section.plans.map((plan) => ({
|
||||||
|
...plan,
|
||||||
|
features: lines[plan.title] ?? plan.features
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At-a-glance comparison. Distilled, accurate facts per service; the "from"
|
||||||
|
// price is read from content (fromFor) so it stays single-sourced with the
|
||||||
|
// cards below and the pricing in each service page's schema.
|
||||||
|
const comparisonRows = [
|
||||||
|
{
|
||||||
|
href: '/pack-walks',
|
||||||
|
service: 'Tiny Gang Pack Walks',
|
||||||
|
bestFor: 'Sociable small & medium dogs',
|
||||||
|
format: 'Small group of 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
+221
-109
@@ -1,16 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/*
|
/*
|
||||||
* Tiny Gang pack walks page — clarity redesign.
|
* Shared v5 "clarity" service page. One design system, one message, said
|
||||||
|
* once in order: what it is -> what's different -> how it works -> fit ->
|
||||||
|
* proof -> price -> FAQ -> where we cover -> book.
|
||||||
*
|
*
|
||||||
* One design system, one message. Replaces the multi-card / multi-gradient
|
* Drives all three service pages (Tiny Gang, Solo Walks, Puppy Visits) from
|
||||||
* ServiceLandingPage treatment (for this page) with a single green hero +
|
* content. Page-specific copy lives in each content file's `clarity` block;
|
||||||
* light body that shares the homepage's visual language. The message is said
|
* see docs/v5.md. View component only: the route owns Header/Footer/SEO.
|
||||||
* once, in order: what it is -> what's different -> how joining works -> fit
|
|
||||||
* -> proof -> price -> FAQ -> book.
|
|
||||||
*
|
|
||||||
* View component only: the route owns Header/Footer, SEO, and data.
|
|
||||||
* Preview at /variants/tiny-gang. To go live, render this instead of
|
|
||||||
* ServiceLandingPage for pack-walks in src/routes/[slug]/+page.svelte.
|
|
||||||
*/
|
*/
|
||||||
import Icon from '$lib/components/ui/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||||
@@ -19,82 +15,19 @@
|
|||||||
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||||
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||||
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||||
|
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||||
import { decoratePlans } from '$lib/utils/pricing';
|
import { decoratePlans } from '$lib/utils/pricing';
|
||||||
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||||
|
|
||||||
export let content: SiteSharedContent;
|
export let content: SiteSharedContent;
|
||||||
export let pageContent: ServicePageContent;
|
export let pageContent: ServicePageContent;
|
||||||
export let currentPath = '/pack-walks';
|
export let currentPath = '/';
|
||||||
|
|
||||||
// What makes Tiny Gang different. Said once here, not spread across a
|
|
||||||
// 3-point highlight and a 6-card benefit grid that repeated each other.
|
|
||||||
const traits = [
|
|
||||||
{
|
|
||||||
icon: 'fas fa-users',
|
|
||||||
title: 'Carefully matched groups',
|
|
||||||
body: '4 to 8 dogs, grouped by temperament, energy, and play style. Never a random pack.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'fas fa-shield-dog',
|
|
||||||
title: 'Small & medium dogs only',
|
|
||||||
body: 'Pace, group size, and play style are all built for little dogs, so yours is never the odd one out.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'fas fa-user-check',
|
|
||||||
title: 'The same walker, every week',
|
|
||||||
body: 'One familiar face at the door and on the walk. Your dog is known, not handed around.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'fas fa-eye',
|
|
||||||
title: 'Eyes on every dog',
|
|
||||||
body: 'Small groups mean we notice the little things early, from recall to a quiet day.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// How joining works. The process, made explicit instead of buried in a
|
|
||||||
// paragraph and an FAQ.
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
title: 'Free Meet & Greet',
|
|
||||||
body: 'We come to your home, meet your dog, and talk honestly about whether the pack suits them.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Two assessment walks',
|
|
||||||
body: 'Your dog settles in gently while we confirm the right group, pace, and handling.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Your weekly slot',
|
|
||||||
body: 'A regular spot with the same little crew and the same walker, week in, week out.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Distilled pricing message. The title already says the routine and the
|
|
||||||
// price says the cost, so each card keeps only the one or two lines that
|
|
||||||
// actually differ. The universal "free pickup" promise is stated once under
|
|
||||||
// the grid instead of repeated on every card.
|
|
||||||
const planLines: Record<string, string[]> = {
|
|
||||||
'1 Walk Per Week': [
|
|
||||||
'A calm weekly outing',
|
|
||||||
'Time to sniff, play, and unwind',
|
|
||||||
'Great for dogs just starting out'
|
|
||||||
],
|
|
||||||
'2-3 Walks Per Week': [
|
|
||||||
'Steady exercise and real social time',
|
|
||||||
'A settled mid-week rhythm',
|
|
||||||
'Our balanced routine'
|
|
||||||
],
|
|
||||||
'4-5 Walks Per Week': [
|
|
||||||
'Best value per walk',
|
|
||||||
'Maximum routine and structure',
|
|
||||||
'For high-energy, social dogs'
|
|
||||||
],
|
|
||||||
'Casual Pack Walk': [
|
|
||||||
'No weekly commitment',
|
|
||||||
'Book only the weeks you need',
|
|
||||||
'For dogs already in the pack'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
|
$: clarity = pageContent.clarity;
|
||||||
|
$: traits = clarity?.traits ?? [];
|
||||||
|
$: steps = clarity?.steps ?? [];
|
||||||
|
$: planLines = clarity?.planLines ?? {};
|
||||||
|
$: priceNotes = clarity?.priceNotes ?? [];
|
||||||
$: pricingPlans = decoratePlans(
|
$: pricingPlans = decoratePlans(
|
||||||
pageContent.pricing.plans.map((plan) => ({
|
pageContent.pricing.plans.map((plan) => ({
|
||||||
...plan,
|
...plan,
|
||||||
@@ -105,8 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="tg">
|
<main class="tg">
|
||||||
<!-- Hero: the shared service-page hero (green copy left, photo bleeds
|
<!-- Hero: shared service-page hero (green copy left, photo bleeds right). -->
|
||||||
right), identical to /dog-walking and /puppy-visits. -->
|
|
||||||
<ServiceHero
|
<ServiceHero
|
||||||
eyebrow={pageContent.hero.eyebrow}
|
eyebrow={pageContent.hero.eyebrow}
|
||||||
title={pageContent.hero.title}
|
title={pageContent.hero.title}
|
||||||
@@ -117,12 +49,26 @@
|
|||||||
cta={pageContent.hero.cta}
|
cta={pageContent.hero.cta}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- What it is: a plain, self-contained definition that opens the page. -->
|
||||||
|
{#if clarity?.definition}
|
||||||
|
<section class="tg-section tg-section-def" use:reveal={{ distance: 0 }} aria-labelledby="tg-def-heading">
|
||||||
|
<div class="tg-inner tg-inner-narrow">
|
||||||
|
<div class="tg-head">
|
||||||
|
<p class="tg-eyebrow">{clarity.definition.eyebrow ?? 'In plain terms'}</p>
|
||||||
|
<h2 id="tg-def-heading" class="tg-h2">{clarity.definition.heading}</h2>
|
||||||
|
</div>
|
||||||
|
<p class="tg-def-body">{clarity.definition.body}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- What makes it different (said once) -->
|
<!-- What makes it different (said once) -->
|
||||||
|
{#if traits.length}
|
||||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
||||||
<div class="tg-inner">
|
<div class="tg-inner">
|
||||||
<div class="tg-head">
|
<div class="tg-head">
|
||||||
<p class="tg-eyebrow">What makes Tiny Gang different</p>
|
<p class="tg-eyebrow">{clarity?.diffEyebrow ?? 'What makes it different'}</p>
|
||||||
<h2 id="tg-diff-heading" class="tg-h2">Not a big anonymous pack. A small, matched crew.</h2>
|
<h2 id="tg-diff-heading" class="tg-h2">{clarity?.diffHeading ?? ''}</h2>
|
||||||
</div>
|
</div>
|
||||||
<ul class="tg-traits">
|
<ul class="tg-traits">
|
||||||
{#each traits as trait}
|
{#each traits as trait}
|
||||||
@@ -137,13 +83,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- How joining works (the process, explicit) -->
|
<!-- 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">
|
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
|
||||||
<div class="tg-inner">
|
<div class="tg-inner">
|
||||||
<div class="tg-head">
|
<div class="tg-head">
|
||||||
<p class="tg-eyebrow">How joining works</p>
|
<p class="tg-eyebrow">{clarity?.stepsEyebrow ?? 'How it works'}</p>
|
||||||
<h2 id="tg-steps-heading" class="tg-h2">Three simple steps, no pressure.</h2>
|
<h2 id="tg-steps-heading" class="tg-h2">{clarity?.stepsHeading ?? ''}</h2>
|
||||||
</div>
|
</div>
|
||||||
<ol class="tg-steps">
|
<ol class="tg-steps">
|
||||||
{#each steps as step, i}
|
{#each steps as step, i}
|
||||||
@@ -156,14 +104,31 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Image beat: one deliberate photo that breaks the longest text run. -->
|
||||||
|
{#if clarity?.imageBand}
|
||||||
|
<div class="tg-band-wrap" use:reveal={{ distance: 0 }}>
|
||||||
|
<figure class="tg-band">
|
||||||
|
<img
|
||||||
|
class="tg-band-img"
|
||||||
|
src={clarity.imageBand.imageUrl}
|
||||||
|
alt={clarity.imageBand.imageAlt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<figcaption class="tg-band-cap">{clarity.imageBand.caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Is it right for your dog? -->
|
<!-- Is it right for your dog? -->
|
||||||
{#if pageContent.decision}
|
{#if pageContent.decision}
|
||||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
|
||||||
<div class="tg-inner">
|
<div class="tg-inner">
|
||||||
<div class="tg-head">
|
<div class="tg-head">
|
||||||
<p class="tg-eyebrow">Honest fit check</p>
|
<p class="tg-eyebrow">{clarity?.fitEyebrow ?? 'Honest fit check'}</p>
|
||||||
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is Tiny Gang right for your dog?'}</h2>
|
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is this right for your dog?'}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-fit">
|
<div class="tg-fit">
|
||||||
<div class="tg-fit-col">
|
<div class="tg-fit-col">
|
||||||
@@ -178,7 +143,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="tg-fit-col">
|
<div class="tg-fit-col">
|
||||||
<h3>{pageContent.decision.notFitTitle ?? 'Better on a solo walk if your dog:'}</h3>
|
<h3>{pageContent.decision.notFitTitle ?? 'Better with another option if your dog:'}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{#each pageContent.decision.notFitItems as item}
|
{#each pageContent.decision.notFitItems as item}
|
||||||
<li>
|
<li>
|
||||||
@@ -196,9 +161,9 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Proof: owners love it -->
|
<!-- Proof -->
|
||||||
<TestimonialsSection
|
<TestimonialsSection
|
||||||
heading="Owners love the Tiny Gang"
|
heading={clarity?.testimonialsHeading ?? pageContent.testimonialsHeading}
|
||||||
testimonials={content.testimonials}
|
testimonials={content.testimonials}
|
||||||
seedKey={currentPath}
|
seedKey={currentPath}
|
||||||
/>
|
/>
|
||||||
@@ -207,28 +172,32 @@
|
|||||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
|
||||||
<div class="tg-inner tg-inner-wide">
|
<div class="tg-inner tg-inner-wide">
|
||||||
<div class="tg-head">
|
<div class="tg-head">
|
||||||
<p class="tg-eyebrow">Simple weekly pricing</p>
|
<p class="tg-eyebrow">{clarity?.pricingEyebrow ?? 'Simple pricing'}</p>
|
||||||
<h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2>
|
<h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2>
|
||||||
<p class="tg-price-tagline">The best investment you'll ever make is in your dog's happiness.</p>
|
{#if clarity?.pricingTagline}
|
||||||
|
<p class="tg-price-tagline">{clarity.pricingTagline}</p>
|
||||||
|
{/if}
|
||||||
{#if pageContent.pricing.intro}
|
{#if pageContent.pricing.intro}
|
||||||
<p class="tg-head-sub">{pageContent.pricing.intro}</p>
|
<p class="tg-head-sub">{pageContent.pricing.intro}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tg-plans">
|
<div class="tg-plans" class:tg-plans-three={pricingPlans.length === 3}>
|
||||||
{#each pricingPlans as plan}
|
{#each pricingPlans as plan}
|
||||||
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if pageContent.pricing.scarcityNote || priceNotes.length}
|
||||||
<div class="tg-price-notes">
|
<div class="tg-price-notes">
|
||||||
{#if pageContent.pricing.scarcityNote}
|
{#if pageContent.pricing.scarcityNote}
|
||||||
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="tg-note"><Icon name="fas fa-stopwatch" />60+ minute adventures.</p>
|
{#each priceNotes as note}
|
||||||
<p class="tg-note"><Icon name="fas fa-car" />Free pickup & drop-off, every walk.</p>
|
<p class="tg-note"><Icon name={note.icon} />{note.label}</p>
|
||||||
<p class="tg-note"><Icon name="fas fa-shield-heart" />Starts with a free Meet & Greet.</p>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet & Greet</a>
|
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet & Greet</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,17 +205,32 @@
|
|||||||
|
|
||||||
<!-- FAQ -->
|
<!-- FAQ -->
|
||||||
{#if faqItems.length}
|
{#if faqItems.length}
|
||||||
<section class="tg-section" use:reveal={{ distance: 0 }}>
|
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-faq-heading">
|
||||||
<div class="tg-inner tg-inner-narrow">
|
<div class="tg-inner tg-inner-narrow">
|
||||||
<FaqSection
|
<div class="tg-head">
|
||||||
title={pageContent.faq?.title ?? 'Tiny Gang FAQs'}
|
<p class="tg-eyebrow">{clarity?.faqEyebrow ?? 'Common questions'}</p>
|
||||||
intro={pageContent.faq?.intro}
|
<h2 id="tg-faq-heading" class="tg-h2">{pageContent.faq?.title ?? 'FAQs'}</h2>
|
||||||
faqs={faqItems}
|
{#if pageContent.faq?.intro}
|
||||||
/>
|
<p class="tg-head-sub">{pageContent.faq.intro}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tg-faq">
|
||||||
|
<FaqSection title={pageContent.faq?.title ?? 'FAQs'} faqs={faqItems} showHeading={false} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Service area: a coverage field that works on every screen. -->
|
||||||
|
<div class="tg-section-area" use:reveal={{ distance: 0 }}>
|
||||||
|
<ServiceAreaCoverage
|
||||||
|
title={clarity?.areaTitle ?? 'Where we walk across Auckland Central'}
|
||||||
|
lead={clarity?.areaLead ??
|
||||||
|
'Free door-to-door pickup and drop-off across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes.'}
|
||||||
|
stats={clarity?.areaStats ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BookingWizard booking={pageContent.booking} pagePath={currentPath} />
|
<BookingWizard booking={pageContent.booking} pagePath={currentPath} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -280,7 +264,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Alternating surface for rhythm, not a new "system": just a tint. */
|
/* Alternating surface for rhythm, not a new "system": just a tint. */
|
||||||
.tg-section-steps {
|
.tg-section-steps,
|
||||||
|
.tg-section-area {
|
||||||
background: var(--surface-panel-cream);
|
background: var(--surface-panel-cream);
|
||||||
border-block: 1px solid var(--border-soft);
|
border-block: 1px solid var(--border-soft);
|
||||||
}
|
}
|
||||||
@@ -332,6 +317,21 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Definition lede: one calm, readable paragraph under the opening head ── */
|
||||||
|
.tg-section-def {
|
||||||
|
padding-bottom: clamp(8px, 2vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-def-body {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--text-heading-soft);
|
||||||
|
font-size: clamp(17px, 1.7vw, 19px);
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
.tg-price-tagline {
|
.tg-price-tagline {
|
||||||
margin: 14px auto 0;
|
margin: 14px auto 0;
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
@@ -503,6 +503,50 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Image beat: a contained cinematic band between sections ───────────── */
|
||||||
|
.tg-band-wrap {
|
||||||
|
max-width: var(--tg-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-inline: var(--space-container-x);
|
||||||
|
padding-block: clamp(8px, 2vw, 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-band {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
background: var(--surface-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-band-img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 7;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Caption rides on the photo: legible over the image, keyword-bearing copy
|
||||||
|
that reads as a moment, not a label. */
|
||||||
|
.tg-band-cap {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(28px, 5vw, 44px) clamp(18px, 4vw, 32px) clamp(16px, 2.4vw, 22px);
|
||||||
|
background: linear-gradient(to top, rgba(33, 48, 33, 0.78), rgba(33, 48, 33, 0));
|
||||||
|
color: rgba(255, 248, 230, 0.96);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(15px, 1.8vw, 19px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Pricing ───────────────────────────────────────────────────────── */
|
/* ── Pricing ───────────────────────────────────────────────────────── */
|
||||||
.tg-plans {
|
.tg-plans {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -510,6 +554,13 @@
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Three-plan pages (solo walks, puppy visits) centre nicely at three-up. */
|
||||||
|
.tg-plans-three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
max-width: 1040px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
|
/* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
|
||||||
PricingPlanCard and other pages keep their own sizing. */
|
PricingPlanCard and other pages keep their own sizing. */
|
||||||
.tg-plans :global(.plan-card) {
|
.tg-plans :global(.plan-card) {
|
||||||
@@ -523,7 +574,7 @@
|
|||||||
padding-top: 62px;
|
padding-top: 62px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Full-width CTA: never squished, and consistent across all four cards.
|
/* Full-width CTA: never squished, and consistent across all cards.
|
||||||
display:flex (block-level) overrides the component's inline-flex +
|
display:flex (block-level) overrides the component's inline-flex +
|
||||||
align-self:center, so the button always spans the full card width. */
|
align-self:center, so the button always spans the full card width. */
|
||||||
.tg-plans :global(.plan-card__cta) {
|
.tg-plans :global(.plan-card__cta) {
|
||||||
@@ -531,7 +582,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
padding-inline: 15px;
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The narrow 4-up cards can't fit "Book a Meet & Greet" plus the arrow on
|
||||||
|
one line, and the card clips overflow — so the label was chopped. Drop the
|
||||||
|
arrow here and let the label use the whole width on its own. */
|
||||||
|
.tg-plans :global(.plan-card__cta .icon) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-price-notes {
|
.tg-price-notes {
|
||||||
@@ -573,9 +631,58 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── FAQ: a calm ruled list, not a stack of cards ──────────────────────
|
||||||
|
The shared FaqSection renders each question as a bordered, shadowed white
|
||||||
|
card. On this off-white page that reads as visual noise. Here we strip the
|
||||||
|
card chrome to hairline dividers so the questions sit as one quiet column,
|
||||||
|
matching the page's restraint. Scoped via .tg-faq; every other page keeps
|
||||||
|
the card treatment. */
|
||||||
|
.tg-faq :global(.faq details) {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details[open]) {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-bleed rows: no card inset, generous vertical breathing room. */
|
||||||
|
.tg-faq :global(.faq summary) {
|
||||||
|
padding: 22px 4px;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover lifts the question to brand green instead of filling a card. */
|
||||||
|
.tg-faq :global(.faq summary:hover) {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details[open] summary) {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--gw-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-faq :global(.faq details p) {
|
||||||
|
padding: 0 44px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ────────────────────────────────────────────────────── */
|
/* ── Responsive ────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.tg-plans {
|
.tg-plans,
|
||||||
|
.tg-plans-three {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,7 +701,12 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-plans {
|
.tg-band-img {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-plans,
|
||||||
|
.tg-plans-three {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -114,7 +114,9 @@ describe('BookingSection', () => {
|
|||||||
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
||||||
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
// The success modal is code-split (dynamic import) and only mounts after
|
||||||
|
// submission resolves, so wait for it rather than asserting synchronously.
|
||||||
|
expect(await screen.findByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||||
@@ -167,7 +169,8 @@ describe('BookingSection', () => {
|
|||||||
journey: [window.location.pathname]
|
journey: [window.location.pathname]
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
// The success modal is code-split (dynamic import); wait for it to mount.
|
||||||
|
expect(await screen.findByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,9 @@
|
|||||||
export let faqs: FaqItem[];
|
export let faqs: FaqItem[];
|
||||||
export let emitSchema = true;
|
export let emitSchema = true;
|
||||||
export let variant: 'panel' | 'plain' = 'panel';
|
export let variant: 'panel' | 'plain' = 'panel';
|
||||||
|
// When the host page supplies its own heading (e.g. to match a page-wide
|
||||||
|
// section system), suppress the built-in one but keep the schema + list.
|
||||||
|
export let showHeading = true;
|
||||||
|
|
||||||
$: schemaJson = JSON.stringify({
|
$: schemaJson = JSON.stringify({
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
||||||
|
{#if showHeading}
|
||||||
<h2 class="faq-section-heading">
|
<h2 class="faq-section-heading">
|
||||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||||
{title}
|
{title}
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
{#if intro}
|
{#if intro}
|
||||||
<p class="faq-section-intro">{intro}</p>
|
<p class="faq-section-intro">{intro}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
<div use:accordion class="faq">
|
<div use:accordion class="faq">
|
||||||
{#each faqs as faq}
|
{#each faqs as faq}
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -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';
|
import { locationPages } from '$lib/content/locations';
|
||||||
|
|
||||||
export let footer: FooterContent;
|
export let footer: FooterContent;
|
||||||
|
// 'minimal' is a typography test running on the /variants/* pages.
|
||||||
|
export let variant: 'default' | 'minimal' = 'default';
|
||||||
|
|
||||||
|
|
||||||
const socialLinks: LinkItem[] = [
|
const socialLinks: LinkItem[] = [
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer data-track-location="footer">
|
<footer data-track-location="footer" class:footer--min={variant === 'minimal'}>
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
@@ -125,3 +127,61 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* Minimal footer variant (typography test on /variants/* pages). One calm
|
||||||
|
* type scale, lighter weights, more air. Structure and colour unchanged.
|
||||||
|
* Two sizes do all the work: 14px for links/text, 12px for meta. Labels drop
|
||||||
|
* the display font for tracked body caps, which reads cleaner at small sizes.
|
||||||
|
*/
|
||||||
|
.footer--min :global(.footer-logo) {
|
||||||
|
height: 26px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-brand p) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-col-label) {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-nav a),
|
||||||
|
.footer--min :global(.footer-contact-link) {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-nav a) {
|
||||||
|
padding: 7px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-nav a:hover),
|
||||||
|
.footer--min :global(.footer-contact-link:hover) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-bottom) {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer--min :global(.footer-back-top) {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import Icon from '$lib/components/ui/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
|
|
||||||
export let email = '';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ logout: void }>();
|
const dispatch = createEventDispatcher<{ logout: void }>();
|
||||||
|
|
||||||
let loggingOut = false;
|
let loggingOut = false;
|
||||||
@@ -32,9 +30,6 @@
|
|||||||
<Icon name="fas fa-arrow-left" />
|
<Icon name="fas fa-arrow-left" />
|
||||||
Back to main site
|
Back to main site
|
||||||
</a>
|
</a>
|
||||||
{#if email}
|
|
||||||
<span class="ob-footer-email">Signed in as {email}</span>
|
|
||||||
{/if}
|
|
||||||
<button type="button" class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
|
<button type="button" class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
|
||||||
<Icon name="fas fa-right-from-bracket" />
|
<Icon name="fas fa-right-from-bracket" />
|
||||||
{loggingOut ? 'Signing out…' : 'Sign out'}
|
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||||
@@ -75,16 +70,6 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ob-footer-email {
|
|
||||||
margin-left: auto;
|
|
||||||
min-width: 0;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ob-footer-logout {
|
.ob-footer-logout {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -116,16 +101,6 @@
|
|||||||
row-gap: 10px;
|
row-gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ob-footer-email {
|
|
||||||
order: 3;
|
|
||||||
flex: 0 0 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
white-space: normal;
|
|
||||||
overflow: visible;
|
|
||||||
text-overflow: clip;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ob-footer-back,
|
.ob-footer-back,
|
||||||
.ob-footer-logout {
|
.ob-footer-logout {
|
||||||
|
|||||||
@@ -183,6 +183,66 @@ export const dogWalkingContent: ServicePageContent = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
testimonialsHeading: 'What our clients say',
|
testimonialsHeading: 'What our clients say',
|
||||||
|
clarity: {
|
||||||
|
diffEyebrow: 'What makes solo walks different',
|
||||||
|
diffHeading: 'Built around your dog. Not a pack schedule.',
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user',
|
||||||
|
title: 'One dog, full attention',
|
||||||
|
body: 'No group to manage. Just your dog and their walker, the whole time.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-person-walking',
|
||||||
|
title: 'Their pace, their route',
|
||||||
|
body: 'Speed, distance, and tempo all shaped around your dog\'s confidence and energy.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user-check',
|
||||||
|
title: 'The same Goodwalk walker, every time',
|
||||||
|
body: 'One familiar handler your dog learns to trust. No rotating faces.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-shield-heart',
|
||||||
|
title: 'Room for sensitive dogs',
|
||||||
|
body: 'Reactive, anxious, senior, or recovering. Solo gives the space and calm they need.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stepsEyebrow: 'How it works',
|
||||||
|
stepsHeading: 'Three simple steps, no pressure.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Free Meet & Greet',
|
||||||
|
body: 'A Goodwalk walker comes to your home, meets your dog, and talks through exactly what they need.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'A settle-in walk',
|
||||||
|
body: 'A first one-on-one outing to learn your dog\'s pace, route, and the handling that suits them.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your regular slot',
|
||||||
|
body: 'A dependable weekly rhythm with the same walker, and a photo update after every walk.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
imageBand: {
|
||||||
|
imageUrl: '/images/goodwalk-large-dog-one-on-one-walk-auckland.webp',
|
||||||
|
imageAlt:
|
||||||
|
'A large dog enjoying a calm one-on-one Goodwalk solo walk in an Auckland park',
|
||||||
|
caption: 'One dog, one Goodwalk walker, full attention. Door-to-door across Auckland Central.'
|
||||||
|
},
|
||||||
|
fitEyebrow: 'Honest fit check',
|
||||||
|
pricingEyebrow: 'Simple per-walk pricing',
|
||||||
|
priceNotes: [
|
||||||
|
{ icon: 'fas fa-stopwatch', label: '30, 45, or 60 minutes.' },
|
||||||
|
{ icon: 'fas fa-car', label: 'Free pickup & drop-off, every walk.' },
|
||||||
|
{ icon: 'fas fa-camera', label: 'Photo update after every walk.' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||||
|
],
|
||||||
|
faqEyebrow: 'Common questions',
|
||||||
|
areaTitle: 'Where we walk across Auckland Central',
|
||||||
|
areaLead:
|
||||||
|
'Goodwalk covers Auckland Central and the suburbs around it, with free door-to-door pickup and drop-off. The same walker, every time. Tap your suburb for its local parks and routes.'
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
title: 'See if solo walks are the right fit',
|
title: 'See if solo walks are the right fit',
|
||||||
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
|
subtitle: 'A few details. We arrange a free Meet & Greet to meet your dog.',
|
||||||
|
|||||||
@@ -206,6 +206,89 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
testimonialsHeading: 'What our clients say',
|
testimonialsHeading: 'What our clients say',
|
||||||
|
clarity: {
|
||||||
|
diffEyebrow: 'What makes Tiny Gang different',
|
||||||
|
diffHeading: 'Not a big anonymous pack. A small, matched crew.',
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-users',
|
||||||
|
title: 'Carefully matched groups',
|
||||||
|
body: '4 to 8 dogs, grouped by temperament, energy, and play style. Never a random pack.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-shield-dog',
|
||||||
|
title: 'Small & medium dogs only',
|
||||||
|
body: 'Pace, group size, and play style are all built for little dogs, so yours is never the odd one out.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user-check',
|
||||||
|
title: 'The same Goodwalk walker, every week',
|
||||||
|
body: 'One familiar face at the door and on the walk. Your dog is known, not handed around.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-eye',
|
||||||
|
title: 'Eyes on every dog',
|
||||||
|
body: 'Small groups mean we notice the little things early, from recall to a quiet day.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stepsEyebrow: 'How joining works',
|
||||||
|
stepsHeading: 'Three simple steps, no pressure.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Free Meet & Greet',
|
||||||
|
body: 'A Goodwalk walker comes to your home, meets your dog, and talks honestly about whether the pack suits them.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Two assessment walks',
|
||||||
|
body: 'Your dog settles in gently while we confirm the right group, pace, and handling.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your weekly slot',
|
||||||
|
body: 'A regular spot with the same little crew and the same walker, week in, week out.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
imageBand: {
|
||||||
|
imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
|
||||||
|
imageAlt:
|
||||||
|
'Eight small and medium Tiny Gang dogs sitting together in the back of the Goodwalk pickup vehicle after an Auckland pack walk',
|
||||||
|
caption: 'Door-to-door pickup, every walk. The Goodwalk Tiny Gang, homeward bound after a park run.'
|
||||||
|
},
|
||||||
|
fitEyebrow: 'Honest fit check',
|
||||||
|
pricingEyebrow: 'Simple weekly pricing',
|
||||||
|
pricingTagline: "The best investment you'll ever make is in your dog's happiness.",
|
||||||
|
priceNotes: [
|
||||||
|
{ icon: 'fas fa-stopwatch', label: '60+ minute adventures.' },
|
||||||
|
{ icon: 'fas fa-car', label: 'Free pickup & drop-off, every walk.' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||||
|
],
|
||||||
|
faqEyebrow: 'Common questions',
|
||||||
|
planLines: {
|
||||||
|
'1 Walk Per Week': [
|
||||||
|
'A calm weekly outing',
|
||||||
|
'Time to sniff, play, and unwind',
|
||||||
|
'Great for dogs just starting out'
|
||||||
|
],
|
||||||
|
'2-3 Walks Per Week': [
|
||||||
|
'Steady exercise and real social time',
|
||||||
|
'A settled mid-week rhythm',
|
||||||
|
'Our balanced routine'
|
||||||
|
],
|
||||||
|
'4-5 Walks Per Week': [
|
||||||
|
'Best value per walk',
|
||||||
|
'Maximum routine and structure',
|
||||||
|
'For high-energy, social dogs'
|
||||||
|
],
|
||||||
|
'Casual Pack Walk': [
|
||||||
|
'No weekly commitment',
|
||||||
|
'Book only the weeks you need',
|
||||||
|
'For dogs already in the pack'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
testimonialsHeading: 'Owners love the Tiny Gang',
|
||||||
|
areaTitle: 'Where the Tiny Gang walks',
|
||||||
|
areaLead:
|
||||||
|
'Goodwalk covers Auckland Central and the suburbs around it, with free door-to-door pickup and drop-off. You drive nowhere. Tap your suburb for its local parks and routes.'
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
title: 'See if your dog fits our Tiny Gang',
|
title: 'See if your dog fits our Tiny Gang',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
|
|||||||
@@ -168,6 +168,71 @@ export const puppyVisitsContent: ServicePageContent = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
testimonialsHeading: 'What our clients say',
|
testimonialsHeading: 'What our clients say',
|
||||||
|
clarity: {
|
||||||
|
diffEyebrow: 'What makes Puppy Visits different',
|
||||||
|
diffHeading: 'Real support at home, while you\'re out.',
|
||||||
|
traits: [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-house',
|
||||||
|
title: 'Care in your own home',
|
||||||
|
body: 'Toilet break, fresh water, a feed if scheduled, and calm company. In the place your puppy already knows.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-clock',
|
||||||
|
title: 'Short and gentle, on purpose',
|
||||||
|
body: 'Low-impact company while growth plates settle. The right stimulation, without the joint stress.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-user-check',
|
||||||
|
title: 'The same face, every visit',
|
||||||
|
body: 'One trusted handler your puppy learns early. Consistency matters most at this age.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-paw',
|
||||||
|
title: 'A smoother step into Tiny Gang',
|
||||||
|
body: 'Goodwalk knows your puppy from the start, so moving into walks later is calm, not sudden.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stepsEyebrow: 'How it works',
|
||||||
|
stepsHeading: 'Three simple steps, no pressure.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'Free Meet & Greet',
|
||||||
|
body: 'A Goodwalk handler comes to your home, meets your puppy, and sets up access and routine together.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your first visit',
|
||||||
|
body: 'A gentle settle-in visit so your puppy gets used to us in familiar surroundings.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your regular routine',
|
||||||
|
body: 'Dependable midday visits with the same handler, and a photo update every time.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
imageBand: {
|
||||||
|
imageUrl: '/images/goodwalk-puppy-visit-cavalier-king-charles-spaniel-auckland.webp',
|
||||||
|
imageAlt:
|
||||||
|
'A young Cavalier King Charles Spaniel puppy resting calmly at home during a Goodwalk puppy visit in Auckland',
|
||||||
|
caption: 'Fed, played with, and settled by Goodwalk. A calmer day at home while you\'re out.'
|
||||||
|
},
|
||||||
|
fitEyebrow: 'Honest fit check',
|
||||||
|
pricingEyebrow: 'Simple per-visit pricing',
|
||||||
|
priceNotes: [
|
||||||
|
{ icon: 'fas fa-stopwatch', label: '20, 45, or 60 minutes.' },
|
||||||
|
{ icon: 'fas fa-house', label: 'In your own home, every visit.' },
|
||||||
|
{ icon: 'fas fa-camera', label: 'Photo update after every visit.' },
|
||||||
|
{ icon: 'fas fa-shield-heart', label: 'Starts with a free Meet & Greet.' }
|
||||||
|
],
|
||||||
|
faqEyebrow: 'Common questions',
|
||||||
|
areaTitle: 'Where we visit',
|
||||||
|
areaLead:
|
||||||
|
'Goodwalk runs in-home puppy visits across Auckland Central and the suburbs around it. We come to you. Tap your suburb for its local details.',
|
||||||
|
areaStats: [
|
||||||
|
{ strong: '17', label: 'suburbs visited' },
|
||||||
|
{ strong: 'In-home', label: 'one-on-one care' },
|
||||||
|
{ strong: 'Same face', label: 'every visit' }
|
||||||
|
]
|
||||||
|
},
|
||||||
booking: {
|
booking: {
|
||||||
title: 'See if Puppy Visits are the right start',
|
title: 'See if Puppy Visits are the right start',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const imageMetadata: Record<string, ImageMetadata> = {
|
|||||||
'/images/wallace-goodwalk-dog-walking-review-auckland.webp': { width: 1254, height: 1254 },
|
'/images/wallace-goodwalk-dog-walking-review-auckland.webp': { width: 1254, height: 1254 },
|
||||||
'/images/goodwalk-small-dog-pack-walk-auckland.webp': { width: 640, height: 480 },
|
'/images/goodwalk-small-dog-pack-walk-auckland.webp': { width: 640, height: 480 },
|
||||||
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp': { width: 1469, height: 1071 },
|
'/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp': { width: 1469, height: 1071 },
|
||||||
|
'/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp': { width: 1457, height: 1079 },
|
||||||
'/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp': { width: 1240, height: 1269 },
|
'/images/goodwalk-small-medium-dogs-pack-walk-auckland.webp': { width: 1240, height: 1269 },
|
||||||
'/images/goodwalk-happy-black-dog-one-on-one-walk-auckland.webp': { width: 1054, height: 1492 },
|
'/images/goodwalk-happy-black-dog-one-on-one-walk-auckland.webp': { width: 1054, height: 1492 },
|
||||||
'/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp': { width: 1091, height: 1441 },
|
'/images/goodwalk-large-breed-dog-one-on-one-walk-auckland.webp': { width: 1091, height: 1441 },
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ export function buildBreadcrumb(items: BreadcrumbItem[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a schema.org AggregateOffer from a set of priced plans. Used for the
|
||||||
|
* "from $X" pricing on service pages and in the homepage OfferCatalog so both
|
||||||
|
* advertise the same accurate low/high range in NZD. Returns null when no plan
|
||||||
|
* carries a usable price.
|
||||||
|
*/
|
||||||
|
export function buildAggregateOffer(plans: { price: string }[]) {
|
||||||
|
const numericPrices = plans
|
||||||
|
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
if (numericPrices.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowPrice = Math.min(...numericPrices);
|
||||||
|
const highPrice = Math.max(...numericPrices);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'@type': 'AggregateOffer',
|
||||||
|
priceCurrency: 'NZD',
|
||||||
|
lowPrice: lowPrice.toFixed(2),
|
||||||
|
highPrice: highPrice.toFixed(2),
|
||||||
|
offerCount: numericPrices.length,
|
||||||
|
availability: 'https://schema.org/InStock'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAreaServed(locations = locationPages) {
|
export function buildAreaServed(locations = locationPages) {
|
||||||
return locations.map((location) => ({
|
return locations.map((location) => ({
|
||||||
'@type': 'Place',
|
'@type': 'Place',
|
||||||
|
|||||||
@@ -157,6 +157,68 @@ export interface ServiceDecisionBlock {
|
|||||||
footnote?: string;
|
footnote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceTrait {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceJoinStep {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceImageBand {
|
||||||
|
imageUrl: string;
|
||||||
|
imageAlt: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicePriceNote {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAreaStat {
|
||||||
|
strong: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 "clarity" content: the page-specific copy the shared ServiceClarityPage
|
||||||
|
* needs so all three service pages run on one component. See docs/v5.md.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Optional "what it is" lede that opens a service page: a short, self-contained
|
||||||
|
* definition of the service in Goodwalk's voice. Renders right after the hero.
|
||||||
|
*/
|
||||||
|
export interface ServiceDefinition {
|
||||||
|
eyebrow?: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceClarityContent {
|
||||||
|
definition?: ServiceDefinition;
|
||||||
|
diffEyebrow: string;
|
||||||
|
diffHeading: string;
|
||||||
|
traits: ServiceTrait[];
|
||||||
|
stepsEyebrow: string;
|
||||||
|
stepsHeading: string;
|
||||||
|
steps: ServiceJoinStep[];
|
||||||
|
imageBand?: ServiceImageBand;
|
||||||
|
fitEyebrow?: string;
|
||||||
|
pricingEyebrow?: string;
|
||||||
|
pricingTagline?: string;
|
||||||
|
priceNotes?: ServicePriceNote[];
|
||||||
|
faqEyebrow?: string;
|
||||||
|
planLines?: Record<string, string[]>;
|
||||||
|
testimonialsHeading?: string;
|
||||||
|
areaTitle?: string;
|
||||||
|
areaLead?: string;
|
||||||
|
areaStats?: ServiceAreaStat[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServicePageContent {
|
export interface ServicePageContent {
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
@@ -202,6 +264,7 @@ export interface ServicePageContent {
|
|||||||
};
|
};
|
||||||
testimonialsHeading: string;
|
testimonialsHeading: string;
|
||||||
booking: BookingContent;
|
booking: BookingContent;
|
||||||
|
clarity?: ServiceClarityContent;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -7,13 +7,24 @@
|
|||||||
// at /variants/homepage-original). Both are pure view components; all data
|
// at /variants/homepage-original). Both are pure view components; all data
|
||||||
// loading, SEO head, and structured data stay here.
|
// loading, SEO head, and structured data stay here.
|
||||||
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
|
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
|
||||||
import { buildAreaServed } from '$lib/seo';
|
import { buildAggregateOffer, buildAreaServed } from '$lib/seo';
|
||||||
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||||
|
|
||||||
|
// Pricing plans keyed by service URL, so the OfferCatalog can advertise the
|
||||||
|
// same accurate "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) {
|
function absoluteUrl(value: string) {
|
||||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
return value;
|
return value;
|
||||||
@@ -63,14 +74,20 @@
|
|||||||
hasOfferCatalog: {
|
hasOfferCatalog: {
|
||||||
'@type': 'OfferCatalog',
|
'@type': 'OfferCatalog',
|
||||||
name: 'Dog Walking Services',
|
name: 'Dog Walking Services',
|
||||||
itemListElement: data.content.services.map((service) => ({
|
itemListElement: data.content.services.map((service) => {
|
||||||
|
const offers = buildAggregateOffer(
|
||||||
|
service.href ? servicePlansByHref[service.href] ?? [] : []
|
||||||
|
);
|
||||||
|
return {
|
||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
itemOffered: {
|
itemOffered: {
|
||||||
'@type': 'Service',
|
'@type': 'Service',
|
||||||
name: service.title,
|
name: service.title,
|
||||||
url: `${siteUrl}${service.href}`
|
url: `${siteUrl}${service.href}`,
|
||||||
|
...(offers ? { offers } : {})
|
||||||
}
|
}
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
},
|
},
|
||||||
aggregateRating: {
|
aggregateRating: {
|
||||||
'@type': 'AggregateRating',
|
'@type': 'AggregateRating',
|
||||||
|
|||||||
@@ -6,17 +6,22 @@
|
|||||||
import BookingPage from '$lib/components/pages/BookingPage.svelte';
|
import BookingPage from '$lib/components/pages/BookingPage.svelte';
|
||||||
import LegalPage from '$lib/components/pages/LegalPage.svelte';
|
import LegalPage from '$lib/components/pages/LegalPage.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import PricingPage from '$lib/components/pages/PricingPage.svelte';
|
import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
|
||||||
import TestimonialsPage from '$lib/components/pages/TestimonialsPage.svelte';
|
import TestimonialsPage from '$lib/components/pages/TestimonialsPage.svelte';
|
||||||
import { aboutPageContent } from '$lib/content/about';
|
import { aboutPageContent } from '$lib/content/about';
|
||||||
import ServiceLandingPage from '$lib/components/pages/ServiceLandingPage.svelte';
|
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
import { ourPricingContent } from '$lib/content/our-pricing';
|
|
||||||
import { packWalksContent } from '$lib/content/pack-walks';
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
||||||
import { buildAreaServed, buildBreadcrumb, absoluteUrl, goodwalkProviderNode } from '$lib/seo';
|
import {
|
||||||
|
buildAggregateOffer,
|
||||||
|
buildAreaServed,
|
||||||
|
buildBreadcrumb,
|
||||||
|
absoluteUrl,
|
||||||
|
goodwalkProviderNode
|
||||||
|
} from '$lib/seo';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@@ -25,35 +30,13 @@
|
|||||||
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
|
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
|
||||||
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
||||||
|
|
||||||
function aggregateOfferSchema(plans: { price: string }[]) {
|
|
||||||
const numericPrices = plans
|
|
||||||
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0);
|
|
||||||
|
|
||||||
if (numericPrices.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowPrice = Math.min(...numericPrices);
|
|
||||||
const highPrice = Math.max(...numericPrices);
|
|
||||||
|
|
||||||
return {
|
|
||||||
'@type': 'AggregateOffer',
|
|
||||||
priceCurrency: 'NZD',
|
|
||||||
lowPrice: lowPrice.toFixed(2),
|
|
||||||
highPrice: highPrice.toFixed(2),
|
|
||||||
offerCount: numericPrices.length,
|
|
||||||
availability: 'https://schema.org/InStock'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildServiceNode(base: {
|
function buildServiceNode(base: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
image: string;
|
image: string;
|
||||||
url: string;
|
url: string;
|
||||||
offers: ReturnType<typeof aggregateOfferSchema>;
|
offers: ReturnType<typeof buildAggregateOffer>;
|
||||||
}) {
|
}) {
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -108,7 +91,7 @@
|
|||||||
serviceType: 'Pack walks for small and medium dogs',
|
serviceType: 'Pack walks for small and medium dogs',
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
offers: buildAggregateOffer(packWalksContent.pricing.plans)
|
||||||
}),
|
}),
|
||||||
buildBreadcrumb([
|
buildBreadcrumb([
|
||||||
{ name: 'Home', url: siteUrl },
|
{ name: 'Home', url: siteUrl },
|
||||||
@@ -126,7 +109,7 @@
|
|||||||
serviceType: 'One-on-one dog walking',
|
serviceType: 'One-on-one dog walking',
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
offers: buildAggregateOffer(dogWalkingContent.pricing.plans)
|
||||||
}),
|
}),
|
||||||
buildBreadcrumb([
|
buildBreadcrumb([
|
||||||
{ name: 'Home', url: siteUrl },
|
{ name: 'Home', url: siteUrl },
|
||||||
@@ -144,7 +127,7 @@
|
|||||||
serviceType: 'In-home puppy visits',
|
serviceType: 'In-home puppy visits',
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
offers: buildAggregateOffer(puppyVisitsContent.pricing.plans)
|
||||||
}),
|
}),
|
||||||
buildBreadcrumb([
|
buildBreadcrumb([
|
||||||
{ name: 'Home', url: siteUrl },
|
{ name: 'Home', url: siteUrl },
|
||||||
@@ -228,13 +211,13 @@
|
|||||||
<Header navigation={data.content.navigation} />
|
<Header navigation={data.content.navigation} />
|
||||||
|
|
||||||
{#if data.slug === 'pack-walks'}
|
{#if data.slug === 'pack-walks'}
|
||||||
<ServiceLandingPage content={data.content} pageContent={packWalksContent} currentPath={data.page.canonicalPath} />
|
<ServiceClarityPage content={data.content} pageContent={packWalksContent} currentPath={data.page.canonicalPath} />
|
||||||
{:else if data.slug === 'dog-walking'}
|
{:else if data.slug === 'dog-walking'}
|
||||||
<ServiceLandingPage content={data.content} pageContent={dogWalkingContent} currentPath={data.page.canonicalPath} />
|
<ServiceClarityPage content={data.content} pageContent={dogWalkingContent} currentPath={data.page.canonicalPath} />
|
||||||
{:else if data.slug === 'puppy-visits'}
|
{:else if data.slug === 'puppy-visits'}
|
||||||
<ServiceLandingPage content={data.content} pageContent={puppyVisitsContent} currentPath={data.page.canonicalPath} />
|
<ServiceClarityPage content={data.content} pageContent={puppyVisitsContent} currentPath={data.page.canonicalPath} />
|
||||||
{:else if data.slug === 'our-pricing'}
|
{:else if data.slug === 'our-pricing'}
|
||||||
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
<PricingClarityPage />
|
||||||
{:else if data.slug === 'about'}
|
{:else if data.slug === 'about'}
|
||||||
<AboutPage pageContent={aboutPageContent} />
|
<AboutPage pageContent={aboutPageContent} />
|
||||||
{:else if data.slug === 'testimonials'}
|
{:else if data.slug === 'testimonials'}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
|
|||||||
get: () => undefined,
|
get: () => undefined,
|
||||||
set: () => {},
|
set: () => {},
|
||||||
delete: () => {}
|
delete: () => {}
|
||||||
}
|
},
|
||||||
|
// The loader reads `locals.abHero`/`locals.anonId` to attach A/B context.
|
||||||
|
// Without an A/B assignment it omits `ab`, matching the expectations below.
|
||||||
|
locals: {}
|
||||||
} as unknown as Parameters<typeof load>[0];
|
} as unknown as Parameters<typeof load>[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,34 +10,66 @@ describe('home page route', () => {
|
|||||||
data: createHomepageRouteData()
|
data: createHomepageRouteData()
|
||||||
});
|
});
|
||||||
|
|
||||||
const hero = document.getElementById('hero');
|
// HomeClarity is the live homepage. Its hero is purpose-built copy rather
|
||||||
const intro = document.getElementById('intro');
|
// than the generic content.hero fields, leading with the specialism.
|
||||||
const values = document.getElementById('values');
|
expect(document.querySelector('.chero')).not.toBeNull();
|
||||||
|
expect(screen.getByText('Meet the Tiny Gang.')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Specialist walks for small & medium dogs')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Offer-first section order: services -> how it works -> values ->
|
||||||
|
// testimonials -> founder story -> info -> booking.
|
||||||
const services = document.getElementById('services');
|
const services = document.getElementById('services');
|
||||||
const howItWorks = document.getElementById('how-it-works');
|
const howItWorks = document.getElementById('how-it-works');
|
||||||
|
const values = document.getElementById('values');
|
||||||
const testimonials = document.getElementById('testimonials');
|
const testimonials = document.getElementById('testimonials');
|
||||||
const founderStory = document.getElementById('promise');
|
const founderStory = document.getElementById('promise');
|
||||||
const info = document.getElementById('info');
|
const info = document.getElementById('info');
|
||||||
const booking = document.getElementById('newlead');
|
const booking = document.getElementById('newlead');
|
||||||
|
|
||||||
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0);
|
for (const section of [
|
||||||
const introHeadlineText = homepageContent.intro.text.replace(/\.$/, '');
|
services,
|
||||||
const introHeading = document.querySelector('#intro .intro-headline');
|
howItWorks,
|
||||||
expect(introHeading?.textContent?.replace(/\s+/g, ' ').trim()).toBe(introHeadlineText);
|
values,
|
||||||
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument();
|
testimonials,
|
||||||
|
founderStory,
|
||||||
|
info,
|
||||||
|
booking
|
||||||
|
]) {
|
||||||
|
expect(section).not.toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
|
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
|
||||||
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
|
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
|
||||||
expect(hero?.compareDocumentPosition(intro as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
||||||
expect(intro?.compareDocumentPosition(values as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(
|
||||||
expect(values?.compareDocumentPosition(services as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
);
|
||||||
expect(howItWorks?.compareDocumentPosition(testimonials as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
expect(howItWorks?.compareDocumentPosition(values as Node)).toBe(
|
||||||
expect(testimonials?.compareDocumentPosition(founderStory as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
expect(founderStory?.compareDocumentPosition(info as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
);
|
||||||
expect(info?.compareDocumentPosition(booking as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
expect(values?.compareDocumentPosition(testimonials as Node)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(testimonials?.compareDocumentPosition(founderStory as Node)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(founderStory?.compareDocumentPosition(info as Node)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(info?.compareDocumentPosition(booking as Node)).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
|
||||||
|
// The homepage booking wizard never exposes the general-enquiry toggle.
|
||||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// SEO head: title, structured data, and the preloaded hero image.
|
||||||
expect(document.title).toBe(homepageContent.seo.title);
|
expect(document.title).toBe(homepageContent.seo.title);
|
||||||
expect(document.head.innerHTML).toContain('FAQPage');
|
expect(document.head.innerHTML).toContain('PetCareService');
|
||||||
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/goodwalk-auckland-happy-dog-hero.webp');
|
expect(document.head.innerHTML).toContain(
|
||||||
|
'https://www.goodwalk.co.nz/images/smiling-happy-dog-maya.webp'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Preview of the homepage design with the simplified Meet & Greet form.
|
||||||
|
* Reuses HomeClarity (the live homepage) and injects BookingWizardSimple in
|
||||||
|
* place of the live BookingWizard via the BookingComponent prop. noindex so
|
||||||
|
* it does not compete with the real homepage in search.
|
||||||
|
*/
|
||||||
|
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
|
||||||
|
import BookingWizardSimple from '$lib/components/sections/BookingWizardSimple.svelte';
|
||||||
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Meet & Greet form (preview) | Goodwalk</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HomeClarity
|
||||||
|
content={homepageContent}
|
||||||
|
pagePath="/variants/meet-greet"
|
||||||
|
BookingComponent={BookingWizardSimple}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Preview of the redesigned pricing page in the v5 clarity system. noindex so
|
||||||
|
* it does not compete with the live /our-pricing page in search.
|
||||||
|
*/
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
|
||||||
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Pricing (redesign preview) | Goodwalk</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Header navigation={homepageContent.navigation} />
|
||||||
|
<PricingClarityPage />
|
||||||
|
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Preview of the redesigned Puppy Visits page on the shared v5 clarity
|
||||||
|
* component. noindex so it does not compete with the live /puppy-visits page.
|
||||||
|
*
|
||||||
|
* To go live: render ServiceClarityPage in the `data.slug === 'puppy-visits'`
|
||||||
|
* branch of src/routes/[slug]/+page.svelte.
|
||||||
|
*/
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||||
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Puppy visits (redesign preview) | Goodwalk</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Header navigation={homepageContent.navigation} />
|
||||||
|
<ServiceClarityPage content={homepageContent} pageContent={puppyVisitsContent} currentPath="/variants/puppy-visits" />
|
||||||
|
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Preview of the services-focus homepage variant.
|
||||||
|
* Single source of truth is HomeServicesFocus.svelte. noindex so it does not
|
||||||
|
* compete with the real homepage in search.
|
||||||
|
*
|
||||||
|
* Purpose: the site gets traffic but few leads. This variant keeps the design
|
||||||
|
* language but reorients the whole page around clicking through to a service
|
||||||
|
* page, dropping the homepage booking form in favour of lower-friction service
|
||||||
|
* choices in the hero, the cards, and a closing band.
|
||||||
|
*/
|
||||||
|
import HomeServicesFocus from '$lib/components/pages/HomeServicesFocus.svelte';
|
||||||
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Services-focus homepage (preview) | Goodwalk</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HomeServicesFocus content={homepageContent} pagePath="/variants/services-first" />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/*
|
||||||
|
* Preview of the redesigned Solo Walks page on the shared v5 clarity
|
||||||
|
* component. noindex so it does not compete with the live /dog-walking page.
|
||||||
|
*
|
||||||
|
* To go live: render ServiceClarityPage in the `data.slug === 'dog-walking'`
|
||||||
|
* branch of src/routes/[slug]/+page.svelte.
|
||||||
|
*/
|
||||||
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
|
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||||
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Solo dog walks (redesign preview) | Goodwalk</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Header navigation={homepageContent.navigation} />
|
||||||
|
<ServiceClarityPage content={homepageContent} pageContent={dogWalkingContent} currentPath="/variants/solo-walks" />
|
||||||
|
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/*
|
/*
|
||||||
* Preview of the redesigned Tiny Gang pack walks page.
|
* Preview of the redesigned Tiny Gang pack walks page.
|
||||||
* Single source of truth is TinyGangPage.svelte. noindex so it does not
|
* Single source of truth is ServiceClarityPage.svelte (shared across all
|
||||||
* compete with the live /pack-walks page in search.
|
* three service variants). noindex so it does not compete with the live
|
||||||
|
* /pack-walks page in search.
|
||||||
*
|
*
|
||||||
* To go live: in src/routes/[slug]/+page.svelte, render TinyGangPage instead
|
* To go live: in src/routes/[slug]/+page.svelte, render ServiceClarityPage
|
||||||
* of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
|
* instead of ServiceLandingPage in the `data.slug === 'pack-walks'` branch.
|
||||||
*/
|
*/
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import Footer from '$lib/components/ui/Footer.svelte';
|
import Footer from '$lib/components/ui/Footer.svelte';
|
||||||
import TinyGangPage from '$lib/components/pages/TinyGangPage.svelte';
|
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||||
import { homepageContent } from '$lib/content/homepage';
|
import { homepageContent } from '$lib/content/homepage';
|
||||||
import { packWalksContent } from '$lib/content/pack-walks';
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
</script>
|
</script>
|
||||||
@@ -20,5 +21,5 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Header navigation={homepageContent.navigation} />
|
<Header navigation={homepageContent.navigation} />
|
||||||
<TinyGangPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
|
<ServiceClarityPage content={homepageContent} pageContent={packWalksContent} currentPath="/variants/tiny-gang" />
|
||||||
<Footer footer={homepageContent.footer} />
|
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||||
|
|||||||
Reference in New Issue
Block a user