v4.0.6 0 Simplicity focus
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
# GEO Analysis — Goodwalk (goodwalk.co.nz)
|
||||
|
||||
_Generative Engine Optimization audit · February 2026 criteria · analyzed 2026-06-23_
|
||||
|
||||
Scope: SvelteKit codebase at `C:\Users\mattc\gw-svelte\gw-svelte` (SSR via `@sveltejs/adapter-node`). This is a single-location Auckland dog-walking service — a local SAB (service-area business), so GEO leverage skews toward local AI answers ("dog walker in Mt Eden", "Auckland pack walks", "puppy visit cost Auckland").
|
||||
|
||||
---
|
||||
|
||||
## 1. GEO Readiness Score: 73 / 100
|
||||
|
||||
A strong technical foundation already in place. The on-site work is close to best-practice; the gap is **off-site brand presence** and **passage-level citability** of the marketing copy.
|
||||
|
||||
| Criterion | Weight | Score | Notes |
|
||||
|-----------|--------|-------|-------|
|
||||
| Citability | 25% | 16/25 | Excellent `llms.txt` facts; on-page copy is punchy brand fragments, not extractable answer blocks |
|
||||
| Structural Readability | 20% | 15/20 | Clean H1→H2, FAQ `<details>`; few tables/comparison blocks |
|
||||
| Multi-Modal | 15% | 9/15 | Rich images + alt text everywhere; no video, infographics, or tools |
|
||||
| Authority & Brand | 20% | 14/20 | Strong on-site E-E-A-T (Person/founder schema, reviews); thin off-site (no Wikipedia/Reddit/YouTube) |
|
||||
| Technical Accessibility | 20% | 19/20 | SSR, AI crawlers explicitly allowed, `llms.txt` present — near perfect |
|
||||
|
||||
---
|
||||
|
||||
## 2. Platform Breakdown
|
||||
|
||||
| Platform | Est. Score | Why |
|
||||
|----------|-----------|-----|
|
||||
| **Google AI Overviews** | 80 | Best-aligned — SSR + rich LocalBusiness/Person/FAQ schema + local entity signals. Citations come from top-ranking pages, so this rides your existing SEO. |
|
||||
| **Bing Copilot** | 68 | Schema and SSR help; needs Bing index coverage + IndexNow. |
|
||||
| **ChatGPT (web search)** | 61 | Leans on Wikipedia (47.9%) and Reddit (11.3%). No entity presence on either → low. |
|
||||
| **Perplexity** | 57 | Reddit-driven (46.7%). No community validation present → weakest channel. |
|
||||
|
||||
**Implication:** you are well-positioned for Google AIO and should focus net-new effort on the off-site signals that feed ChatGPT/Perplexity.
|
||||
|
||||
---
|
||||
|
||||
## 3. AI Crawler Access Status ✅
|
||||
|
||||
`src/routes/robots.txt/+server.ts` explicitly allows the key search crawlers — this is correctly done:
|
||||
|
||||
| Crawler | Status |
|
||||
|---------|--------|
|
||||
| GPTBot (OpenAI) | ✅ Allowed |
|
||||
| OAI-SearchBot (OpenAI) | ✅ Allowed |
|
||||
| ClaudeBot (Anthropic) | ✅ Allowed |
|
||||
| PerplexityBot | ✅ Allowed |
|
||||
| Bingbot / others (`*`) | ✅ Allowed (with sensible `/api/`, `/variants/`, `/contract` disallows) |
|
||||
|
||||
No action required. One optional addition: `ChatGPT-User` (live browsing fetches) is not named but is covered by `User-agent: *`.
|
||||
|
||||
---
|
||||
|
||||
## 4. llms.txt Status ✅
|
||||
|
||||
Present at `static/llms.txt` and well-structured — better than most sites. It already includes founder, contact, hours, pricing summary, service URLs, suburb list, and location-page index.
|
||||
|
||||
Minor improvements:
|
||||
- **Update `Last updated`** (currently `2026-05-18`) on each meaningful content change.
|
||||
- Consider adding a one-line **"What is Goodwalk"** definitional sentence at the very top under `> ` (the quotable blockquote) — AI models lift this verbatim.
|
||||
- Add the **Meet & Greet is free** fact and **free pickup/drop-off** as explicit bullet facts (they're a differentiator AI answers love to surface).
|
||||
|
||||
---
|
||||
|
||||
## 5. Brand Mention Analysis — biggest opportunity
|
||||
|
||||
Per Ahrefs (Dec 2025), **brand mentions correlate ~3× more strongly with AI visibility than backlinks**. This is your largest lever and it is entirely off-site.
|
||||
|
||||
| Platform | Presence | Priority |
|
||||
|----------|----------|----------|
|
||||
| Instagram | ✅ `@goodwalk.nz` (linked, in `sameAs`) | Maintained |
|
||||
| Facebook | ✅ Linked in footer | Maintained |
|
||||
| Google Business Profile | ✅ `g.page` link + 30× 5-star reviews in schema | Strong |
|
||||
| **Reddit** | ❌ None | **High** — drives Perplexity (46.7%) + ChatGPT (11.3%) |
|
||||
| **YouTube** | ❌ None | **High** — strongest single citation correlation (~0.737) |
|
||||
| **Wikipedia/Wikidata** | ❌ None | Medium (hard for a small local business; skip unless notable) |
|
||||
| LinkedIn (Alessandra) | ❌ Not in Person `sameAs` | Medium — easy entity win |
|
||||
|
||||
**Realistic actions for a local business:**
|
||||
1. Get genuine mentions in Auckland subreddits (r/auckland, r/newzealand) — answer dog-care questions helpfully; don't spam.
|
||||
2. Post a few short YouTube clips (pack walk footage, "what is a Meet & Greet") — even low view counts create the entity association.
|
||||
3. List on NZ pet directories and local-business aggregators (consistent NAP).
|
||||
4. Add Alessandra's LinkedIn (if any) to the `Person` schema `sameAs`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Passage-Level Citability
|
||||
|
||||
Optimal AI-citation block length is **134–167 words, self-contained**. Your on-page copy is deliberately fragmented brand voice — great for humans, weak for extraction:
|
||||
|
||||
> "Built for dogs who do better one-on-one. Their pace. Their walk. Same walker every time." _(dog-walking.ts)_
|
||||
|
||||
These single-line fragments rarely get quoted because they don't answer a question standalone. **Recommendation: keep the brand hero copy, but add one extractable answer block per service page**, e.g.:
|
||||
|
||||
> **What is a solo dog walk?** A solo dog walk is a one-on-one outing where a single dog is walked by the same walker every time, at the dog's own pace. Goodwalk offers solo walks across Auckland Central for dogs who do better without a group — reactive dogs, seniors, post-surgery recovery, and anxious rescues. Walks run 30 minutes (seniors/lower energy), 45 minutes (most dogs), or 60 minutes (longer outings), with free door-to-door pickup and drop-off and a photo update after every walk. Solo walks start from $45 per walk. _(≈70 words — expand toward ~140 with a sentence on when to choose solo vs pack.)_
|
||||
|
||||
Your **`llms.txt` already does this well** — the "Key facts" and "Pricing summary" blocks are exactly the quotable format. Mirror that density into visible page HTML.
|
||||
|
||||
---
|
||||
|
||||
## 7. Server-Side Rendering Check ✅
|
||||
|
||||
- SSR is **on** (adapter-node, no `export const ssr = false` anywhere). AI crawlers, which do not execute JS, will see full content.
|
||||
- All structured data is emitted server-side in `<svelte:head>` (SeoHead.svelte, FaqSection.svelte) — good.
|
||||
- ⚠️ One caveat: the `<noscript>` overlay says "Please enable JavaScript to use this site." The rendered HTML is fine for crawlers, but make sure the **primary content is in the SSR payload and not gated behind hydration** — the booking/contact form needing JS is fine; the service descriptions and FAQs must be in source HTML (they are, via the content modules — confirmed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Top 5 Highest-Impact Changes
|
||||
|
||||
1. **Build off-site brand mentions (Reddit + YouTube + NZ directories).** Highest correlation with AI citation; the single biggest unlock for ChatGPT/Perplexity. _(High effort, high impact)_
|
||||
2. **Add a 134–167 word "What is [service]?" answer block** to each service page (pack-walks, dog-walking, puppy-visits) and an "How much does dog walking cost in Auckland?" block to `/our-pricing`. _(Medium effort, high impact)_
|
||||
3. **Add price + priceCurrency to the `Offer` nodes** in the homepage `hasOfferCatalog` (currently name/url only). Lets AI quote exact prices with confidence. _(Low effort, medium impact)_
|
||||
4. **Create an Auckland dog-care FAQ / short guide page** to capture informational AI queries ("how often should I walk my dog", "pack walk vs solo walk"). You have zero informational content today — purely commercial. _(Medium effort, high impact)_
|
||||
5. **Add `sameAs` to the Person (Alessandra) schema** and a visible author/founder byline with credentials (pet first-aid cert, insured) near service content. Strengthens E-E-A-T entity. _(Low effort, medium impact)_
|
||||
|
||||
---
|
||||
|
||||
## 9. Schema Recommendations
|
||||
|
||||
Already excellent: `WebSite`, `LocalBusiness`+`PetCareService` (with `aggregateRating`, `review`, `openingHoursSpecification`, `areaServed`, `hasOfferCatalog`), `Person` (founder, `knowsAbout`), `FAQPage`, `BreadcrumbList`, `Service` (location pages). Gaps to close:
|
||||
|
||||
```jsonc
|
||||
// In hasOfferCatalog → each Offer, add price:
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": { "@type": "Service", "name": "Tiny Gang Pack Walks", "url": "https://www.goodwalk.co.nz/pack-walks" },
|
||||
"price": "49.50",
|
||||
"priceCurrency": "NZD"
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// Person (Alessandra) — add credentials + external entity links:
|
||||
"hasCredential": [
|
||||
{ "@type": "EducationalOccupationalCredential", "credentialCategory": "certificate", "name": "Pet First Aid Certificate" }
|
||||
],
|
||||
"sameAs": ["https://www.linkedin.com/in/..."] // if available
|
||||
```
|
||||
|
||||
- Add **`priceRange`** (e.g. `"$$"`) and a **`geo`** node (`latitude`/`longitude`) to the LocalBusiness — both are standard LocalBusiness AI signals currently absent.
|
||||
- Optionally model each service page as its own `Service` with an `OfferCatalog` so service pages stand alone as cited entities (location pages already do this well).
|
||||
|
||||
---
|
||||
|
||||
## 10. Content Reformatting Suggestions
|
||||
|
||||
| Page | Current | Suggested GEO addition |
|
||||
|------|---------|------------------------|
|
||||
| `/dog-walking` (solo) | Brand fragments, no definition | Add "What is a solo dog walk?" 140-word block (see §6) |
|
||||
| `/pack-walks` | — | Add "What is a Tiny Gang pack walk?" + a **pack vs solo comparison table** (dogs per group, best for, price) |
|
||||
| `/puppy-visits` | — | Add "What happens in a puppy visit?" + duration/price block |
|
||||
| `/our-pricing` | Pricing copy | Add "How much does a dog walker cost in Auckland?" answer block with a clean price table (service / duration / price NZD) |
|
||||
| New: `/guide` or FAQ hub | none | Informational content to win non-commercial AI queries and earn citations |
|
||||
|
||||
**Tables are under-used.** AI Overviews and Perplexity disproportionately cite comparative tables. A single "Pack vs Solo vs Puppy Visit" comparison table would be high-value and on-brand.
|
||||
|
||||
---
|
||||
|
||||
### Bottom line
|
||||
|
||||
Technically this is a **top-decile small-business GEO setup** — SSR, AI crawler allow-list, `llms.txt`, and comprehensive schema are already done and done well. The remaining wins are **content shape** (add extractable answer blocks + tables, prices in schema) and, most of all, **off-site brand presence** (Reddit, YouTube, directories), which the data says matters more than anything on the page itself.
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# v5: Simplify the message on service pages
|
||||
|
||||
## Goal
|
||||
|
||||
Every service page should say one clear message, once, in a fixed order, using the
|
||||
homepage's single visual language. No restated sections, no card-grid sprawl. Lead
|
||||
with trust, show the emotional outcome, let the page breathe (PRODUCT.md principles).
|
||||
|
||||
The Tiny Gang page is the finished reference. The rest of this task is applying the
|
||||
same treatment to the other two service pages.
|
||||
|
||||
## The problem we are fixing
|
||||
|
||||
The current `ServiceLandingPage.svelte` stacks ~10 sections that repeat each other:
|
||||
hero, highlight collage, a 6-card benefit pager, an intro badge block, a decision
|
||||
card, a pricing block with an extras grid, an areas card, a FAQ card, and a related
|
||||
services grid. The same promise (matched groups, safe, free pickup) is said three or
|
||||
four times in three or four different card styles. It reads as pages of text and
|
||||
boxes, not one calm argument.
|
||||
|
||||
## The v5 rule
|
||||
|
||||
**Say it once, in order. One design system.**
|
||||
|
||||
Canonical section spine for every service page (this is the order):
|
||||
|
||||
1. **Hero** - what it is (shared `ServiceHero`: green copy left, photo right).
|
||||
2. **What's different** - the 3-4 things that actually set this service apart.
|
||||
3. **How it works** - the steps to get started, made explicit.
|
||||
4. **Honest fit check** - who it suits, who it does not (two columns, one card).
|
||||
5. **Proof** - `TestimonialsSection`.
|
||||
6. **One deliberate image** - a real photo that breaks the text and carries meaning.
|
||||
7. **Price** - even plan cards, plus a short row of promise notes.
|
||||
8. **FAQ** - a calm ruled list, not stacked cards.
|
||||
9. **Service area** - `ServiceAreaCoverage`: an Auckland hub with all suburbs as
|
||||
tappable links. Robust on every screen (no pin map). Per-page stats, so puppy-visits
|
||||
reads "in-home", not "pickup & drop-off".
|
||||
10. **Book** - `BookingWizard`.
|
||||
|
||||
If a piece of content does not fit one of those ten slots, it is probably a repeat.
|
||||
Cut it.
|
||||
|
||||
## Design rules (non-negotiable, taken from the Tiny Gang build)
|
||||
|
||||
- **Lists, not cards.** "What's different" is an icon + text list. "How it works" is
|
||||
numbered items. Reserve real cards for pricing only. Never nest cards.
|
||||
- **One heading grammar.** Every section opens with the same centered eyebrow + H2
|
||||
(`.tg-eyebrow` + `.tg-h2`). No per-section badges, marks, or collages.
|
||||
- **One token set.** Use the shared CSS variables (colours, radius, shadow, spacing).
|
||||
No new gradients or per-section colour systems. Goodwalk green `#213021`, yellow
|
||||
`#FFD100` for buttons only.
|
||||
- **Pricing cards stay even.** Four equal cards in a row on desktop, 2-up at 980px,
|
||||
1-up on mobile. Do not put images inside or beside the cards.
|
||||
- **One image, deliberate.** A single full-width photo band between sections, with a
|
||||
short keyword-bearing caption. It must mean something (the pack, the pickup, the
|
||||
outcome), not decorate. See SEO rules below.
|
||||
- **FAQ is a ruled list.** Hairline dividers between questions, no card chrome.
|
||||
- **Copy: half it, then half it again.** Active voice. No em dashes. No heading that
|
||||
restates the one above it.
|
||||
|
||||
## Image and SEO rules
|
||||
|
||||
- Use real Goodwalk photos from `static/images/` (keyword-rich filenames already).
|
||||
- Register width + height in `src/lib/image-metadata.ts` so there is zero layout shift.
|
||||
- Set explicit `width`, `height`, `loading="lazy"`, `decoding="async"` on every `<img>`.
|
||||
- Alt text is a full, natural, location-aware sentence (e.g. "Four small Tiny Gang dogs
|
||||
looking up together in the long grass at an Auckland park"), not "dogs".
|
||||
- Keep the `FaqSection` schema (`emitSchema`) and the `Service` + `AggregateOffer`
|
||||
structured data in `src/routes/[slug]/+page.svelte` intact.
|
||||
|
||||
## Reference implementation (copy this)
|
||||
|
||||
- Component: `src/lib/components/pages/ServiceClarityPage.svelte` (shared by all three
|
||||
service pages, content-driven)
|
||||
- Pricing variant: `src/lib/components/pages/PricingClarityPage.svelte`
|
||||
- Coverage section: `src/lib/components/sections/ServiceAreaCoverage.svelte`
|
||||
- Preview routes (noindex): `/variants/tiny-gang`, `/variants/solo-walks`,
|
||||
`/variants/puppy-visits`, `/variants/our-pricing`
|
||||
- Content: the `clarity` block in `pack-walks.ts`, `dog-walking.ts`, `puppy-visits.ts`
|
||||
- Variant switcher (preview only): `src/lib/components/ui/VariantNav.svelte`, mounted in
|
||||
`src/routes/+layout.svelte`
|
||||
|
||||
Read the header comment in `ServiceClarityPage.svelte` first. It states the philosophy
|
||||
and the section order in one place.
|
||||
|
||||
## Status
|
||||
|
||||
Done (built, svelte-check clean, server-rendered):
|
||||
|
||||
- `ServiceClarityPage` generalised and content-driven; `ServicePageContent.clarity`
|
||||
added in `src/lib/types.ts`. Old `TinyGangPage.svelte` deleted.
|
||||
- `clarity` content written for pack-walks, dog-walking, puppy-visits. Tiny Gang renders
|
||||
identically to before.
|
||||
- Coverage spine slot added (`ServiceAreaCoverage`), with per-page stats so puppy-visits
|
||||
reads "in-home" not "pickup & drop-off".
|
||||
- Preview routes for all four variants, plus a global `VariantNav` to compare them.
|
||||
|
||||
## Live now
|
||||
|
||||
The clarity pages are the live pages. `src/routes/[slug]/+page.svelte` renders
|
||||
`ServiceClarityPage` for pack-walks / dog-walking / puppy-visits and `PricingClarityPage`
|
||||
for our-pricing. The old `ServiceLandingPage.svelte`, `PricingPage.svelte`, and
|
||||
`ServiceAreaMap.svelte` have been deleted.
|
||||
|
||||
The `/variants/*` routes still exist as a sandbox (the minimal-footer test lives there),
|
||||
but are **dev/localhost only**: `src/hooks.server.ts` returns 500 for the `/variants`
|
||||
namespace off-localhost in production, and `VariantNav` is hidden the same way in
|
||||
`+layout.svelte`. Verified on a real `node build`: public host → 500 for any
|
||||
`/variants/*` and no `VariantNav`; localhost → variants load.
|
||||
|
||||
Footer note: the live pages keep the default footer. The `minimal` footer is still only
|
||||
on the `/variants/*` pages, a separate decision from the page-design swap.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [x] One `ServiceClarityPage` component drives all three service pages.
|
||||
- [x] Page-specific copy lives in content files, not the component.
|
||||
- [x] All three pages follow the spine, say each point once, share one heading grammar
|
||||
and one token set.
|
||||
- [x] Pricing cards are even on every page. No image in the pricing block.
|
||||
- [x] Exactly one deliberate, captioned, fully-SEO'd image band per service page.
|
||||
- [x] FAQ is a ruled list. Schema preserved.
|
||||
- [x] `npx svelte-check` clean (no new errors).
|
||||
- [x] Live `[slug]` branches swapped to the clarity pages.
|
||||
- [x] Old `ServiceLandingPage.svelte` / `PricingPage.svelte` / `ServiceAreaMap.svelte` deleted.
|
||||
- [x] `/variants/*` blocked in production (500) and `VariantNav` hidden there.
|
||||
- [ ] Decide whether to promote the `minimal` footer to the live pages.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not redesign the brand or introduce new colours, fonts, or gradients.
|
||||
- Do not add card grids, badges, collages, or mobile pagers back in.
|
||||
- Do not change wording on a page unless it is a repeat being removed or copy being
|
||||
shortened. Keep the meaning.
|
||||
Binary file not shown.
@@ -9,7 +9,37 @@
|
||||
"priceDetails": "45",
|
||||
"startDate": "2026-05-18",
|
||||
"sentAt": "2026-05-19T22:59:53"
|
||||
},
|
||||
"lifecycle": {
|
||||
"status": "active",
|
||||
"reason": "",
|
||||
"changedAt": "2026-06-29T20:39:59",
|
||||
"changedBy": "info@goodwalk.co.nz"
|
||||
},
|
||||
"lifecycleHistory": [
|
||||
{
|
||||
"status": "active",
|
||||
"reason": "",
|
||||
"changedAt": "2026-06-29T20:39:59",
|
||||
"changedBy": "info@goodwalk.co.nz"
|
||||
}
|
||||
],
|
||||
"dogs": [
|
||||
{
|
||||
"id": "primary",
|
||||
"name": "Geoffrey",
|
||||
"breed": "",
|
||||
"birthDate": "",
|
||||
"birthdayAutoSend": false,
|
||||
"birthdayEmailLastSentAt": "",
|
||||
"birthdayEmailLastSentYear": ""
|
||||
}
|
||||
],
|
||||
"dogBreed": "",
|
||||
"dogAge": "",
|
||||
"birthdayAutoSend": false,
|
||||
"birthdayEmailLastSentAt": "",
|
||||
"birthdayEmailLastSentYear": ""
|
||||
},
|
||||
"natalie@desseinparke.com": {
|
||||
"fullName": "Natalie Parke",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -61,11 +61,37 @@ class WelcomePackEmailRequest(BaseModel):
|
||||
# Owner-set subject line. Defaults to "Goodwalk Onboarding - <Dog Name>" when
|
||||
# blank (resolved server-side so scheduled sends keep a sensible subject too).
|
||||
subject: str = ""
|
||||
serviceType: str
|
||||
priceDetails: str
|
||||
startDate: str
|
||||
# Structured offer details rendered in the details table. Now fully optional —
|
||||
# the owner can hide the table entirely (showDetails=False) and write a free
|
||||
# email instead. Left blank, a row is simply omitted from the table.
|
||||
serviceType: str = ""
|
||||
priceDetails: str = ""
|
||||
startDate: str = ""
|
||||
# Free-text customisation. All blank => the email falls back to the original
|
||||
# default copy, so existing callers keep working unchanged. `{first_name}`
|
||||
# and `{dog_name}` tokens are substituted server-side.
|
||||
heading: str = ""
|
||||
intro: str = ""
|
||||
outro: str = ""
|
||||
tagLabel: str = ""
|
||||
# Customisable button block. Defaults to "Complete onboarding" pointing at the
|
||||
# client onboarding portal. Clearing either field hides the button.
|
||||
ctaLabel: str = ""
|
||||
ctaUrl: str = ""
|
||||
# Whether to render the Service / Price / Start date table at all.
|
||||
showDetails: bool = True
|
||||
# Rich-text (WYSIWYG) message body. When present it replaces the
|
||||
# heading/intro/outro copy above and is sanitised to an email-safe subset of
|
||||
# HTML server-side. `includeButton` toggles the onboarding button beneath it.
|
||||
bodyHtml: str = ""
|
||||
includeButton: bool = True
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
# When sending for real (not a preview), also copy the owner so they keep a
|
||||
# record of exactly what the client received. CC is visible to the client;
|
||||
# BCC is hidden. Independent. Ignored on previews and scheduled sends.
|
||||
ccOwner: bool = False
|
||||
bccOwner: bool = False
|
||||
# ISO 8601 local datetime (e.g. "2026-06-20T14:30"). When set on a non-preview
|
||||
# request, the welcome email is queued for reliable delivery at that time
|
||||
# instead of being sent immediately.
|
||||
@@ -84,6 +110,9 @@ class ScheduledEmailRescheduleRequest(BaseModel):
|
||||
class BirthdayEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
dogId: str | None = None
|
||||
# Owner-editable subject line. Blank falls back to the default
|
||||
# "Happy birthday <dog>" subject.
|
||||
subject: str = ""
|
||||
preview: bool = False
|
||||
previewRecipients: list[EmailStr] = []
|
||||
|
||||
@@ -124,6 +153,12 @@ class ResetOnboardingRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class DeleteClientRequest(BaseModel):
|
||||
"""Owner-initiated permanent deletion of a client and their access."""
|
||||
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class NewClientRequest(BaseModel):
|
||||
"""Owner-initiated client creation (e.g. leads from Instagram / Facebook
|
||||
that never came through the public enquiry form). Registers the email so
|
||||
@@ -138,6 +173,9 @@ class NewClientRequest(BaseModel):
|
||||
dogAge: str = ""
|
||||
# Where the client came from, e.g. "Instagram", "Facebook", "Referral".
|
||||
source: str = ""
|
||||
# The date the client joined Goodwalk (ISO yyyy-mm-dd). Owner-supplied, since
|
||||
# only the owner knows it. Used to surface joining anniversaries.
|
||||
joiningDate: str = ""
|
||||
# When true (and the MYOB integration is configured), also create the client
|
||||
# as a customer contact in MYOB. Non-fatal: a MYOB failure never blocks the
|
||||
# local client from being created.
|
||||
|
||||
+541
-42
@@ -2,6 +2,8 @@ import asyncio
|
||||
import base64
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -71,6 +73,7 @@ from mail_api.models import (
|
||||
BookingSubmission,
|
||||
ClientStatusUpdate,
|
||||
ContractSubmission,
|
||||
DeleteClientRequest,
|
||||
NewClientRequest,
|
||||
OnboardingSubmission,
|
||||
RenderMessageRequest,
|
||||
@@ -648,6 +651,53 @@ async def _update_client_profile(
|
||||
return merged
|
||||
|
||||
|
||||
async def _delete_client_profile(email: str) -> dict[str, Any]:
|
||||
"""Permanently remove a client: their profile, saved draft, any pending login
|
||||
codes/sessions, their allowed-email entry (so they can no longer sign in),
|
||||
and any welcome emails still queued to send to them. The business owner's own
|
||||
account can never be deleted."""
|
||||
normalized = email.strip().lower()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="A valid email is required.")
|
||||
if normalized == OWNER_EMAIL.strip().lower():
|
||||
raise HTTPException(status_code=400, detail="The business owner account cannot be deleted.")
|
||||
|
||||
async with _auth_lock:
|
||||
if normalized not in _client_profiles:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
|
||||
removed = _client_profiles.pop(normalized, {})
|
||||
_drafts.pop(normalized, None)
|
||||
_pending_codes.pop(normalized, None)
|
||||
_code_requests.pop(normalized, None)
|
||||
for token in [t for t, s in _active_sessions.items() if isinstance(s, dict) and s.get("email") == normalized]:
|
||||
_active_sessions.pop(token, None)
|
||||
_allowed_emails.discard(normalized)
|
||||
|
||||
profiles_snapshot = dict(_client_profiles)
|
||||
drafts_snapshot = dict(_drafts)
|
||||
allowed_emails_snapshot = sorted(_allowed_emails)
|
||||
|
||||
await asyncio.to_thread(_save_client_profiles_file, profiles_snapshot)
|
||||
await asyncio.to_thread(_save_drafts_file, drafts_snapshot)
|
||||
await asyncio.to_thread(_save_allowed_emails_file, set(allowed_emails_snapshot))
|
||||
await _persist_admin_state("client_profiles", profiles_snapshot)
|
||||
await _persist_admin_state("drafts", drafts_snapshot)
|
||||
await _persist_admin_state("allowed_emails", {"emails": allowed_emails_snapshot})
|
||||
await _save_active_sessions_async()
|
||||
|
||||
# Drop any scheduled welcome emails still queued for this address.
|
||||
async with _scheduled_lock:
|
||||
stale = [eid for eid, entry in _scheduled_emails.items()
|
||||
if isinstance(entry, dict) and str(entry.get("email", "")).strip().lower() == normalized]
|
||||
for eid in stale:
|
||||
_scheduled_emails.pop(eid, None)
|
||||
if stale:
|
||||
await _persist_scheduled_emails()
|
||||
|
||||
return removed if isinstance(removed, dict) else {}
|
||||
|
||||
|
||||
async def _reset_client_onboarding(email: str) -> dict[str, Any]:
|
||||
"""Mark a client's onboarding as incomplete while keeping every saved detail.
|
||||
|
||||
@@ -2384,11 +2434,353 @@ def _format_date_label(value: str) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str, price_details: str, start_date: str) -> str:
|
||||
WELCOME_FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
|
||||
WELCOME_CTA_DEFAULT_LABEL = "Complete onboarding"
|
||||
WELCOME_CTA_DEFAULT_URL = "https://clients.goodwalk.co.nz/"
|
||||
|
||||
# Canonical business contact details, kept in step with the public site footer
|
||||
# (src/lib/content/homepage.ts). These power the copyright + contact block at the
|
||||
# bottom of every email the owner sends.
|
||||
BUSINESS_EMAIL = "info@goodwalk.co.nz"
|
||||
BUSINESS_PHONE = "(022) 642 1011"
|
||||
BUSINESS_PHONE_TEL = "0226421011"
|
||||
|
||||
|
||||
def _email_footer_html(
|
||||
*,
|
||||
text_color: str = "#ffffff",
|
||||
link_color: str = "#ffffff",
|
||||
font_stack: str = WELCOME_FONT_STACK,
|
||||
) -> str:
|
||||
"""Shared email footer: a simple copyright notice plus the business contact
|
||||
details. White text by default so it reads cleanly on the dark-green band."""
|
||||
year = datetime.now().year
|
||||
return (
|
||||
f'<div style="font-family:{font_stack};font-size:12px;line-height:1.7;color:{text_color};">'
|
||||
f'© {year} Goodwalk<br>'
|
||||
f'<a href="mailto:{BUSINESS_EMAIL}" style="color:{link_color};text-decoration:none;">{BUSINESS_EMAIL}</a>'
|
||||
f' · '
|
||||
f'<a href="tel:{BUSINESS_PHONE_TEL}" style="color:{link_color};text-decoration:none;">{BUSINESS_PHONE}</a>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _apply_welcome_tokens(text: str, first_name: str, dog_name: str) -> str:
|
||||
"""Substitute the personalisation tokens the owner can use in custom copy."""
|
||||
dog = dog_name or "your dog"
|
||||
return (
|
||||
text.replace("{first_name}", first_name)
|
||||
.replace("{firstName}", first_name)
|
||||
.replace("{dog_name}", dog)
|
||||
.replace("{dogName}", dog)
|
||||
.replace("{dog}", dog)
|
||||
)
|
||||
|
||||
|
||||
def _welcome_paragraphs(text: str, color: str) -> str:
|
||||
"""Render owner body text as one or more email-safe paragraphs."""
|
||||
parts = [p.strip() for p in (text or "").split("\n\n") if p.strip()]
|
||||
return "".join(
|
||||
f'<p style="margin:0 0 18px;font-family:{WELCOME_FONT_STACK};font-size:15px;'
|
||||
f'line-height:1.7;color:{color};">{_html_breaks(para)}</p>'
|
||||
for para in parts
|
||||
)
|
||||
|
||||
|
||||
def _html_breaks(text: str) -> str:
|
||||
"""Convert single newlines within a paragraph to <br> (text is already trusted owner copy)."""
|
||||
return text.replace("\n", "<br>")
|
||||
|
||||
|
||||
# Inline styles applied to the whitelisted tags the WYSIWYG editor can produce,
|
||||
# so the owner's message renders consistently across email clients.
|
||||
_WELCOME_BODY_STYLES = {
|
||||
"p": f"margin:0 0 16px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.7;color:#4b584b;",
|
||||
"h1": f"margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:26px;line-height:1.2;letter-spacing:-0.02em;font-weight:700;color:#171b20;",
|
||||
"h2": f"margin:20px 0 10px;font-family:{WELCOME_FONT_STACK};font-size:20px;line-height:1.25;letter-spacing:-0.01em;font-weight:700;color:#171b20;",
|
||||
"h3": f"margin:18px 0 8px;font-family:{WELCOME_FONT_STACK};font-size:16px;line-height:1.3;font-weight:700;color:#213021;",
|
||||
"ul": "margin:0 0 16px;padding-left:22px;",
|
||||
"ol": "margin:0 0 16px;padding-left:22px;",
|
||||
"li": f"margin:0 0 6px;font-family:{WELCOME_FONT_STACK};font-size:15px;line-height:1.6;color:#4b584b;",
|
||||
"strong": "font-weight:700;color:#171b20;",
|
||||
"em": "font-style:italic;",
|
||||
"u": "text-decoration:underline;",
|
||||
"a": "color:#213021;text-decoration:underline;",
|
||||
}
|
||||
# Tags browsers emit that we fold onto our canonical equivalents.
|
||||
_WELCOME_TAG_ALIASES = {"b": "strong", "i": "em", "div": "p"}
|
||||
# Block tags that establish a paragraph context (so stray text gets wrapped).
|
||||
_WELCOME_BLOCK_TAGS = {"p", "li", "h1", "h2", "h3"}
|
||||
|
||||
|
||||
class _WelcomeBodySanitizer(HTMLParser):
|
||||
"""Reduce the contenteditable HTML the owner produces to a small, email-safe
|
||||
subset (paragraphs, bold/italic/underline, lists and http(s)/mailto links),
|
||||
re-emitting every tag with our inline styles and dropping everything else."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts: list[str] = []
|
||||
self.open_tags: list[str] = []
|
||||
|
||||
def _in_block(self) -> bool:
|
||||
return any(t in _WELCOME_BLOCK_TAGS for t in self.open_tags)
|
||||
|
||||
def _in_list(self) -> bool:
|
||||
return any(t in ("ul", "ol") for t in self.open_tags)
|
||||
|
||||
def _emit_open(self, tag: str) -> None:
|
||||
style = _WELCOME_BODY_STYLES.get(tag, "")
|
||||
self.parts.append(f'<{tag} style="{style}">' if style else f"<{tag}>")
|
||||
self.open_tags.append(tag)
|
||||
|
||||
def _emit_close(self, tag: str) -> None:
|
||||
if tag not in self.open_tags:
|
||||
return
|
||||
while self.open_tags:
|
||||
current = self.open_tags.pop()
|
||||
self.parts.append(f"</{current}>")
|
||||
if current == tag:
|
||||
break
|
||||
|
||||
def _ensure_paragraph(self) -> None:
|
||||
if not self._in_block() and not self._in_list():
|
||||
self._emit_open("p")
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tag = _WELCOME_TAG_ALIASES.get(tag, tag)
|
||||
if tag == "br":
|
||||
self._ensure_paragraph()
|
||||
self.parts.append("<br>")
|
||||
return
|
||||
if tag in ("p", "ul", "ol", "h1", "h2", "h3"):
|
||||
# A new block ends any paragraph/heading we were implicitly inside.
|
||||
if self._in_block():
|
||||
for candidate in ("p", "li", "h1", "h2", "h3"):
|
||||
if candidate in self.open_tags:
|
||||
self._emit_close(candidate)
|
||||
break
|
||||
self._emit_open(tag)
|
||||
return
|
||||
if tag == "li":
|
||||
self._emit_open("li")
|
||||
return
|
||||
if tag in ("strong", "em", "u"):
|
||||
self._ensure_paragraph()
|
||||
self._emit_open(tag)
|
||||
return
|
||||
if tag == "a":
|
||||
href = ""
|
||||
for name, value in attrs:
|
||||
if name == "href" and value:
|
||||
href = value.strip()
|
||||
if not re.match(r"^(https?:|mailto:)", href, re.IGNORECASE):
|
||||
return # drop unsafe/relative links, keep their text
|
||||
self._ensure_paragraph()
|
||||
self.parts.append(f'<a href="{_escape_attr(href)}" style="{_WELCOME_BODY_STYLES["a"]}">')
|
||||
self.open_tags.append("a")
|
||||
return
|
||||
# Any other tag (span, font, h1…): ignored, but its text is kept.
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
tag = _WELCOME_TAG_ALIASES.get(tag, tag)
|
||||
if tag in ("p", "ul", "ol", "li", "strong", "em", "u", "a", "h1", "h2", "h3"):
|
||||
self._emit_close(tag)
|
||||
|
||||
def handle_data(self, data):
|
||||
if not data.strip() and not self._in_block():
|
||||
return # ignore whitespace between block tags
|
||||
self._ensure_paragraph()
|
||||
self.parts.append(
|
||||
data.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
|
||||
def result(self) -> str:
|
||||
while self.open_tags:
|
||||
self.parts.append(f"</{self.open_tags.pop()}>")
|
||||
html = "".join(self.parts)
|
||||
# Drop empty paragraphs the editor leaves behind (e.g. trailing blank line).
|
||||
html = re.sub(r"<p[^>]*>(?:\s|<br>| )*</p>", "", html)
|
||||
return html.strip()
|
||||
|
||||
|
||||
def _sanitize_welcome_body_html(raw: str) -> str:
|
||||
"""Public entry point: parse and re-emit owner rich-text as email-safe HTML."""
|
||||
if not (raw or "").strip():
|
||||
return ""
|
||||
parser = _WelcomeBodySanitizer()
|
||||
parser.feed(raw)
|
||||
parser.close()
|
||||
return parser.result()
|
||||
|
||||
|
||||
def _strip_legacy_welcome_tag_html(raw: str) -> str:
|
||||
"""Remove the old frontend-injected yellow tag block if it was saved into
|
||||
bodyHtml before tagLabel became a first-class server-rendered field."""
|
||||
if not (raw or "").strip():
|
||||
return ""
|
||||
return re.sub(
|
||||
r'^\s*<div[^>]*>\s*<span[^>]*background:\s*#f4e7a8;[^>]*>.*?</span>\s*</div>\s*',
|
||||
'',
|
||||
raw,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
).strip()
|
||||
|
||||
|
||||
def _welcome_cta_html(label: str, url: str) -> str:
|
||||
"""The dark-green pill button used at the foot of the welcome email."""
|
||||
label = (label or "").strip()
|
||||
url = (url or "").strip()
|
||||
if not (label and url):
|
||||
return ""
|
||||
safe_label = label.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
return (
|
||||
f'<a href="{_escape_attr(url)}" style="display:inline-block;background:#213021;color:#ffffff;'
|
||||
"text-decoration:none;border-radius:999px;padding:14px 20px;font-family:"
|
||||
f'{WELCOME_FONT_STACK};font-size:15px;font-weight:700;">{safe_label}</a>'
|
||||
)
|
||||
|
||||
|
||||
def _welcome_pack_email_html(
|
||||
client_name: str,
|
||||
dog_name: str,
|
||||
service_type: str,
|
||||
price_details: str,
|
||||
start_date: str,
|
||||
*,
|
||||
heading: str = "",
|
||||
intro: str = "",
|
||||
outro: str = "",
|
||||
tag_label: str = "",
|
||||
cta_label: str = WELCOME_CTA_DEFAULT_LABEL,
|
||||
cta_url: str = WELCOME_CTA_DEFAULT_URL,
|
||||
show_details: bool = True,
|
||||
body_html: str = "",
|
||||
include_button: bool = True,
|
||||
) -> str:
|
||||
first_name = client_name.split()[0] if client_name.strip() else "there"
|
||||
dog_line = f" for {dog_name}" if dog_name.strip() else ""
|
||||
formatted_start_date = _format_date_label(start_date)
|
||||
|
||||
# Preferred path: the owner wrote a free-form message in the WYSIWYG editor.
|
||||
# We sanitise it, substitute the personalisation tokens, and (optionally) add
|
||||
# the onboarding button beneath it — no forced heading or details table.
|
||||
clean_body = _sanitize_welcome_body_html(_strip_legacy_welcome_tag_html(body_html))
|
||||
if clean_body:
|
||||
clean_body = _apply_welcome_tokens(clean_body, first_name, dog_name)
|
||||
# The "Badge" field drives the single header badge in the shell — it is not a
|
||||
# second in-body pill, so the email never shows the badge text twice.
|
||||
badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name)
|
||||
cta_html = _welcome_cta_html(WELCOME_CTA_DEFAULT_LABEL, WELCOME_CTA_DEFAULT_URL) if include_button else ""
|
||||
# Only add a top margin when there's a badge to sit beneath; otherwise the
|
||||
# cell's own top padding is enough and a top margin would leave an empty
|
||||
# gap where the badge would have been.
|
||||
body_top_margin = "16px" if badge_label else "0"
|
||||
content_inner = f'<div style="margin:{body_top_margin} 0 22px;">{clean_body}</div>{cta_html}'
|
||||
return _welcome_pack_shell(content_inner, tag_label=badge_label)
|
||||
|
||||
# No message written and no legacy structured copy supplied: render just the
|
||||
# email chrome (logo + badge) so the live preview stays genuinely empty until
|
||||
# the owner writes something, rather than falling back to canned default copy.
|
||||
if not any(((heading or "").strip(), (intro or "").strip(), (outro or "").strip())):
|
||||
badge_label = _apply_welcome_tokens((tag_label or "").strip(), first_name, dog_name)
|
||||
return _welcome_pack_shell("", tag_label=badge_label)
|
||||
|
||||
# Legacy structured path: each block falls back to the original default copy
|
||||
# when blank, so older callers that pass heading/intro/outro look unchanged.
|
||||
heading_text = _apply_welcome_tokens(
|
||||
(heading or "").strip()
|
||||
or f"Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk.",
|
||||
first_name,
|
||||
dog_name,
|
||||
)
|
||||
intro_text = _apply_welcome_tokens(
|
||||
(intro or "").strip()
|
||||
or (
|
||||
f"We’ve set aside the details below{dog_line}. When you’re ready, "
|
||||
"complete your onboarding form and we’ll take it from there."
|
||||
),
|
||||
first_name,
|
||||
dog_name,
|
||||
)
|
||||
outro_text = _apply_welcome_tokens(
|
||||
(outro or "").strip()
|
||||
or (
|
||||
"Use the same email address you originally used with Goodwalk. "
|
||||
"We’ll send you a one-time code when you sign in."
|
||||
),
|
||||
first_name,
|
||||
dog_name,
|
||||
)
|
||||
|
||||
intro_html = _welcome_paragraphs(intro_text, "#4b584b")
|
||||
outro_html = ""
|
||||
if outro_text:
|
||||
outro_html = (
|
||||
f'<p style="margin:18px 0 0;font-family:{WELCOME_FONT_STACK};font-size:14px;'
|
||||
f'line-height:1.7;color:#657365;">{_html_breaks(outro_text)}</p>'
|
||||
)
|
||||
|
||||
# The details table is optional — when hidden, or when every row is blank, it
|
||||
# is dropped entirely so a free-text email reads cleanly.
|
||||
details_rows = ""
|
||||
if show_details:
|
||||
details_rows = (
|
||||
_detail_row("Service", service_type)
|
||||
+ _detail_row("Price", price_details)
|
||||
+ _detail_row("Start date", formatted_start_date if start_date.strip() else "")
|
||||
)
|
||||
details_html = ""
|
||||
if details_rows:
|
||||
details_html = f"""
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
|
||||
<tr><td style="padding:22px 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{details_rows}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>"""
|
||||
|
||||
# The button block keeps the welcome email's dark-green pill styling but lets
|
||||
# the owner relabel it and point it anywhere. Clearing either field hides it.
|
||||
# The standard default is materialised by callers (see _welcome_pack_payload),
|
||||
# so an explicit blank here genuinely means "no button".
|
||||
label = (cta_label or "").strip()
|
||||
url = (cta_url or "").strip()
|
||||
cta_html = ""
|
||||
if label and url:
|
||||
safe_url = _escape_attr(url)
|
||||
safe_label = label.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
cta_html = (
|
||||
f'<a href="{safe_url}" style="display:inline-block;background:#213021;color:#ffffff;'
|
||||
"text-decoration:none;border-radius:999px;padding:14px 20px;font-family:"
|
||||
f'{WELCOME_FONT_STACK};font-size:15px;font-weight:700;">{safe_label}</a>'
|
||||
)
|
||||
|
||||
# The legacy structured path never renders a header badge, so the heading
|
||||
# has no top margin — the cell's top padding alone sets the spacing.
|
||||
heading_html = (
|
||||
f'<h1 style="margin:0 0 12px;font-family:{WELCOME_FONT_STACK};font-size:32px;'
|
||||
f'line-height:1.05;letter-spacing:-0.03em;color:#171b20;">{heading_text}</h1>'
|
||||
)
|
||||
content_inner = f"{heading_html}{intro_html}{details_html}{cta_html}{outro_html}"
|
||||
return _welcome_pack_shell(content_inner)
|
||||
|
||||
|
||||
def _welcome_pack_shell(content_inner: str, *, tag_label: str = "") -> str:
|
||||
"""The shared welcome-email chrome (logo header, optional header badge, rounded
|
||||
card) wrapped around whatever message content is supplied. The badge text is
|
||||
owner-editable via the "Badge" field; when it is left blank, no badge is shown.
|
||||
This is the email's only tag — there is no second in-body pill."""
|
||||
badge_label = (tag_label or "").strip()
|
||||
badge_html = (
|
||||
'<div style="display:inline-block;background:#ffd100;border-radius:999px;'
|
||||
f'padding:8px 14px;margin-bottom:4px;font-family:{WELCOME_FONT_STACK};font-size:12px;'
|
||||
'font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">'
|
||||
f'{html.escape(badge_label)}</div>'
|
||||
if badge_label
|
||||
else ""
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -2407,33 +2799,13 @@ def _welcome_pack_email_html(client_name: str, dog_name: str, service_type: str,
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#fbfaf7;padding:34px 24px 30px;">
|
||||
<div style="display:inline-block;background:#ffd100;border-radius:999px;padding:8px 14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;font-weight:700;color:#213021;letter-spacing:0.06em;text-transform:uppercase;">
|
||||
Welcome to the pack
|
||||
</div>
|
||||
<h1 style="margin:18px 0 12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:32px;line-height:1.05;letter-spacing:-0.03em;color:#171b20;">
|
||||
Hi {first_name}, we’d love to get {dog_name or 'your dog'} started with Goodwalk.
|
||||
</h1>
|
||||
<p style="margin:0 0 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;line-height:1.7;color:#4b584b;">
|
||||
We’ve set aside the details below{dog_line}. When you’re ready, complete your onboarding form and we’ll take it from there.
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:18px;border:1px solid rgba(33,48,33,0.08);margin-bottom:22px;">
|
||||
<tr><td style="padding:22px 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_detail_row("Service", service_type)}
|
||||
{_detail_row("Price", price_details)}
|
||||
{_detail_row("Start date", formatted_start_date)}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<a href="https://clients.goodwalk.co.nz/" style="display:inline-block;background:#213021;color:#ffffff;text-decoration:none;border-radius:999px;padding:14px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:15px;font-weight:700;">
|
||||
Complete onboarding
|
||||
</a>
|
||||
|
||||
<p style="margin:18px 0 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.7;color:#657365;">
|
||||
Use the same email address you originally used with Goodwalk. We’ll send you a one-time code when you sign in.
|
||||
</p>
|
||||
{badge_html}
|
||||
{content_inner}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#213021;padding:22px 24px;text-align:center;">
|
||||
{_email_footer_html()}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -2575,6 +2947,11 @@ def _birthday_email_html(client_name: str, dog_name: str) -> str:
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#213021;padding:22px 24px;text-align:center;">
|
||||
{_email_footer_html()}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
@@ -2635,6 +3012,7 @@ async def _send_birthday_email_for_profile(
|
||||
mark_auto_year: int | None = None,
|
||||
preview: bool = False,
|
||||
preview_recipients: list[str] | None = None,
|
||||
subject: str = "",
|
||||
) -> None:
|
||||
client_name = str(profile.get("fullName", "")).strip()
|
||||
dog = _get_selected_dog(profile, dog_id)
|
||||
@@ -2642,7 +3020,7 @@ async def _send_birthday_email_for_profile(
|
||||
raise HTTPException(status_code=400, detail="This client does not have a dog on file.")
|
||||
dog_name = str(dog.get("name", "")).strip()
|
||||
recipients = _resolve_preview_recipients(preview_recipients) if preview else [email]
|
||||
subject = f"Happy birthday {dog_name or 'from Goodwalk'}"
|
||||
subject = (subject or "").strip() or f"Happy birthday {dog_name or 'from Goodwalk'}"
|
||||
if preview:
|
||||
subject = f"[PREVIEW for {client_name or email}] {subject}"
|
||||
payload = {
|
||||
@@ -3346,7 +3724,8 @@ def _render_message_html(
|
||||
<tr>
|
||||
<td align="center" style="background:{footer_bg};padding:22px 24px 18px;font-family:{font_stack};font-size:12px;line-height:1.6;color:{footer_text_color};">
|
||||
<div style="font-size:14px;letter-spacing:0.3em;margin-bottom:8px;color:{footer_text_color};">{ornament_bottom or '🐾 · 🐾 · 🐾'}</div>
|
||||
{('<div style="font-weight:600;">' + fn + '</div>') if fn else ''}
|
||||
{('<div style="font-weight:600;margin-bottom:10px;">' + fn + '</div>') if fn else ''}
|
||||
{_email_footer_html(text_color=footer_text_color, link_color=footer_text_color, font_stack=font_stack)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3415,12 +3794,22 @@ async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Requ
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
|
||||
custom = _welcome_custom_fields(data)
|
||||
html = _welcome_pack_email_html(
|
||||
owner_name,
|
||||
dog_name,
|
||||
_trimmed(data.serviceType),
|
||||
_trimmed(data.priceDetails),
|
||||
_trimmed(data.startDate),
|
||||
heading=str(custom["heading"]),
|
||||
intro=str(custom["intro"]),
|
||||
outro=str(custom["outro"]),
|
||||
tag_label=str(custom["tagLabel"]),
|
||||
cta_label=str(custom["ctaLabel"]),
|
||||
cta_url=str(custom["ctaUrl"]),
|
||||
show_details=bool(custom["showDetails"]),
|
||||
body_html=str(custom["bodyHtml"]),
|
||||
include_button=bool(custom["includeButton"]),
|
||||
)
|
||||
return {"ok": True, "html": html}
|
||||
|
||||
@@ -4030,6 +4419,31 @@ async def owner_client_profile(data: ClientProfileUpdate, request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/owner/delete-client")
|
||||
async def owner_delete_client(data: DeleteClientRequest, request: Request):
|
||||
"""Permanently delete a client and revoke their access to the onboarding form."""
|
||||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||
owner_email = await _require_owner_email(request)
|
||||
|
||||
email = str(data.email).strip().lower()
|
||||
removed = await _delete_client_profile(email)
|
||||
|
||||
await admin_db.record_event(
|
||||
event_type="owner_client_deleted",
|
||||
request_id=request_id,
|
||||
actor_email=owner_email,
|
||||
ip=_get_ip(request),
|
||||
status="ok",
|
||||
detail={
|
||||
"email": email,
|
||||
"fullName": removed.get("fullName", "") if isinstance(removed, dict) else "",
|
||||
"dogName": removed.get("dogName", "") if isinstance(removed, dict) else "",
|
||||
},
|
||||
)
|
||||
|
||||
return {"ok": True, "email": email}
|
||||
|
||||
|
||||
@app.post("/owner/add-client")
|
||||
async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
"""Create a client by hand (leads from Instagram / Facebook / referrals that
|
||||
@@ -4046,6 +4460,7 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
dog_breed = str(data.dogBreed).strip()
|
||||
dog_age = str(data.dogAge).strip()
|
||||
source = str(data.source).strip()
|
||||
joining_date = str(data.joiningDate).strip()
|
||||
|
||||
if not full_name:
|
||||
raise HTTPException(status_code=400, detail="Please enter the owner name.")
|
||||
@@ -4067,6 +4482,9 @@ async def owner_add_client(data: NewClientRequest, request: Request):
|
||||
"dogBreed": dog_breed,
|
||||
"dogAge": dog_age,
|
||||
"source": source,
|
||||
# Owner-supplied date the client joined Goodwalk (falls back to the
|
||||
# add timestamp). Drives the joining-anniversaries view.
|
||||
"joiningDate": joining_date or added_at[:10],
|
||||
# Treated like an enquiry timestamp so the client surfaces in the
|
||||
# pending-onboarding and recent-clients views straight away.
|
||||
"lastEnquiryAt": added_at,
|
||||
@@ -4352,6 +4770,22 @@ def _default_welcome_subject(dog_name: str) -> str:
|
||||
return f"Goodwalk Onboarding - {name}" if name else "Goodwalk Onboarding"
|
||||
|
||||
|
||||
def _welcome_custom_fields(data: WelcomePackEmailRequest) -> dict:
|
||||
"""Pull the owner's customisation fields off the request into a plain dict
|
||||
that travels with the offer (live send, preview, and scheduled queue)."""
|
||||
return {
|
||||
"tagLabel": _trimmed(data.tagLabel),
|
||||
"heading": _trimmed(data.heading),
|
||||
"intro": _trimmed(data.intro),
|
||||
"outro": _trimmed(data.outro),
|
||||
"ctaLabel": _trimmed(data.ctaLabel),
|
||||
"ctaUrl": _trimmed(data.ctaUrl),
|
||||
"showDetails": bool(data.showDetails),
|
||||
"bodyHtml": _trimmed(data.bodyHtml),
|
||||
"includeButton": bool(data.includeButton),
|
||||
}
|
||||
|
||||
|
||||
def _welcome_pack_payload(
|
||||
email: str,
|
||||
owner_name: str,
|
||||
@@ -4363,22 +4797,45 @@ def _welcome_pack_payload(
|
||||
subject: str,
|
||||
is_preview: bool,
|
||||
recipients: list[str],
|
||||
custom: dict | None = None,
|
||||
cc: list[str] | None = None,
|
||||
bcc_extra: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Assemble the Resend payload for a welcome-pack email. Shared by the
|
||||
immediate send, the preview path, and the scheduled-email worker."""
|
||||
subject = (subject or "").strip() or _default_welcome_subject(dog_name)
|
||||
if is_preview:
|
||||
subject = f"[PREVIEW for {owner_name or email}] {subject}"
|
||||
custom = custom or {}
|
||||
payload = {
|
||||
"from": FROM_EMAIL,
|
||||
"to": recipients,
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": subject,
|
||||
"html": _welcome_pack_email_html(owner_name, dog_name, service, price, start),
|
||||
"html": _welcome_pack_email_html(
|
||||
owner_name, dog_name, service, price, start,
|
||||
heading=str(custom.get("heading", "")),
|
||||
intro=str(custom.get("intro", "")),
|
||||
outro=str(custom.get("outro", "")),
|
||||
tag_label=str(custom.get("tagLabel", "")),
|
||||
# Missing keys (legacy scheduled entries) fall back to the standard
|
||||
# button; a present-but-empty value is an explicit "hide the button".
|
||||
cta_label=str(custom.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)),
|
||||
cta_url=str(custom.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)),
|
||||
show_details=bool(custom.get("showDetails", True)),
|
||||
body_html=str(custom.get("bodyHtml", "")),
|
||||
include_button=bool(custom.get("includeButton", True)),
|
||||
),
|
||||
}
|
||||
bcc = _client_bcc_list()
|
||||
bcc = list(_client_bcc_list())
|
||||
if bcc_extra and not is_preview:
|
||||
for addr in bcc_extra:
|
||||
if addr and addr not in bcc:
|
||||
bcc.append(addr)
|
||||
if bcc and not is_preview:
|
||||
payload["bcc"] = bcc
|
||||
if cc and not is_preview:
|
||||
payload["cc"] = cc
|
||||
return payload
|
||||
|
||||
|
||||
@@ -4390,6 +4847,9 @@ async def _deliver_welcome_pack(
|
||||
start: str,
|
||||
subject: str,
|
||||
request_id: str,
|
||||
custom: dict | None = None,
|
||||
cc: list[str] | None = None,
|
||||
bcc_extra: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Send the welcome pack to the client now and record it on the profile.
|
||||
Returns the sentAt timestamp. Raises on send failure (caller decides how to
|
||||
@@ -4397,10 +4857,11 @@ async def _deliver_welcome_pack(
|
||||
two paths can never drift apart."""
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
custom = custom or {}
|
||||
sent_at = datetime.now().isoformat(timespec="seconds")
|
||||
payload = _welcome_pack_payload(
|
||||
email, owner_name, dog_name, service, price, start,
|
||||
subject=subject, is_preview=False, recipients=[email],
|
||||
subject=subject, is_preview=False, recipients=[email], custom=custom, cc=cc, bcc_extra=bcc_extra,
|
||||
)
|
||||
await _send_email(payload, label="welcome_pack_email", request_id=request_id)
|
||||
await _store_client_profile(email, {
|
||||
@@ -4409,6 +4870,16 @@ async def _deliver_welcome_pack(
|
||||
"serviceType": service,
|
||||
"priceDetails": price,
|
||||
"startDate": start,
|
||||
"subject": subject,
|
||||
"tagLabel": str(custom.get("tagLabel", "")),
|
||||
"heading": str(custom.get("heading", "")),
|
||||
"intro": str(custom.get("intro", "")),
|
||||
"outro": str(custom.get("outro", "")),
|
||||
"ctaLabel": str(custom.get("ctaLabel", "")),
|
||||
"ctaUrl": str(custom.get("ctaUrl", "")),
|
||||
"showDetails": bool(custom.get("showDetails", True)),
|
||||
"bodyHtml": str(custom.get("bodyHtml", "")),
|
||||
"includeButton": bool(custom.get("includeButton", True)),
|
||||
"sentAt": sent_at,
|
||||
},
|
||||
})
|
||||
@@ -4425,8 +4896,10 @@ async def _enqueue_scheduled_welcome(
|
||||
subject: str,
|
||||
scheduled_for: datetime,
|
||||
owner_email: str,
|
||||
custom: dict | None = None,
|
||||
) -> dict:
|
||||
"""Add a welcome email to the durable queue for later delivery."""
|
||||
custom = custom or {}
|
||||
entry_id = uuid.uuid4().hex
|
||||
entry = {
|
||||
"id": entry_id,
|
||||
@@ -4436,6 +4909,12 @@ async def _enqueue_scheduled_welcome(
|
||||
"serviceType": service,
|
||||
"priceDetails": price,
|
||||
"startDate": start,
|
||||
"heading": str(custom.get("heading", "")),
|
||||
"intro": str(custom.get("intro", "")),
|
||||
"outro": str(custom.get("outro", "")),
|
||||
"ctaLabel": str(custom.get("ctaLabel", "")),
|
||||
"ctaUrl": str(custom.get("ctaUrl", "")),
|
||||
"showDetails": bool(custom.get("showDetails", True)),
|
||||
"clientName": str(profile.get("fullName", "")).strip(),
|
||||
"dogName": str(profile.get("dogName", "")).strip(),
|
||||
"scheduledFor": scheduled_for.isoformat(timespec="minutes"),
|
||||
@@ -4495,6 +4974,16 @@ async def _run_scheduled_sender_once() -> None:
|
||||
str(entry.get("startDate", "")),
|
||||
str(entry.get("subject", "")),
|
||||
request_id,
|
||||
custom={
|
||||
"heading": str(entry.get("heading", "")),
|
||||
"intro": str(entry.get("intro", "")),
|
||||
"outro": str(entry.get("outro", "")),
|
||||
# Pre-customisation entries have no cta keys → standard
|
||||
# button; newer entries store explicit values (incl. "").
|
||||
"ctaLabel": str(entry.get("ctaLabel", WELCOME_CTA_DEFAULT_LABEL)),
|
||||
"ctaUrl": str(entry.get("ctaUrl", WELCOME_CTA_DEFAULT_URL)),
|
||||
"showDetails": bool(entry.get("showDetails", True)),
|
||||
},
|
||||
)
|
||||
entry["status"] = "sent"
|
||||
entry["sentAt"] = sent_at
|
||||
@@ -4545,19 +5034,26 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
raise HTTPException(status_code=404, detail="Client profile not found.")
|
||||
if profile.get("onboardingCompleted"):
|
||||
raise HTTPException(status_code=400, detail="This client has already completed onboarding.")
|
||||
if not _trimmed(data.serviceType):
|
||||
raise HTTPException(status_code=400, detail="Please enter a service.")
|
||||
if not _trimmed(data.priceDetails):
|
||||
raise HTTPException(status_code=400, detail="Please enter the price details.")
|
||||
if not _trimmed(data.startDate):
|
||||
raise HTTPException(status_code=400, detail="Please enter a start date.")
|
||||
|
||||
# The email is now fully customisable, so the structured offer fields are
|
||||
# optional. We only require that the email isn't completely empty.
|
||||
service = _trimmed(data.serviceType)
|
||||
price = _trimmed(data.priceDetails)
|
||||
start = _trimmed(data.startDate)
|
||||
custom = _welcome_custom_fields(data)
|
||||
subject = _trimmed(data.subject) or _default_welcome_subject(str(profile.get("dogName", "")))
|
||||
is_preview = bool(data.preview)
|
||||
|
||||
has_body = bool(
|
||||
_sanitize_welcome_body_html(str(custom["bodyHtml"]))
|
||||
or custom["heading"]
|
||||
or custom["intro"]
|
||||
or custom["outro"]
|
||||
)
|
||||
has_details = bool(custom["showDetails"] and (service or price or start))
|
||||
if not has_body and not has_details:
|
||||
raise HTTPException(status_code=400, detail="Please add some content or offer details to the email.")
|
||||
|
||||
# Scheduled send: queue it for the worker rather than sending now. Previews
|
||||
# are always immediate — there's nothing to schedule about a test email.
|
||||
if data.scheduledFor and not is_preview:
|
||||
@@ -4566,7 +5062,7 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
raise HTTPException(status_code=400, detail="Please choose a time in the future to schedule the email.")
|
||||
entry = await _enqueue_scheduled_welcome(
|
||||
email=email, profile=profile, service=service, price=price, start=start,
|
||||
subject=subject, scheduled_for=when, owner_email=owner_email,
|
||||
subject=subject, scheduled_for=when, owner_email=owner_email, custom=custom,
|
||||
)
|
||||
logger.info("[%s] welcome pack scheduled: email=%s for=%s id=%s", request_id, email, entry["scheduledFor"], entry["id"])
|
||||
await admin_db.record_event(
|
||||
@@ -4582,7 +5078,7 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
recipients = _resolve_preview_recipients([str(e) for e in (data.previewRecipients or [])])
|
||||
payload = _welcome_pack_payload(
|
||||
email, owner_name, dog_name, service, price, start,
|
||||
subject=subject, is_preview=True, recipients=recipients,
|
||||
subject=subject, is_preview=True, recipients=recipients, custom=custom,
|
||||
)
|
||||
try:
|
||||
await _send_email(payload, label="welcome_pack_email_preview", request_id=request_id)
|
||||
@@ -4599,8 +5095,10 @@ async def owner_send_welcome_pack(data: WelcomePackEmailRequest, request: Reques
|
||||
logger.info("[%s] welcome pack PREVIEW sent: original_recipient=%s -> owner", request_id, email)
|
||||
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": True}
|
||||
|
||||
cc_list = [owner_email] if (bool(data.ccOwner) and owner_email) else None
|
||||
bcc_list = [owner_email] if (bool(data.bccOwner) and owner_email) else None
|
||||
try:
|
||||
sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id)
|
||||
sent_at = await _deliver_welcome_pack(email, profile, service, price, start, subject, request_id, custom=custom, cc=cc_list, bcc_extra=bcc_list)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] welcome pack email failed: %s", request_id, exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
@@ -4727,6 +5225,7 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
|
||||
dog_id=data.dogId,
|
||||
preview=bool(data.preview),
|
||||
preview_recipients=[str(e) for e in (data.previewRecipients or [])],
|
||||
subject=str(data.subject or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] birthday email failed: %s", request_id, exc, exc_info=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
export let value = '';
|
||||
export let disabled = false;
|
||||
export let placeholder = 'Write your message…';
|
||||
// Optional one-tap insert chips, e.g. the client's first name / dog name.
|
||||
export let tokens: { label: string; value: string }[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher<{ input: string }>();
|
||||
|
||||
let editor: HTMLDivElement;
|
||||
let focused = false;
|
||||
// Remember the HTML we last pushed into the DOM so external `value` changes
|
||||
// (presets, reopening the dialog) update the editor without fighting the
|
||||
// caret while the owner is actively typing.
|
||||
let lastSet = '';
|
||||
|
||||
function sync() {
|
||||
const html = editor?.innerHTML ?? '';
|
||||
value = html;
|
||||
lastSet = html;
|
||||
dispatch('input', html);
|
||||
}
|
||||
|
||||
$: if (editor && value !== lastSet && !focused) {
|
||||
editor.innerHTML = value || '';
|
||||
lastSet = value;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (editor) {
|
||||
editor.innerHTML = value || '';
|
||||
lastSet = value || '';
|
||||
}
|
||||
});
|
||||
|
||||
function exec(command: string, arg?: string) {
|
||||
if (disabled) return;
|
||||
editor?.focus();
|
||||
document.execCommand(command, false, arg);
|
||||
sync();
|
||||
}
|
||||
|
||||
// Turn the selected line(s) into a heading (or back to a normal paragraph).
|
||||
// The bracketed tag form is the one browsers accept consistently for formatBlock.
|
||||
function setBlock(tag: string) {
|
||||
if (disabled) return;
|
||||
editor?.focus();
|
||||
document.execCommand('formatBlock', false, `<${tag}>`);
|
||||
sync();
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
if (disabled) return;
|
||||
editor?.focus();
|
||||
const url = window.prompt('Link address (e.g. https://goodwalk.co.nz)');
|
||||
if (!url) return;
|
||||
const trimmed = url.trim();
|
||||
const href = /^(https?:|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
document.execCommand('createLink', false, href);
|
||||
sync();
|
||||
}
|
||||
|
||||
function insertToken(token: string) {
|
||||
if (disabled) return;
|
||||
editor?.focus();
|
||||
document.execCommand('insertText', false, token);
|
||||
sync();
|
||||
}
|
||||
|
||||
// Plain-text view of the content drives the placeholder + lets the parent
|
||||
// validate that the message isn't empty.
|
||||
$: plainText = (value || '').replace(/<[^>]*>/g, '').replace(/ /g, ' ').trim();
|
||||
$: isEmpty = plainText.length === 0;
|
||||
</script>
|
||||
|
||||
<div class="rte" class:rte-disabled={disabled}>
|
||||
<div class="rte-toolbar">
|
||||
<button type="button" class="rte-btn rte-btn-text" title="Large heading" aria-label="Large heading" on:click={() => setBlock('h1')} disabled={disabled}>
|
||||
H1
|
||||
</button>
|
||||
<button type="button" class="rte-btn rte-btn-text" title="Medium heading" aria-label="Medium heading" on:click={() => setBlock('h2')} disabled={disabled}>
|
||||
H2
|
||||
</button>
|
||||
<button type="button" class="rte-btn rte-btn-text" title="Small heading" aria-label="Small heading" on:click={() => setBlock('h3')} disabled={disabled}>
|
||||
H3
|
||||
</button>
|
||||
<button type="button" class="rte-btn" title="Normal text" aria-label="Normal text" on:click={() => setBlock('p')} disabled={disabled}>
|
||||
<Icon name="fas fa-paragraph" />
|
||||
</button>
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<button type="button" class="rte-btn" title="Bold" aria-label="Bold" on:click={() => exec('bold')} disabled={disabled}>
|
||||
<Icon name="fas fa-bold" />
|
||||
</button>
|
||||
<button type="button" class="rte-btn" title="Italic" aria-label="Italic" on:click={() => exec('italic')} disabled={disabled}>
|
||||
<Icon name="fas fa-italic" />
|
||||
</button>
|
||||
<button type="button" class="rte-btn" title="Bulleted list" aria-label="Bulleted list" on:click={() => exec('insertUnorderedList')} disabled={disabled}>
|
||||
<Icon name="fas fa-list-ul" />
|
||||
</button>
|
||||
<button type="button" class="rte-btn" title="Add a link" aria-label="Add a link" on:click={addLink} disabled={disabled}>
|
||||
<Icon name="fas fa-link" />
|
||||
</button>
|
||||
{#if tokens.length}
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
{#each tokens as t}
|
||||
<button type="button" class="rte-token" on:click={() => insertToken(t.value)} disabled={disabled}>
|
||||
+ {t.label}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rte-surface">
|
||||
<div
|
||||
class="rte-input"
|
||||
bind:this={editor}
|
||||
contenteditable={!disabled}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Message"
|
||||
tabindex="0"
|
||||
on:input={sync}
|
||||
on:focus={() => (focused = true)}
|
||||
on:blur={() => { focused = false; sync(); }}
|
||||
></div>
|
||||
{#if isEmpty}
|
||||
<div class="rte-placeholder" aria-hidden="true">{placeholder}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rte {
|
||||
border: 1px solid rgba(33, 48, 33, 0.16);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rte-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rte-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #f4f5f2;
|
||||
border-bottom: 1px solid rgba(33, 48, 33, 0.1);
|
||||
}
|
||||
|
||||
.rte-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(33, 48, 33, 0.14);
|
||||
background: #fff;
|
||||
color: #213021;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rte-btn:hover:not(:disabled) {
|
||||
background: var(--yellow, #ffd100);
|
||||
border-color: var(--yellow, #ffd100);
|
||||
}
|
||||
|
||||
.rte-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rte-btn-text {
|
||||
width: auto;
|
||||
min-width: 32px;
|
||||
padding: 0 9px;
|
||||
font-family: var(--font-head, inherit);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.rte-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: rgba(33, 48, 33, 0.16);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.rte-token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed rgba(33, 48, 33, 0.28);
|
||||
background: #fff;
|
||||
color: #213021;
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rte-token:hover:not(:disabled) {
|
||||
background: #213021;
|
||||
border-color: #213021;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rte-token:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rte-surface {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rte-input {
|
||||
min-height: 180px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #213021;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rte-input:focus {
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 209, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Tidy the rich content the owner produces. */
|
||||
.rte-input :global(p) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.rte-input :global(h1) {
|
||||
margin: 0 0 10px;
|
||||
font-size: 25px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
color: #171b20;
|
||||
}
|
||||
|
||||
.rte-input :global(h2) {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
color: #171b20;
|
||||
}
|
||||
|
||||
.rte-input :global(h3) {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
font-weight: 800;
|
||||
color: #213021;
|
||||
}
|
||||
|
||||
.rte-input :global(ul),
|
||||
.rte-input :global(ol) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.rte-input :global(a) {
|
||||
color: #213021;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rte-placeholder {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #9aa39a;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -847,7 +847,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
||||
<OnboardingFooter on:logout={handleLogout} />
|
||||
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Services-focus homepage variant.
|
||||
*
|
||||
* Same design language as HomeClarity (green hero, yellow CTAs, the shared
|
||||
* ServicesSection / ValuesSection / TestimonialsSection), but every section is
|
||||
* pointed at one job: get the visitor to click through into a service page.
|
||||
*
|
||||
* The site gets traffic but few leads, so this variant removes the big-commitment
|
||||
* booking form from the homepage and lowers the first ask to "pick the walk that
|
||||
* fits your dog". The three services are offered as quick-picks in the hero
|
||||
* itself, repeated as the main ServicesSection cards, reinforced with proof, and
|
||||
* offered a final time in a closing band. A view component only: the route that
|
||||
* renders it owns data loading, SEO head, and structured data.
|
||||
*
|
||||
* Preview route: /variants/services-first (noindex).
|
||||
*/
|
||||
import Footer from '$lib/components/ui/Footer.svelte';
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ServicesSection from '$lib/components/sections/ServicesSection.svelte';
|
||||
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||
import type { HomePageContent } from '$lib/types';
|
||||
|
||||
export let content: HomePageContent;
|
||||
export let pagePath = '/variants/services-first';
|
||||
|
||||
const reviewCta = content.intro.reviewCta;
|
||||
const heroImage = '/images/smiling-happy-dog-maya.webp';
|
||||
const stars = Array.from({ length: 5 });
|
||||
|
||||
// The assurance bar replaces the old grid of value cards: four plain facts,
|
||||
// stated once. Each is a reason to trust the click, not a feature to skim.
|
||||
const assurances = [
|
||||
{ icon: 'fas fa-user-check', label: 'Same walker, every time' },
|
||||
{ icon: 'fas fa-users', label: 'Small, matched groups' },
|
||||
{ icon: 'fas fa-shield-heart', label: 'Pet first-aid certified & insured' },
|
||||
{ icon: 'fas fa-star', label: '5.0 from 30+ owners' }
|
||||
];
|
||||
|
||||
// The hero quick-picks and the closing band both draw from the same services
|
||||
// data the main ServicesSection uses, so the click targets never drift apart.
|
||||
$: services = content.services;
|
||||
</script>
|
||||
|
||||
<Header navigation={content.navigation} />
|
||||
|
||||
<section class="sfhero" data-track-location="hero" aria-label="Goodwalk dog walking">
|
||||
<div class="sfhero-inner">
|
||||
<div class="sfhero-copy">
|
||||
<p class="sfhero-eyebrow">Professional dog walkers</p>
|
||||
<h1 class="sfhero-title">
|
||||
<span class="sfhero-title-main">Come home to a calmer dog.</span>
|
||||
</h1>
|
||||
<p class="sfhero-lead">
|
||||
Small, matched groups for small & medium dogs. The same walker, every time.
|
||||
</p>
|
||||
|
||||
<div class="sfhero-actions">
|
||||
<a
|
||||
href="#services"
|
||||
class="btn btn-yellow btn-with-arrow"
|
||||
data-track-event="cta_click"
|
||||
data-track-type="hero_primary"
|
||||
data-track-label="See our walks"
|
||||
>
|
||||
See our walks
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="sfhero-reviews"
|
||||
href={reviewCta.href}
|
||||
target={reviewCta.external ? '_blank' : undefined}
|
||||
rel={reviewCta.external ? 'noopener' : undefined}
|
||||
aria-label="30+ five-star Google reviews"
|
||||
data-track-event="social_proof_click"
|
||||
data-track-type="hero_reviews"
|
||||
>
|
||||
<span class="sfhero-reviews-mark" aria-hidden="true">
|
||||
<img src="/images/google-g-logo.svg" alt="" width="15" height="16" />
|
||||
</span>
|
||||
<span class="sfhero-reviews-stars" aria-hidden="true">
|
||||
{#each stars as _}
|
||||
<Icon name="fas fa-star" />
|
||||
{/each}
|
||||
</span>
|
||||
<span class="sfhero-reviews-label">30+ five-star reviews</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sfhero-media">
|
||||
<div class="sfhero-photo">
|
||||
<img
|
||||
src={heroImage}
|
||||
alt="Maya, a happy small dog, after a Goodwalk walk in Auckland"
|
||||
width="1536"
|
||||
height="1024"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-picks: the three services as clickable choices, in the hero, above
|
||||
the fold. This is the conversion move — the visitor's first decision is
|
||||
"which walk", not "fill in a form". -->
|
||||
<nav class="sfhero-picks" aria-label="Choose a service">
|
||||
{#each services as service}
|
||||
<a
|
||||
class="sfhero-pick"
|
||||
href={service.href}
|
||||
data-track-event="service_quickpick_click"
|
||||
data-track-type="hero_quickpick"
|
||||
data-track-label={service.title}
|
||||
>
|
||||
<span class="sfhero-pick-glyph" aria-hidden="true">
|
||||
<Icon name={service.icon} />
|
||||
</span>
|
||||
<span class="sfhero-pick-text">
|
||||
<span class="sfhero-pick-title">{service.title}</span>
|
||||
{#if service.priceFrom}
|
||||
<span class="sfhero-pick-price">{service.priceFrom}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Icon name="fas fa-arrow-right" className="sfhero-pick-arrow" />
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The conversion engine: the service cards themselves link straight through to
|
||||
each service page. -->
|
||||
<ServicesSection
|
||||
services={content.services}
|
||||
heading="Choose your dog's walk."
|
||||
intro="Three ways to walk — pack, solo, or puppy visits. Same walker, small groups, built around your dog."
|
||||
/>
|
||||
|
||||
<!-- One calm assurance beat. Replaces the old wall of value cards: four plain
|
||||
facts, stated once, then straight on to proof. No competing form. -->
|
||||
<section class="sfproof" aria-label="What every Goodwalk includes">
|
||||
<ul class="sfproof-row">
|
||||
{#each assurances as item}
|
||||
<li class="sfproof-item">
|
||||
<Icon name={item.icon} className="sfproof-icon" />
|
||||
<span>{item.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<TestimonialsSection testimonials={content.testimonials} seedKey={pagePath} />
|
||||
|
||||
<!-- Closing band: offer the same three choices one more time, on brand green. -->
|
||||
<section class="sfclose" aria-label="Choose your dog's walk">
|
||||
<div class="sfclose-inner">
|
||||
<div class="sfclose-head">
|
||||
<h2 class="sfclose-title">Find the right walk for your dog.</h2>
|
||||
<p class="sfclose-lead">
|
||||
Three options. No commitment. Just pick the one that sounds like your dog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sfclose-grid">
|
||||
{#each services as service}
|
||||
<a
|
||||
class="sfclose-card"
|
||||
href={service.href}
|
||||
data-track-event="service_card_click"
|
||||
data-track-type="closing_band"
|
||||
data-track-label={service.title}
|
||||
>
|
||||
<span class="sfclose-card-glyph" aria-hidden="true">
|
||||
<Icon name={service.icon} />
|
||||
</span>
|
||||
<span class="sfclose-card-title">{service.title}</span>
|
||||
<span class="sfclose-card-body">{service.body}</span>
|
||||
{#if service.priceFrom}
|
||||
<span class="sfclose-card-price">{service.priceFrom}</span>
|
||||
{/if}
|
||||
<span class="sfclose-card-cta">
|
||||
View this walk
|
||||
<Icon name="fas fa-arrow-right" className="sfclose-card-arrow" />
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer footer={content.footer} />
|
||||
|
||||
<style>
|
||||
/* ── Hero ──────────────────────────────────────────────────────────────
|
||||
Mirrors HomeClarity's .chero so the variant reads as the same site:
|
||||
green field, one soft yellow light, identical type scale. The layout
|
||||
adds a third row for the service quick-picks. */
|
||||
|
||||
.sfhero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gw-green);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.sfhero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -28%;
|
||||
right: -10%;
|
||||
width: 60%;
|
||||
height: 90%;
|
||||
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.16), transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sfhero-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 5.5vw, 80px) var(--space-container-x);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
grid-template-areas:
|
||||
'copy media'
|
||||
'picks picks';
|
||||
column-gap: clamp(32px, 5vw, 76px);
|
||||
row-gap: clamp(32px, 4vw, 48px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sfhero-copy {
|
||||
grid-area: copy;
|
||||
}
|
||||
|
||||
.sfhero-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 18px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(var(--white-rgb), 0.7);
|
||||
}
|
||||
|
||||
.sfhero-eyebrow::before {
|
||||
content: '';
|
||||
width: 26px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.sfhero-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
}
|
||||
|
||||
.sfhero-title-main {
|
||||
display: block;
|
||||
font-size: clamp(42px, 6.4vw, 78px);
|
||||
font-weight: 800;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--yellow);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.sfhero-lead {
|
||||
margin: 22px 0 0;
|
||||
max-width: 42ch;
|
||||
font-size: clamp(16px, 1.3vw, 19px);
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--white-rgb), 0.82);
|
||||
}
|
||||
|
||||
.sfhero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px 26px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.sfhero-reviews {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-decoration: none;
|
||||
color: rgba(var(--white-rgb), 0.9);
|
||||
transition: color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sfhero-reviews-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-page);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.sfhero-reviews-mark img {
|
||||
display: block;
|
||||
width: 15px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sfhero-reviews-stars {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
color: var(--yellow);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sfhero-reviews-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--white-rgb), 0.9);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sfhero-reviews:hover {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
}
|
||||
|
||||
.sfhero-reviews:focus-visible {
|
||||
outline: 2px solid var(--yellow);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Media */
|
||||
.sfhero-media {
|
||||
grid-area: media;
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.sfhero-media::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 8% -6% -10% 10%;
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(var(--accent-rgb), 0.22);
|
||||
filter: blur(46px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sfhero-photo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
aspect-ratio: 4 / 5;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--white-rgb), 0.12),
|
||||
var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.sfhero-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 28%;
|
||||
}
|
||||
|
||||
/* ── Hero quick-picks ────────────────────────────────────────────────── */
|
||||
|
||||
.sfhero-picks {
|
||||
grid-area: picks;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding-top: clamp(8px, 1.5vw, 16px);
|
||||
border-top: 1px solid rgba(var(--white-rgb), 0.12);
|
||||
}
|
||||
|
||||
.sfhero-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(var(--white-rgb), 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.1);
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sfhero-pick-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 13px;
|
||||
background: rgba(var(--accent-rgb), 0.16);
|
||||
color: var(--yellow);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sfhero-pick-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sfhero-pick-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sfhero-pick-price {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--white-rgb), 0.66);
|
||||
}
|
||||
|
||||
:global(.sfhero-pick-arrow) {
|
||||
font-size: 12px;
|
||||
color: var(--yellow);
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sfhero-pick:hover {
|
||||
background: rgba(var(--white-rgb), 0.09);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.22);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.sfhero-pick:hover :global(.sfhero-pick-arrow) {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.sfhero-pick:focus-visible {
|
||||
outline: 2px solid var(--yellow);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Entrance — same choreography as the clarity hero. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sfhero-copy,
|
||||
.sfhero-picks {
|
||||
animation: sfheroRise 0.6s var(--ease-out-soft) both;
|
||||
}
|
||||
|
||||
.sfhero-picks {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sfhero-photo {
|
||||
animation: sfheroPhoto 0.8s var(--ease-out-soft) both;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sfheroRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sfheroPhoto {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Assurance bar ─────────────────────────────────────────────────────
|
||||
A single calm strip of facts — the "say it plainly" beat that replaces a
|
||||
long grid of value cards. Warm cream divider between services and proof. */
|
||||
|
||||
.sfproof {
|
||||
background: var(--surface-panel-cream);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.sfproof-row {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-w);
|
||||
padding: clamp(18px, 2.4vw, 26px) var(--space-container-x);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px clamp(24px, 4vw, 52px);
|
||||
}
|
||||
|
||||
.sfproof-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
:global(.sfproof-icon) {
|
||||
font-size: 15px;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
/* ── Closing band ──────────────────────────────────────────────────────
|
||||
Brand-green field that repeats the three service choices. Mirrors the
|
||||
service-card styling so the page closes on the same call it opened with. */
|
||||
|
||||
.sfclose {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gw-green);
|
||||
color: var(--text-inverse);
|
||||
padding: var(--space-section-support-y) 0;
|
||||
}
|
||||
|
||||
.sfclose::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30%;
|
||||
left: -8%;
|
||||
width: 55%;
|
||||
height: 90%;
|
||||
background: radial-gradient(closest-side, rgba(var(--accent-rgb), 0.14), transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sfclose-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: min(1180px, calc(var(--max-w) - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-container-x);
|
||||
}
|
||||
|
||||
.sfclose-head {
|
||||
max-width: 46ch;
|
||||
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sfclose-title {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 4vw, 44px);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-inverse);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.sfclose-lead {
|
||||
margin: 0;
|
||||
font-size: clamp(15px, 1.3vw, 18px);
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--white-rgb), 0.82);
|
||||
}
|
||||
|
||||
.sfclose-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sfclose-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 28px 24px;
|
||||
border-radius: 22px;
|
||||
background: rgba(var(--white-rgb), 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.1);
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.24s ease,
|
||||
box-shadow 0.24s ease,
|
||||
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sfclose-card-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 15px;
|
||||
background: rgba(var(--accent-rgb), 0.16);
|
||||
color: var(--yellow);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sfclose-card-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sfclose-card-body {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--white-rgb), 0.78);
|
||||
}
|
||||
|
||||
.sfclose-card-price {
|
||||
margin-top: 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.sfclose-card-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding-top: 18px;
|
||||
width: 100%;
|
||||
border-top: 1px solid rgba(var(--white-rgb), 0.12);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
:global(.sfclose-card-arrow) {
|
||||
font-size: 11px;
|
||||
color: var(--yellow);
|
||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sfclose-card:hover {
|
||||
background: rgba(var(--white-rgb), 0.09);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--white-rgb), 0.22);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.sfclose-card:hover :global(.sfclose-card-arrow) {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.sfclose-card:focus-visible {
|
||||
outline: 2px solid var(--yellow);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* ── Mobile ────────────────────────────────────────────────────────────
|
||||
Same single-column logic as the clarity hero: message, photo, action,
|
||||
then the picks stack full width. */
|
||||
@media (max-width: 900px) {
|
||||
.sfhero-picks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sfhero-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px var(--space-container-x-mobile) 36px;
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.sfhero-copy {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.sfhero-media {
|
||||
order: 2;
|
||||
justify-self: stretch;
|
||||
max-width: none;
|
||||
margin: 24px 0 26px;
|
||||
}
|
||||
|
||||
.sfhero-picks {
|
||||
order: 3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sfhero-photo {
|
||||
aspect-ratio: 5 / 4;
|
||||
}
|
||||
|
||||
.sfhero-lead {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sfhero-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sfclose-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -123,17 +123,6 @@
|
||||
// Computed legacy field for downstream consumers expecting a single full name.
|
||||
$: fullName = `${ownerFirstName.trim()} ${ownerLastName.trim()}`.trim();
|
||||
|
||||
$: displayName = ownerFirstName.trim() || (userEmail ? userEmail.split('@')[0] : '');
|
||||
|
||||
$: accountInitials = (() => {
|
||||
const first = ownerFirstName.trim();
|
||||
const last = ownerLastName.trim();
|
||||
if (first || last) {
|
||||
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase() || first.charAt(0).toUpperCase();
|
||||
}
|
||||
return (userEmail.charAt(0) || '?').toUpperCase();
|
||||
})();
|
||||
|
||||
let formStartedAt = 0;
|
||||
let visitStartedAt = 0;
|
||||
let pageEnteredAt = 0;
|
||||
@@ -703,12 +692,10 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
</a>
|
||||
</div>
|
||||
<div class="onboarding-topbar-right">
|
||||
{#if isAuthenticated && displayName}
|
||||
{#if isAuthenticated}
|
||||
<div class="onboarding-account" aria-label="Account">
|
||||
<span class="onboarding-account-avatar" aria-hidden="true">{accountInitials}</span>
|
||||
<span class="onboarding-account-text">
|
||||
<span class="onboarding-account-prefix">Logged in as</span>
|
||||
<span class="onboarding-account-name">{displayName}</span>
|
||||
<span class="onboarding-account-avatar" aria-hidden="true">
|
||||
<Icon name="fas fa-user" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -749,8 +736,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
<h1>
|
||||
{#if onboardingCompleted}
|
||||
Your submitted onboarding details.
|
||||
{:else if displayName}
|
||||
Hi {displayName}, let's get your dog onboarded.
|
||||
{:else}
|
||||
A few details so we can care for your dog properly.
|
||||
{/if}
|
||||
@@ -1399,7 +1384,7 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OnboardingFooter email={userEmail} on:logout={handleLogout} />
|
||||
<OnboardingFooter on:logout={handleLogout} />
|
||||
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1504,33 +1489,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.12);
|
||||
}
|
||||
|
||||
.onboarding-account-text {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-account-prefix {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.onboarding-account-name {
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
color: #fff;
|
||||
max-width: 22ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.onboarding-account-logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1575,15 +1533,6 @@ function buildSubmissionSnapshot(submittedAt: string): SubmissionSummary {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.onboarding-account-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-account-name {
|
||||
font-size: 12px;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.onboarding-account-logout-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* v5 clarity treatment for the pricing page. Keeps each service prominent,
|
||||
* like the live PricingPage, but strips the message right back: one photo,
|
||||
* the name, a single outcome line, the "from" price, then the even plan
|
||||
* cards. No chooser grid, lede, assurance, fit lists, or meta rows.
|
||||
*
|
||||
* View component only. Preview at /variants/our-pricing.
|
||||
*/
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||
import { decoratePlans } from '$lib/utils/pricing';
|
||||
import { ourPricingContent } from '$lib/content/our-pricing';
|
||||
import { packWalksContent } from '$lib/content/pack-walks';
|
||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
import type { PricingPageSection, ServicePageContent } from '$lib/types';
|
||||
|
||||
type Block = { section: PricingPageSection; service: ServicePageContent; href: string };
|
||||
|
||||
// Pair each pricing section with its service (for the distilled per-plan copy)
|
||||
// and its variant page. Order matches ourPricingContent.sections.
|
||||
const blocks: Block[] = [
|
||||
{ section: ourPricingContent.sections[0], service: packWalksContent, href: '/pack-walks' },
|
||||
{ section: ourPricingContent.sections[1], service: dogWalkingContent, href: '/dog-walking' },
|
||||
{ section: ourPricingContent.sections[2], service: puppyVisitsContent, href: '/puppy-visits' }
|
||||
];
|
||||
|
||||
function fromValue(section: PricingPageSection): string {
|
||||
return section.meta?.find((m) => m.label === 'From')?.value ?? '';
|
||||
}
|
||||
|
||||
function plansFor(block: Block) {
|
||||
const lines = block.service.clarity?.planLines ?? {};
|
||||
return decoratePlans(
|
||||
block.section.plans.map((plan) => ({
|
||||
...plan,
|
||||
features: lines[plan.title] ?? plan.features
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// At-a-glance comparison. Distilled, accurate facts per service; the "from"
|
||||
// price is read from content (fromFor) so it stays single-sourced with the
|
||||
// cards below and the pricing in each service page's schema.
|
||||
const comparisonRows = [
|
||||
{
|
||||
href: '/pack-walks',
|
||||
service: 'Tiny Gang Pack Walks',
|
||||
bestFor: 'Sociable small & medium dogs',
|
||||
format: 'Small group of 4–8 dogs',
|
||||
length: '60–75 min'
|
||||
},
|
||||
{
|
||||
href: '/dog-walking',
|
||||
service: 'Solo Walks',
|
||||
bestFor: 'Reactive, anxious, senior or recovering dogs',
|
||||
format: 'One dog, one-on-one',
|
||||
length: '30 / 45 / 60 min'
|
||||
},
|
||||
{
|
||||
href: '/puppy-visits',
|
||||
service: 'Puppy Visits',
|
||||
bestFor: 'Puppies too young for full walks',
|
||||
format: 'In-home, one-on-one',
|
||||
length: '20 / 45 / 60 min'
|
||||
}
|
||||
];
|
||||
|
||||
function fromFor(href: string): string {
|
||||
const block = blocks.find((b) => b.href === href);
|
||||
return block ? fromValue(block.section) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="pc">
|
||||
<!-- Standard service hero (green copy left, photo right). -->
|
||||
<ServiceHero
|
||||
eyebrow={ourPricingContent.eyebrow ?? 'Pricing'}
|
||||
title={ourPricingContent.title}
|
||||
subtitle={ourPricingContent.subtitle}
|
||||
imageUrl={ourPricingContent.heroImageUrl}
|
||||
imageAlt={ourPricingContent.heroImageAlt}
|
||||
chips={ourPricingContent.heroChips ?? []}
|
||||
cta={{ label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'yellow' }}
|
||||
/>
|
||||
|
||||
<!-- Value before price: one line, given room to land. -->
|
||||
<section class="pc-value" use:reveal={{ distance: 0 }} aria-labelledby="pc-value-quote">
|
||||
<div class="pc-inner">
|
||||
<span class="pc-value-rule" aria-hidden="true"></span>
|
||||
<p id="pc-value-quote" class="pc-value-quote">
|
||||
The best investment you’ll ever make is in your dog’s happiness.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- At a glance: a compact comparison of the three services before the
|
||||
detailed per-service blocks. A real table, so it reads cleanly and is
|
||||
easy to lift as a unit. -->
|
||||
<section class="pc-compare" use:reveal={{ distance: 0 }} aria-labelledby="pc-compare-heading">
|
||||
<div class="pc-inner">
|
||||
<div class="pc-compare-head">
|
||||
<p class="pc-eyebrow">Compare at a glance</p>
|
||||
<h2 id="pc-compare-heading" class="pc-h2">Three ways Goodwalk fits your dog</h2>
|
||||
</div>
|
||||
<div class="pc-table-wrap">
|
||||
<table class="pc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Service</th>
|
||||
<th scope="col">Best for</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Length</th>
|
||||
<th scope="col">From</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each comparisonRows as row}
|
||||
<tr>
|
||||
<th scope="row"><a href={row.href}>{row.service}</a></th>
|
||||
<td>{row.bestFor}</td>
|
||||
<td>{row.format}</td>
|
||||
<td>{row.length}</td>
|
||||
<td class="pc-table-from">{fromFor(row.href)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- One prominent block per service: photo + simplified message, then cards. -->
|
||||
{#each blocks as block, i}
|
||||
<section
|
||||
class="pc-service"
|
||||
class:pc-service-tint={i % 2 === 1}
|
||||
use:reveal={{ distance: 0 }}
|
||||
aria-labelledby={`pc-svc-${i}`}
|
||||
>
|
||||
<div class="pc-inner">
|
||||
<div class="pc-row" class:pc-row-reverse={i % 2 === 1}>
|
||||
{#if block.section.imageUrl}
|
||||
<figure class="pc-media">
|
||||
<img
|
||||
src={block.section.imageUrl}
|
||||
alt={block.section.imageAlt ?? block.section.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</figure>
|
||||
{/if}
|
||||
|
||||
<div class="pc-copy">
|
||||
{#if block.section.eyebrow}
|
||||
<p class="pc-eyebrow">{block.section.eyebrow}</p>
|
||||
{/if}
|
||||
<h2 id={`pc-svc-${i}`} class="pc-h2">{block.section.title}</h2>
|
||||
{#if block.section.outcome}
|
||||
<p class="pc-outcome">{block.section.outcome}</p>
|
||||
{/if}
|
||||
{#if fromValue(block.section)}
|
||||
<p class="pc-from"><span>From</span> <strong>{fromValue(block.section)}</strong></p>
|
||||
{/if}
|
||||
<a class="pc-link" href={block.href}>
|
||||
See {block.section.title}
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-plans" class:pc-plans-three={block.section.plans.length === 3}>
|
||||
{#each plansFor(block) as plan}
|
||||
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<!-- Coverage -->
|
||||
<div class="pc-area" use:reveal={{ distance: 0 }}>
|
||||
<ServiceAreaCoverage
|
||||
title="Where we work across Auckland Central"
|
||||
lead="Free door-to-door pickup and drop-off on walks across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Close -->
|
||||
<section class="pc-cta" use:reveal={{ distance: 0 }}>
|
||||
<div class="pc-inner">
|
||||
<h2 class="pc-cta-title">Not sure which one fits?</h2>
|
||||
<p class="pc-cta-sub">Every Goodwalk routine starts with a free Meet & Greet. We will tell you what fits, and what does not.</p>
|
||||
<a class="btn btn-yellow" href="/contact-us">Book a free Meet & Greet</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.pc {
|
||||
--pc-max: 1180px;
|
||||
background: var(--off-white);
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.pc-inner {
|
||||
max-width: var(--pc-max);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--space-container-x);
|
||||
}
|
||||
|
||||
.pc-service {
|
||||
padding-block: clamp(48px, 6vw, 88px);
|
||||
}
|
||||
|
||||
/* ── Value statement: one line, given room to land ── */
|
||||
.pc-value {
|
||||
padding-block: clamp(64px, 9vw, 128px);
|
||||
background: var(--surface-panel-cream);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pc-value-rule {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
margin: 0 auto 30px;
|
||||
border-radius: 3px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.pc-value-quote {
|
||||
margin: 0 auto;
|
||||
max-width: 16ch;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(30px, 4.8vw, 56px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--gw-green);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── At-a-glance comparison table ── */
|
||||
.pc-compare {
|
||||
padding-block: clamp(48px, 6vw, 88px);
|
||||
}
|
||||
|
||||
.pc-compare-head {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pc-compare-head .pc-eyebrow {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Scrolls on narrow screens rather than cramping five columns. */
|
||||
.pc-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.pc-table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface-panel);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pc-table thead th {
|
||||
padding: 16px 20px;
|
||||
background: var(--gw-green);
|
||||
color: rgba(255, 248, 230, 0.96);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pc-table tbody th,
|
||||
.pc-table tbody td {
|
||||
padding: 18px 20px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-heading-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pc-table tbody th {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.pc-table tbody th a {
|
||||
color: var(--gw-green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pc-table tbody th a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pc-table-from {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
color: var(--gw-green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Alternating tint for rhythm, the same trick the service pages use. */
|
||||
.pc-service-tint,
|
||||
.pc-area {
|
||||
background: var(--surface-panel-cream);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* ── Prominent service row: photo + simplified message ── */
|
||||
.pc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: clamp(28px, 5vw, 64px);
|
||||
align-items: center;
|
||||
margin-bottom: clamp(32px, 4.5vw, 56px);
|
||||
}
|
||||
|
||||
.pc-media {
|
||||
order: 0;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--surface-brand);
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.pc-row-reverse .pc-media {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pc-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pc-copy {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pc-row-reverse .pc-copy {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.pc-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.pc-eyebrow::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.pc-h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3.4vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-heading);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* The simplified message: one line, carried with weight. */
|
||||
.pc-outcome {
|
||||
margin: 16px 0 0;
|
||||
max-width: 34ch;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(18px, 1.9vw, 23px);
|
||||
font-weight: 600;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--gw-green);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.pc-from {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin: 22px 0 0;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-brand-soft);
|
||||
color: var(--gw-green);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.pc-from span {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pc-from strong {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--gw-green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pc-from + .pc-link {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.pc-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Plans ── */
|
||||
.pc-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.pc-plans-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
max-width: 1040px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card) {
|
||||
padding: 40px 28px 34px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card--featured) {
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card__cta) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card__cta .icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Close CTA ── */
|
||||
.pc-cta {
|
||||
padding-block: clamp(48px, 6vw, 88px);
|
||||
background: var(--gw-green);
|
||||
color: rgba(255, 248, 230, 0.96);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pc-cta-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(26px, 3.2vw, 40px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--yellow);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.pc-cta-sub {
|
||||
margin: 14px auto 24px;
|
||||
max-width: 38rem;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 248, 230, 0.9);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 860px) {
|
||||
.pc-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(20px, 5vw, 32px);
|
||||
}
|
||||
|
||||
.pc-media,
|
||||
.pc-row-reverse .pc-media {
|
||||
order: 0;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.pc-copy,
|
||||
.pc-row-reverse .pc-copy {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.pc-plans,
|
||||
.pc-plans-three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pc-plans,
|
||||
.pc-plans-three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card) {
|
||||
padding: 30px 24px 26px;
|
||||
}
|
||||
|
||||
.pc-plans :global(.plan-card--featured) {
|
||||
padding-top: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,725 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Shared v5 "clarity" service page. One design system, one message, said
|
||||
* once in order: what it is -> what's different -> how it works -> fit ->
|
||||
* proof -> price -> FAQ -> where we cover -> book.
|
||||
*
|
||||
* Drives all three service pages (Tiny Gang, Solo Walks, Puppy Visits) from
|
||||
* content. Page-specific copy lives in each content file's `clarity` block;
|
||||
* see docs/v5.md. View component only: the route owns Header/Footer/SEO.
|
||||
*/
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
|
||||
import PricingPlanCard from '$lib/components/ui/PricingPlanCard.svelte';
|
||||
import TestimonialsSection from '$lib/components/sections/TestimonialsSection.svelte';
|
||||
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||
import ServiceAreaCoverage from '$lib/components/sections/ServiceAreaCoverage.svelte';
|
||||
import { decoratePlans } from '$lib/utils/pricing';
|
||||
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: ServicePageContent;
|
||||
export let currentPath = '/';
|
||||
|
||||
$: clarity = pageContent.clarity;
|
||||
$: traits = clarity?.traits ?? [];
|
||||
$: steps = clarity?.steps ?? [];
|
||||
$: planLines = clarity?.planLines ?? {};
|
||||
$: priceNotes = clarity?.priceNotes ?? [];
|
||||
$: pricingPlans = decoratePlans(
|
||||
pageContent.pricing.plans.map((plan) => ({
|
||||
...plan,
|
||||
features: planLines[plan.title] ?? plan.features
|
||||
}))
|
||||
);
|
||||
$: faqItems = pageContent.faq?.items?.slice(0, 6) ?? [];
|
||||
</script>
|
||||
|
||||
<main class="tg">
|
||||
<!-- Hero: shared service-page hero (green copy left, photo bleeds right). -->
|
||||
<ServiceHero
|
||||
eyebrow={pageContent.hero.eyebrow}
|
||||
title={pageContent.hero.title}
|
||||
subtitle={pageContent.hero.subtitle}
|
||||
imageUrl={pageContent.hero.imageUrl}
|
||||
imageAlt={pageContent.hero.imageAlt}
|
||||
chips={pageContent.hero.chips ?? []}
|
||||
cta={pageContent.hero.cta}
|
||||
/>
|
||||
|
||||
<!-- What it is: a plain, self-contained definition that opens the page. -->
|
||||
{#if clarity?.definition}
|
||||
<section class="tg-section tg-section-def" use:reveal={{ distance: 0 }} aria-labelledby="tg-def-heading">
|
||||
<div class="tg-inner tg-inner-narrow">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity.definition.eyebrow ?? 'In plain terms'}</p>
|
||||
<h2 id="tg-def-heading" class="tg-h2">{clarity.definition.heading}</h2>
|
||||
</div>
|
||||
<p class="tg-def-body">{clarity.definition.body}</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- What makes it different (said once) -->
|
||||
{#if traits.length}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-diff-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.diffEyebrow ?? 'What makes it different'}</p>
|
||||
<h2 id="tg-diff-heading" class="tg-h2">{clarity?.diffHeading ?? ''}</h2>
|
||||
</div>
|
||||
<ul class="tg-traits">
|
||||
{#each traits as trait}
|
||||
<li class="tg-trait">
|
||||
<span class="tg-trait-icon" aria-hidden="true"><Icon name={trait.icon} /></span>
|
||||
<div>
|
||||
<h3>{trait.title}</h3>
|
||||
<p>{trait.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- How it works (the process, explicit) -->
|
||||
{#if steps.length}
|
||||
<section class="tg-section tg-section-steps" use:reveal={{ distance: 0 }} aria-labelledby="tg-steps-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.stepsEyebrow ?? 'How it works'}</p>
|
||||
<h2 id="tg-steps-heading" class="tg-h2">{clarity?.stepsHeading ?? ''}</h2>
|
||||
</div>
|
||||
<ol class="tg-steps">
|
||||
{#each steps as step, i}
|
||||
<li class="tg-step">
|
||||
<span class="tg-step-num" aria-hidden="true">{i + 1}</span>
|
||||
<h3>{step.title}</h3>
|
||||
<p>{step.body}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Image beat: one deliberate photo that breaks the longest text run. -->
|
||||
{#if clarity?.imageBand}
|
||||
<div class="tg-band-wrap" use:reveal={{ distance: 0 }}>
|
||||
<figure class="tg-band">
|
||||
<img
|
||||
class="tg-band-img"
|
||||
src={clarity.imageBand.imageUrl}
|
||||
alt={clarity.imageBand.imageAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption class="tg-band-cap">{clarity.imageBand.caption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Is it right for your dog? -->
|
||||
{#if pageContent.decision}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-fit-heading">
|
||||
<div class="tg-inner">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.fitEyebrow ?? 'Honest fit check'}</p>
|
||||
<h2 id="tg-fit-heading" class="tg-h2">{pageContent.decision.title ?? 'Is this right for your dog?'}</h2>
|
||||
</div>
|
||||
<div class="tg-fit">
|
||||
<div class="tg-fit-col">
|
||||
<h3>{pageContent.decision.fitTitle ?? 'A great fit if your dog:'}</h3>
|
||||
<ul>
|
||||
{#each pageContent.decision.fitItems as item}
|
||||
<li>
|
||||
<span class="tg-fit-mark tg-fit-mark-yes" aria-hidden="true"><Icon name="fas fa-check" /></span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tg-fit-col">
|
||||
<h3>{pageContent.decision.notFitTitle ?? 'Better with another option if your dog:'}</h3>
|
||||
<ul>
|
||||
{#each pageContent.decision.notFitItems as item}
|
||||
<li>
|
||||
<span class="tg-fit-mark tg-fit-mark-no" aria-hidden="true"><Icon name="fas fa-xmark" /></span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{#if pageContent.decision.footnote}
|
||||
<p class="tg-fit-note">{pageContent.decision.footnote}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Proof -->
|
||||
<TestimonialsSection
|
||||
heading={clarity?.testimonialsHeading ?? pageContent.testimonialsHeading}
|
||||
testimonials={content.testimonials}
|
||||
seedKey={currentPath}
|
||||
/>
|
||||
|
||||
<!-- Price -->
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-price-heading">
|
||||
<div class="tg-inner tg-inner-wide">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.pricingEyebrow ?? 'Simple pricing'}</p>
|
||||
<h2 id="tg-price-heading" class="tg-h2">{pageContent.pricing.title}</h2>
|
||||
{#if clarity?.pricingTagline}
|
||||
<p class="tg-price-tagline">{clarity.pricingTagline}</p>
|
||||
{/if}
|
||||
{#if pageContent.pricing.intro}
|
||||
<p class="tg-head-sub">{pageContent.pricing.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="tg-plans" class:tg-plans-three={pricingPlans.length === 3}>
|
||||
{#each pricingPlans as plan}
|
||||
<PricingPlanCard {plan} variant="service" hideCtaOnMobile={true} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if pageContent.pricing.scarcityNote || priceNotes.length}
|
||||
<div class="tg-price-notes">
|
||||
{#if pageContent.pricing.scarcityNote}
|
||||
<p class="tg-note tg-note-warm"><Icon name="fas fa-clock" />{pageContent.pricing.scarcityNote}</p>
|
||||
{/if}
|
||||
{#each priceNotes as note}
|
||||
<p class="tg-note"><Icon name={note.icon} />{note.label}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a class="btn btn-yellow btn-mobile-center tg-plans-cta" href="#newlead">Book a Meet & Greet</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
{#if faqItems.length}
|
||||
<section class="tg-section" use:reveal={{ distance: 0 }} aria-labelledby="tg-faq-heading">
|
||||
<div class="tg-inner tg-inner-narrow">
|
||||
<div class="tg-head">
|
||||
<p class="tg-eyebrow">{clarity?.faqEyebrow ?? 'Common questions'}</p>
|
||||
<h2 id="tg-faq-heading" class="tg-h2">{pageContent.faq?.title ?? 'FAQs'}</h2>
|
||||
{#if pageContent.faq?.intro}
|
||||
<p class="tg-head-sub">{pageContent.faq.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tg-faq">
|
||||
<FaqSection title={pageContent.faq?.title ?? 'FAQs'} faqs={faqItems} showHeading={false} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Service area: a coverage field that works on every screen. -->
|
||||
<div class="tg-section-area" use:reveal={{ distance: 0 }}>
|
||||
<ServiceAreaCoverage
|
||||
title={clarity?.areaTitle ?? 'Where we walk across Auckland Central'}
|
||||
lead={clarity?.areaLead ??
|
||||
'Free door-to-door pickup and drop-off across Auckland Central and the suburbs around it. Tap your suburb for its local parks and routes.'}
|
||||
stats={clarity?.areaStats ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BookingWizard booking={pageContent.booking} pagePath={currentPath} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* ── One system: tokens for this page ──────────────────────────────── */
|
||||
.tg {
|
||||
--tg-max: 1120px;
|
||||
--tg-narrow: 820px;
|
||||
background: var(--off-white);
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-inner {
|
||||
max-width: var(--tg-max);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--space-container-x);
|
||||
}
|
||||
|
||||
.tg-inner-narrow {
|
||||
max-width: var(--tg-narrow);
|
||||
}
|
||||
|
||||
/* Pricing breaks out wider than the body so four cards have real width
|
||||
(and the full CTA never clips). */
|
||||
.tg-inner-wide {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.tg-section {
|
||||
padding-block: clamp(48px, 6vw, 84px);
|
||||
}
|
||||
|
||||
/* Alternating surface for rhythm, not a new "system": just a tint. */
|
||||
.tg-section-steps,
|
||||
.tg-section-area {
|
||||
background: var(--surface-panel-cream);
|
||||
border-block: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
/* One eyebrow, one heading, everywhere. */
|
||||
.tg-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-eyebrow::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.tg-head {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto clamp(28px, 4vw, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tg-h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3.4vw, 42px);
|
||||
font-weight: 700;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-heading);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.tg-head-sub {
|
||||
margin: 14px auto 0;
|
||||
max-width: 36rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Definition lede: one calm, readable paragraph under the opening head ── */
|
||||
.tg-section-def {
|
||||
padding-bottom: clamp(8px, 2vw, 24px);
|
||||
}
|
||||
|
||||
.tg-def-body {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
color: var(--text-heading-soft);
|
||||
font-size: clamp(17px, 1.7vw, 19px);
|
||||
line-height: 1.7;
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tg-price-tagline {
|
||||
margin: 14px auto 0;
|
||||
max-width: 32rem;
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(17px, 1.7vw, 20px);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Traits: a clean feature list, not boxed cards ─────────────────── */
|
||||
.tg-traits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(24px, 3vw, 40px) clamp(32px, 5vw, 64px);
|
||||
}
|
||||
|
||||
.tg-trait {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tg-trait-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-brand);
|
||||
color: var(--yellow);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.tg-trait h3 {
|
||||
margin: 2px 0 6px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-trait p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
/* ── Steps: numerals, not cards ────────────────────────────────────── */
|
||||
.tg-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: clamp(24px, 4vw, 48px);
|
||||
counter-reset: tg-step;
|
||||
}
|
||||
|
||||
.tg-step {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tg-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tg-step h3 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-step p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Fit check: one card, two columns ──────────────────────────────── */
|
||||
.tg-fit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(20px, 3vw, 32px);
|
||||
padding: clamp(24px, 3vw, 40px);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-panel);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--border-soft-strong),
|
||||
var(--shadow-card);
|
||||
}
|
||||
|
||||
.tg-fit-col h3 {
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.tg-fit-col ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tg-fit-col li {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
color: var(--text-heading-soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tg-fit-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tg-fit-mark-yes {
|
||||
background: rgba(var(--brand-rgb), 0.1);
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-fit-mark-no {
|
||||
background: rgba(var(--ink-rgb), 0.06);
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.tg-fit-note {
|
||||
margin: 22px auto 0;
|
||||
max-width: 48rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Image beat: a contained cinematic band between sections ───────────── */
|
||||
.tg-band-wrap {
|
||||
max-width: var(--tg-max);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--space-container-x);
|
||||
padding-block: clamp(8px, 2vw, 28px);
|
||||
}
|
||||
|
||||
.tg-band {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--surface-brand);
|
||||
}
|
||||
|
||||
.tg-band-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 7;
|
||||
object-fit: cover;
|
||||
object-position: center 42%;
|
||||
}
|
||||
|
||||
/* Caption rides on the photo: legible over the image, keyword-bearing copy
|
||||
that reads as a moment, not a label. */
|
||||
.tg-band-cap {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: clamp(28px, 5vw, 44px) clamp(18px, 4vw, 32px) clamp(16px, 2.4vw, 22px);
|
||||
background: linear-gradient(to top, rgba(33, 48, 33, 0.78), rgba(33, 48, 33, 0));
|
||||
color: rgba(255, 248, 230, 0.96);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(15px, 1.8vw, 19px);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Pricing ───────────────────────────────────────────────────────── */
|
||||
.tg-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Three-plan pages (solo walks, puppy visits) centre nicely at three-up. */
|
||||
.tg-plans-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
max-width: 1040px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Roomier, consistent cards on this page, scoped via .tg-plans so the shared
|
||||
PricingPlanCard and other pages keep their own sizing. */
|
||||
.tg-plans :global(.plan-card) {
|
||||
padding: 44px 30px 38px;
|
||||
}
|
||||
|
||||
/* The featured card carries the "Goodwalk favourite" ribbon (absolute,
|
||||
top: 16px). Give it enough head room that the ribbon sits clearly above
|
||||
the title instead of over it. */
|
||||
.tg-plans :global(.plan-card--featured) {
|
||||
padding-top: 62px;
|
||||
}
|
||||
|
||||
/* Full-width CTA: never squished, and consistent across all cards.
|
||||
display:flex (block-level) overrides the component's inline-flex +
|
||||
align-self:center, so the button always spans the full card width. */
|
||||
.tg-plans :global(.plan-card__cta) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
/* The narrow 4-up cards can't fit "Book a Meet & Greet" plus the arrow on
|
||||
one line, and the card clips overflow — so the label was chopped. Drop the
|
||||
arrow here and let the label use the whole width on its own. */
|
||||
.tg-plans :global(.plan-card__cta .icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tg-price-notes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tg-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-brand-soft);
|
||||
color: var(--gw-green);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tg-note-warm {
|
||||
background: var(--surface-accent-muted);
|
||||
color: #5a4500;
|
||||
}
|
||||
|
||||
:global(.tg-note .icon) {
|
||||
color: var(--yellow);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:global(.tg-note-warm .icon) {
|
||||
color: #b88800;
|
||||
}
|
||||
|
||||
.tg-plans-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── FAQ: a calm ruled list, not a stack of cards ──────────────────────
|
||||
The shared FaqSection renders each question as a bordered, shadowed white
|
||||
card. On this off-white page that reads as visual noise. Here we strip the
|
||||
card chrome to hairline dividers so the questions sit as one quiet column,
|
||||
matching the page's restraint. Scoped via .tg-faq; every other page keeps
|
||||
the card treatment. */
|
||||
.tg-faq :global(.faq details) {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details[open]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Full-bleed rows: no card inset, generous vertical breathing room. */
|
||||
.tg-faq :global(.faq summary) {
|
||||
padding: 22px 4px;
|
||||
border-radius: 0;
|
||||
font-size: 17px;
|
||||
color: var(--text-heading);
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
/* Hover lifts the question to brand green instead of filling a card. */
|
||||
.tg-faq :global(.faq summary:hover) {
|
||||
background: transparent;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details[open] summary) {
|
||||
border-bottom: 0;
|
||||
border-radius: 0;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.tg-faq :global(.faq details p) {
|
||||
padding: 0 44px 24px 4px;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────── */
|
||||
@media (max-width: 980px) {
|
||||
.tg-plans,
|
||||
.tg-plans-three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tg-traits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tg-fit {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-band-img {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
|
||||
.tg-plans,
|
||||
.tg-plans-three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tg-plans :global(.plan-card) {
|
||||
padding: 30px 24px 26px;
|
||||
}
|
||||
|
||||
.tg-plans :global(.plan-card--featured) {
|
||||
padding-top: 46px;
|
||||
}
|
||||
|
||||
.tg-plans-cta {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -114,7 +114,9 @@ describe('BookingSection', () => {
|
||||
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
||||
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||
// The success modal is code-split (dynamic import) and only mounts after
|
||||
// submission resolves, so wait for it rather than asserting synchronously.
|
||||
expect(await screen.findByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||
@@ -167,7 +169,8 @@ describe('BookingSection', () => {
|
||||
journey: [window.location.pathname]
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||
// The success modal is code-split (dynamic import); wait for it to mount.
|
||||
expect(await screen.findByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -2,8 +2,6 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
export let email = '';
|
||||
|
||||
const dispatch = createEventDispatcher<{ logout: void }>();
|
||||
|
||||
let loggingOut = false;
|
||||
@@ -32,9 +30,6 @@
|
||||
<Icon name="fas fa-arrow-left" />
|
||||
Back to main site
|
||||
</a>
|
||||
{#if email}
|
||||
<span class="ob-footer-email">Signed in as {email}</span>
|
||||
{/if}
|
||||
<button type="button" class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
|
||||
<Icon name="fas fa-right-from-bracket" />
|
||||
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||
@@ -75,16 +70,6 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ob-footer-email {
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ob-footer-logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -116,16 +101,6 @@
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.ob-footer-email {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ob-footer-back,
|
||||
.ob-footer-logout {
|
||||
|
||||
@@ -184,12 +184,6 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
clarity: {
|
||||
definition: {
|
||||
eyebrow: 'In plain terms',
|
||||
heading: 'What a solo dog walk actually is',
|
||||
body:
|
||||
"A solo dog walk is a one-on-one outing where a single dog is walked by the same Goodwalk walker every time, at their own pace, across Auckland Central. There's no group to keep up with — just your dog, their route, and their tempo. It's built for dogs who do better without a pack: reactive or anxious dogs, seniors, those recovering from surgery, and rescues still finding their feet. You choose the length — 30 minutes for seniors or lower-energy dogs, 45 for most, or 60 for a longer outing — with free door-to-door pickup and drop-off and a photo update after every walk. Solo walks start from $45 per walk. Every dog begins with a free Meet & Greet, so we can agree the right pace and handling before the first walk."
|
||||
},
|
||||
diffEyebrow: 'What makes solo walks different',
|
||||
diffHeading: 'Built around your dog. Not a pack schedule.',
|
||||
traits: [
|
||||
|
||||
@@ -207,12 +207,6 @@ export const packWalksContent: ServicePageContent = {
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
clarity: {
|
||||
definition: {
|
||||
eyebrow: 'In plain terms',
|
||||
heading: 'What a Tiny Gang pack walk actually is',
|
||||
body:
|
||||
'A Tiny Gang pack walk is a small-group dog walk for sociable small and medium dogs across Auckland Central. Instead of one big anonymous pack, your dog joins a crew of 4 to 8 dogs, matched on temperament, energy, and play style, and walked by the same Goodwalk walker every week. Each outing runs 60 to 75 minutes on the ground at parks like Western Springs, Cornwall Park, and Grey Lynn Park, with free door-to-door pickup and drop-off included. Regular weekly walks start from $49.50 per walk; casual walks are $65. It suits dogs who enjoy company and thrive on routine — the ones who come home settled instead of pacing the house at 5pm. Every dog starts with a free Meet & Greet and two assessment walks, so the fit is right before anything becomes regular.'
|
||||
},
|
||||
diffEyebrow: 'What makes Tiny Gang different',
|
||||
diffHeading: 'Not a big anonymous pack. A small, matched crew.',
|
||||
traits: [
|
||||
|
||||
@@ -169,12 +169,6 @@ export const puppyVisitsContent: ServicePageContent = {
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
clarity: {
|
||||
definition: {
|
||||
eyebrow: 'In plain terms',
|
||||
heading: 'What a puppy visit actually is',
|
||||
body:
|
||||
"A puppy visit is an in-home check-in for young dogs who are too small or too young for full walks — usually puppies under 12 to 18 months. Instead of a walk, a Goodwalk walker comes to your home during the day to give your puppy a toilet break, feed them if needed, and spend calm one-on-one time playing, settling, and keeping them company. Visits run 20, 45, or 60 minutes, so you can match the length to your puppy's age and how much support they need that day. Pricing starts from $39 per visit, with photo updates included. It's low-impact support while they grow, and a gentle first step: many Tiny Gang pack dogs started with puppy visits before moving into full walks once they were ready."
|
||||
},
|
||||
diffEyebrow: 'What makes Puppy Visits different',
|
||||
diffHeading: 'Real support at home, while you\'re out.',
|
||||
traits: [
|
||||
|
||||
@@ -20,7 +20,10 @@ function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {}
|
||||
}
|
||||
},
|
||||
// The loader reads `locals.abHero`/`locals.anonId` to attach A/B context.
|
||||
// Without an A/B assignment it omits `ab`, matching the expectations below.
|
||||
locals: {}
|
||||
} as unknown as Parameters<typeof load>[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,34 +10,66 @@ describe('home page route', () => {
|
||||
data: createHomepageRouteData()
|
||||
});
|
||||
|
||||
const hero = document.getElementById('hero');
|
||||
const intro = document.getElementById('intro');
|
||||
const values = document.getElementById('values');
|
||||
// HomeClarity is the live homepage. Its hero is purpose-built copy rather
|
||||
// than the generic content.hero fields, leading with the specialism.
|
||||
expect(document.querySelector('.chero')).not.toBeNull();
|
||||
expect(screen.getByText('Meet the Tiny Gang.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Specialist walks for small & medium dogs')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Offer-first section order: services -> how it works -> values ->
|
||||
// testimonials -> founder story -> info -> booking.
|
||||
const services = document.getElementById('services');
|
||||
const howItWorks = document.getElementById('how-it-works');
|
||||
const values = document.getElementById('values');
|
||||
const testimonials = document.getElementById('testimonials');
|
||||
const founderStory = document.getElementById('promise');
|
||||
const info = document.getElementById('info');
|
||||
const booking = document.getElementById('newlead');
|
||||
|
||||
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0);
|
||||
const introHeadlineText = homepageContent.intro.text.replace(/\.$/, '');
|
||||
const introHeading = document.querySelector('#intro .intro-headline');
|
||||
expect(introHeading?.textContent?.replace(/\s+/g, ' ').trim()).toBe(introHeadlineText);
|
||||
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument();
|
||||
for (const section of [
|
||||
services,
|
||||
howItWorks,
|
||||
values,
|
||||
testimonials,
|
||||
founderStory,
|
||||
info,
|
||||
booking
|
||||
]) {
|
||||
expect(section).not.toBeNull();
|
||||
}
|
||||
|
||||
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
|
||||
expect(hero?.compareDocumentPosition(intro as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(intro?.compareDocumentPosition(values as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(values?.compareDocumentPosition(services as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(howItWorks?.compareDocumentPosition(testimonials as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(testimonials?.compareDocumentPosition(founderStory as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(founderStory?.compareDocumentPosition(info as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(info?.compareDocumentPosition(booking as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
expect(services?.compareDocumentPosition(howItWorks as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(howItWorks?.compareDocumentPosition(values as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(values?.compareDocumentPosition(testimonials as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(testimonials?.compareDocumentPosition(founderStory as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(founderStory?.compareDocumentPosition(info as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(info?.compareDocumentPosition(booking as Node)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
|
||||
// The homepage booking wizard never exposes the general-enquiry toggle.
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
|
||||
// SEO head: title, structured data, and the preloaded hero image.
|
||||
expect(document.title).toBe(homepageContent.seo.title);
|
||||
expect(document.head.innerHTML).toContain('FAQPage');
|
||||
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/goodwalk-auckland-happy-dog-hero.webp');
|
||||
expect(document.head.innerHTML).toContain('PetCareService');
|
||||
expect(document.head.innerHTML).toContain(
|
||||
'https://www.goodwalk.co.nz/images/smiling-happy-dog-maya.webp'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the homepage design with the simplified Meet & Greet form.
|
||||
* Reuses HomeClarity (the live homepage) and injects BookingWizardSimple in
|
||||
* place of the live BookingWizard via the BookingComponent prop. noindex so
|
||||
* it does not compete with the real homepage in search.
|
||||
*/
|
||||
import HomeClarity from '$lib/components/pages/HomeClarity.svelte';
|
||||
import BookingWizardSimple from '$lib/components/sections/BookingWizardSimple.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meet & Greet form (preview) | Goodwalk</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<HomeClarity
|
||||
content={homepageContent}
|
||||
pagePath="/variants/meet-greet"
|
||||
BookingComponent={BookingWizardSimple}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the redesigned pricing page in the v5 clarity system. noindex so
|
||||
* it does not compete with the live /our-pricing page in search.
|
||||
*/
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
import Footer from '$lib/components/ui/Footer.svelte';
|
||||
import PricingClarityPage from '$lib/components/pages/PricingClarityPage.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Pricing (redesign preview) | Goodwalk</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<Header navigation={homepageContent.navigation} />
|
||||
<PricingClarityPage />
|
||||
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the redesigned Puppy Visits page on the shared v5 clarity
|
||||
* component. noindex so it does not compete with the live /puppy-visits page.
|
||||
*
|
||||
* To go live: render ServiceClarityPage in the `data.slug === 'puppy-visits'`
|
||||
* branch of src/routes/[slug]/+page.svelte.
|
||||
*/
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
import Footer from '$lib/components/ui/Footer.svelte';
|
||||
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Puppy visits (redesign preview) | Goodwalk</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<Header navigation={homepageContent.navigation} />
|
||||
<ServiceClarityPage content={homepageContent} pageContent={puppyVisitsContent} currentPath="/variants/puppy-visits" />
|
||||
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the services-focus homepage variant.
|
||||
* Single source of truth is HomeServicesFocus.svelte. noindex so it does not
|
||||
* compete with the real homepage in search.
|
||||
*
|
||||
* Purpose: the site gets traffic but few leads. This variant keeps the design
|
||||
* language but reorients the whole page around clicking through to a service
|
||||
* page, dropping the homepage booking form in favour of lower-friction service
|
||||
* choices in the hero, the cards, and a closing band.
|
||||
*/
|
||||
import HomeServicesFocus from '$lib/components/pages/HomeServicesFocus.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Services-focus homepage (preview) | Goodwalk</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<HomeServicesFocus content={homepageContent} pagePath="/variants/services-first" />
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* Preview of the redesigned Solo Walks page on the shared v5 clarity
|
||||
* component. noindex so it does not compete with the live /dog-walking page.
|
||||
*
|
||||
* To go live: render ServiceClarityPage in the `data.slug === 'dog-walking'`
|
||||
* branch of src/routes/[slug]/+page.svelte.
|
||||
*/
|
||||
import Header from '$lib/components/ui/Header.svelte';
|
||||
import Footer from '$lib/components/ui/Footer.svelte';
|
||||
import ServiceClarityPage from '$lib/components/pages/ServiceClarityPage.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Solo dog walks (redesign preview) | Goodwalk</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<Header navigation={homepageContent.navigation} />
|
||||
<ServiceClarityPage content={homepageContent} pageContent={dogWalkingContent} currentPath="/variants/solo-walks" />
|
||||
<Footer footer={homepageContent.footer} variant="minimal" />
|
||||
Reference in New Issue
Block a user