17 Commits

Author SHA1 Message Date
admin baafafabdb Design language tweaks, improvements 2026-05-13 20:44:01 +12:00
admin de8b60b9c3 Design language 2026-05-13 09:39:52 +12:00
admin 6c943b14bd Design language 2026-05-13 00:34:34 +12:00
admin ac6179e776 SEO Tweaks 2026-05-12 00:45:02 +12:00
admin 955a563d14 Onboarding / Deployment Scripts / Marketing updates 2026-05-11 21:02:24 +12:00
admin a90dfb7c66 Content Rewrite 2026-05-07 21:47:42 +12:00
admin 0d86f450ec Revert "Merge branch 'main' of http://10.0.0.213:3001/admin/gw-svelte"
This reverts commit 4d70993817, reversing
changes made to 32ccd49d78.
2026-05-07 08:04:13 +12:00
admin 4d70993817 Merge branch 'main' of http://10.0.0.213:3001/admin/gw-svelte 2026-05-07 07:57:56 +12:00
admin 32ccd49d78 Remove CTA button from mobile 2026-05-07 07:57:52 +12:00
admin 7edd4c7f9d AboutUs rewrite 2026-05-06 23:55:31 +12:00
admin ad9df7578a 4.2.3 - CTA footer, How it works 2026-05-06 17:42:43 +12:00
admin 6d021e05ea Testimonials fixes 2026-05-06 16:47:15 +12:00
admin 2f4001b8af 4.2.2 - tracking across email, fixes to dark mode. 2026-05-06 15:50:01 +12:00
admin a7ce4c74b5 4.2.1 final fixes 2026-05-06 11:36:19 +12:00
admin b8b9d12a82 4.2.1 polish 2026-05-06 08:27:24 +12:00
admin 55217f59bd Merge branch 'ui-consistency'
Visual consistency pass: unified H1 token, shared .eyebrow utility,
card border-radius standardised to 28px, card hover transforms
unified to translateY(-6px) scale(1.012).
2026-05-06 07:25:09 +12:00
admin a665368d02 UI: unify H1 size, eyebrow style, card radius and hover transform
Visual consistency pass so every page feels familiar.

H1 token (clamp(34px, 4vw, 56px) / line-height 1.05 / letter-spacing
-0.04em):
- typography.css: hero H1 was 50.2px fixed (with 40px/38px breakpoints).
- BookingPage: was clamp(32px, 4vw, 52px).
Service-, About-, Pricing-, Legal-page H1s already matched.

Shared .eyebrow utility (typography.css):
- 13px, green, uppercase, 700, 0.08em letter-spacing.
- Replaces the bespoke .service-eyebrow (14px) and .instagram-kicker
  (11px green pill).
- The yellow 28px .service-highlight-eyebrow now inherits the same
  utility — the eyebrow line is no longer competing with the H2
  underneath. Local rule kept only for the bottom-margin override.

Card border-radius unified to 28px:
- sections.css .service-card: 20px → 28px
- sections.css .value-card: 16px → 28px
- sections.css .testimonial-card: 20px → 28px
- TestimonialsSection .testimonial-stage: 24px → 28px
- AboutPage .about-section-gradient + .about-contact-card: 36px → 28px
- LegalPage .legal-card: 32px → 28px (mobile 24px → 28px)
- InstagramSection .instagram-panel: 24px → 28px
- PricingPage .meet-greet-prompt: 24px → 28px (mobile 20px → 28px)
Modal dialogs left at 24px (different visual class — overlay, not
inline content card).

Card hover transform unified to translateY(-6px) scale(1.012):
- sections.css .service-card: -6px / 1.01 → 1.012
- sections.css .value-card: -5px (no scale) → -6px / 1.012
- sections.css .testimonial-card: -4px (no scale) → -6px / 1.012
- ServiceLandingPage .service-plan-card / .service-benefit-card:
  -8px → -6px
- PricingPage .pricing-plan-card: -8px → -6px
- ServiceLandingPage .service-related-card already -6px / 1.012.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 21:23:41 +12:00
168 changed files with 16984 additions and 2303 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"allow": [
"Bash(kill %1)",
"Bash(pkill -f \"vite dev\")",
"Bash(npm run *)"
"Bash(npm run *)",
"WebFetch(domain:raw.githubusercontent.com)"
]
}
}
+10 -2
View File
@@ -52,7 +52,7 @@ containers untouched.
- Repo-local SSH config used by the deployment script.
- [nginx/goodwalk.co.nz.svelte.conf.example](nginx/goodwalk.co.nz.svelte.conf.example)
- Example shared-nginx config for routing the main public site to the new
Svelte app and mail API.
Svelte app and mail API, including the onboarding subdomain.
## First-time server preparation
@@ -73,8 +73,9 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env
APP_VERSION=4.0.2
APP_VERSION=4.2.3
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -100,6 +101,11 @@ After the first deploy, edit `/docker/goodwalk-svelte/.env` on the server and re
- `RESEND_API_KEY=replace-me`
- `OWNER_EMAIL=replace-me`
Frontend flags:
- `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false` keeps the sticky mobile booking CTA hidden.
- Set `PUBLIC_ENABLE_MOBILE_CTA_BUTTON=true` to show it again.
4. Confirm the shared Docker network already exists:
```bash
@@ -232,6 +238,8 @@ nginx/goodwalk.co.nz.svelte.conf.example
Important:
- `deploy.ps1` now copies the repo nginx config to `/docker/nginx/conf.d/goodwalk.co.nz.conf` and reloads the shared nginx container as part of deployment.
- The repo nginx config uses Docker's internal resolver so future app/mail container rebuilds will not leave nginx pinned to stale upstream IPs.
- The same nginx config now also routes `onboarding.goodwalk.co.nz` to the Svelte app and `/api/onboarding-submit` to the shared mail API.
- Before cutover, confirm the server has a valid certificate for `onboarding.goodwalk.co.nz`, or adjust the onboarding certificate paths in the nginx config to match your cert layout.
Manual nginx commands, if you ever need them:
+730
View File
@@ -0,0 +1,730 @@
# Goodwalk Design Language (2026 Refined Edition)
This document is the source of truth for Goodwalks visual design system. It defines the visual language, emotional tone, interaction philosophy, and implementation rules used across the website and future digital products.
The goal is not trend-chasing. The goal is to create a calm, trustworthy, modern experience that feels premium without becoming cold, corporate, or overly “tech”.
---
# Core Principles
## 1. Warmth over perfection
Goodwalk is a human service business. The experience should feel:
- calm
- warm
- trustworthy
- emotionally safe
- premium without arrogance
Avoid interfaces that feel:
- clinical
- overly corporate
- aggressive
- “SaaS-like”
- excessively polished
Slight softness and imperfection are intentional.
---
## 2. Refinement over redesign
The brand identity already works.
Modernisation should happen through:
- spacing
- typography
- image treatment
- motion
- surface depth
- consistency
- restraint
Do not redesign for novelty.
---
## 3. Emotion before decoration
Visual decisions must support:
- trust
- clarity
- calmness
- conversion
Do not add visual effects simply because they are modern.
Every effect must have emotional purpose.
---
## 4. Mobile-first experience
The site must feel premium on:
- 375px width
- average brightness outdoors
- thumb-driven interaction
- imperfect network conditions
Mobile is not a secondary layout.
---
## 5. Quiet luxury
The site should feel:
- expensive
- intentional
- calm
- understated
Not:
- flashy
- loud
- hyper-animated
- trend-driven
Modern premium design in 2026 is increasingly about restraint.
---
# Colour System
Defined in:
`src/lib/styles/variables.css`
| Token | Hex | Purpose |
|---|---|---|
| `--gw-green` | `#213021` | Primary brand colour |
| `--green-mid` | `#2d4230` | Hover states, elevated surfaces |
| `--green-soft` | `#344b38` | Optional softer elevated green |
| `--yellow` | `#ffd100` | Primary CTA accent |
| `--yellow-soft` | `#f2bf2f` | Premium warm accent alternative |
| `--off-white` | `#fbfbfb` | Primary background |
| `--surface-light` | `#f7f8f6` | Elevated light surface |
| `--text` | `#2e3031` | Default text |
| `--gray` | `#59606d` | Secondary copy |
| `--beige` | `#e5d6c2` | Warm neutral accent |
---
# Colour Philosophy
## Greens
The green palette represents:
- safety
- reliability
- groundedness
- nature
- professionalism
Greens should feel deep and organic rather than synthetic.
Avoid:
- saturated emerald tones
- neon greens
- cold blue-greens
---
## Yellow usage
Yellow is an accent, not a dominant UI colour.
Yellow should:
- guide attention
- signal warmth
- create optimism
Yellow should NOT:
- overpower sections
- become large reading surfaces
- create visual fatigue
For premium applications:
```css
background:
linear-gradient(
135deg,
#ffd54a,
#f2bf2f
);
```
This creates a warmer, more refined feel.
---
# Surface Design
## Philosophy
Modern interfaces should feel layered rather than flat.
Depth should come from:
- tonal separation
- soft gradients
- atmospheric shadows
- subtle edge highlights
NOT heavy shadows or strong borders.
---
## Surface Rules
### Avoid pure white
Never use:
```css
#ffffff
```
Prefer:
```css
#fbfbfb
#f7f8f6
#f5f6f3
```
---
## Avoid pure black
Never use:
```css
#000000
```
Prefer:
```css
#0f1115
#16181d
#1b1d21
```
---
## Micro gradients
Large flat surfaces should contain extremely subtle tonal variation.
Example:
```css
background:
linear-gradient(
180deg,
rgba(255,255,255,0.95),
rgba(247,248,246,0.95)
);
```
These gradients should be nearly invisible.
---
# Typography
Defined in:
`src/lib/styles/typography.css`
| Token | Family | Purpose |
|---|---|---|
| `--font-head` | Unbounded | Headlines |
| `--font-body` | Readex Pro | Body text |
---
# Typography Philosophy
Typography should create:
- confidence
- calmness
- readability
- rhythm
Avoid:
- excessive weight changes
- overly dense layouts
- tiny text
- over-stylised headings
---
# Typography Rules
## Body copy
Desktop:
```css
font-size: 15px;
line-height: 1.65;
```
Mobile:
```css
font-size: 16px;
line-height: 1.7;
```
---
## Headings
Hero headings:
```css
letter-spacing: -0.04em;
```
Section headings:
```css
letter-spacing: -0.02em;
```
Large headings should feel:
- compact
- intentional
- editorial
---
## Weight restraint
Prefer:
- 400
- 500
- 700
Avoid excessive font-weight variety.
Spacing should create hierarchy more than font weight.
---
# Spacing System
## Philosophy
Modern premium interfaces feel expensive because they are under-filled.
Whitespace is a feature.
Do not compress layouts simply to fit more information.
---
## Spacing Scale
Only use:
- 4
- 8
- 12
- 16
- 24
- 32
- 48
- 64
- 72
- 96
Avoid arbitrary spacing values.
---
# Border Radius
| Context | Radius |
|---|---|
| Standard cards | `28px` |
| Large feature containers | `32px` |
| Pills | `999px` |
| Small UI surfaces | `16px` |
The system should feel soft and approachable.
Avoid:
- sharp corners
- overly circular “bubble UI”
---
# Shadows & Depth
## Philosophy
Modern depth is atmospheric, not dramatic.
Shadows should feel:
- diffused
- soft
- realistic
Avoid:
- heavy elevation
- dark shadows
- obvious floating cards
---
## Standard shadows
### Definition shadow
```css
inset 0 0 0 1px rgba(17,20,24,0.045);
```
### Ambient depth
```css
0 8px 40px rgba(0,0,0,0.06);
```
### Inner edge highlight
```css
inset 0 1px 0 rgba(255,255,255,0.04);
```
---
# Texture & Grain
## Philosophy
Digital flatness feels artificial.
Subtle texture adds:
- realism
- warmth
- richness
Very subtle grain is encouraged on:
- hero sections
- dark surfaces
- footers
- large empty areas
Texture must never become visibly noisy.
---
# Motion System
## Philosophy
Motion should feel calm and physical.
Avoid:
- excessive movement
- bouncing
- playful overshoot
- aggressive transitions
---
## Preferred transition timing
```css
220ms cubic-bezier(0.22, 1, 0.36, 1)
```
---
## Hover behaviour
Modern hover effects should rely more on:
- brightness shifts
- opacity
- slight elevation
- subtle glow
Less:
```css
translateY(-6px)
```
More:
```css
translateY(-2px)
filter: brightness(1.02)
```
---
# Cards
## Philosophy
Cards should not feel disconnected from the layout.
Modern premium layouts use:
- softer separation
- lower contrast
- quieter surfaces
Avoid:
- harsh borders
- obvious “dashboard card” styling
- excessive shadowing
---
# Photography Direction
## This is one of the most important parts of the brand.
Photography should feel:
- candid
- warm
- emotionally genuine
- lightly cinematic
- calm
- naturally lit
Avoid:
- harsh HDR
- over-sharpening
- fake bokeh
- obvious AI look
- cluttered backgrounds
---
## Image treatment
Preferred:
- shallow depth of field
- warm colour grading
- natural greens
- soft contrast
- realistic shadows
Images should feel:
“premium lifestyle”
not:
“local flyer advertisement”
---
# Layout Philosophy
## Editorial rhythm
Not every section should feel identical.
The site should alternate between:
- dense
- airy
- emotional
- informational
This creates pacing and reduces fatigue.
---
## Controlled asymmetry
Small asymmetry is encouraged:
- offset images
- uneven crops
- staggered alignment
- imperfect positioning
This creates humanity and visual interest.
Avoid perfect grid rigidity everywhere.
---
# Interaction Design
## Buttons
Buttons should feel:
- tactile
- confident
- soft
Never aggressive.
---
## Hover states
Hover should:
- reward
- guide
- reassure
Not distract.
---
# Navigation
The navigation should feel:
- lightweight
- stable
- unobtrusive
Avoid:
- oversized sticky headers
- excessive blur
- flashy dropdowns
The navigation exists to support trust and conversion.
---
# Mobile UX Principles
## Thumb-first interaction
Primary actions should remain reachable.
Spacing should prevent accidental taps.
---
## Reduced visual noise
Mobile layouts should:
- simplify aggressively
- reduce simultaneous options
- preserve emotional tone
Not simply shrink desktop layouts.
---
# Accessibility
Always support:
- reduced motion
- readable contrast
- large tap targets
- visible focus states
- scalable text
Accessibility should feel integrated, not bolted on.
---
# Design Language Keywords
The Goodwalk experience should feel:
- calm
- premium
- trustworthy
- editorial
- grounded
- warm
- modern
- understated
- human
- emotionally safe
- refined
- spacious
Never:
- corporate
- loud
- trendy
- hyper-minimal
- cold
- sterile
- flashy
---
# What To Prioritise Next
Highest impact improvements:
1. Better photography consistency
2. Softer surface contrast
3. More atmospheric depth
4. Grain/texture implementation
5. Reduced hover movement
6. More editorial layouts
7. More restrained motion
8. More premium CTA treatment
9. Improved mobile spacing rhythm
10. More subtle card separation
---
# Final Rule
If a design decision looks impressive but reduces:
- clarity
- warmth
- trust
- calmness
Do not ship it.
Goodwalk should feel premium because it feels thoughtful — not because it feels flashy.
---
# Implementation Reference
Technical specs for what is currently live. Update this section when the codebase changes.
## Colour tokens (variables.css)
| Token | Hex | Status |
|---|---|---|
| `--gw-green` | `#213021` | ✅ Live |
| `--green-mid` | `#2d4230` | ✅ Live |
| `--green-soft` | `#344b38` | ✅ Live |
| `--yellow` | `#ffd100` | ✅ Live |
| `--yellow-soft` | `#f2bf2f` | ✅ Live |
| `--gray` | `#59606d` | ✅ Live |
| `--beige` | `#e5d6c2` | ✅ Live |
| `--off-white` | `#fbfbfb` | ✅ Live |
| `--surface-light` | `#f7f8f6` | ✅ Live — use where a surface sits above `--off-white` |
| `--text` | `#2e3031` | ✅ Live |
## Typography (live values)
| Context | Size | Weight | Tracking | Line-height |
|---|---|---|---|---|
| Hero h1 | `clamp(34px, 4vw, 56px)` | 800 | `-0.04em` | `1.05` |
| Section headings | `42px` | 700 | `-0.02em` | `1.08` |
| Body (desktop) | `15px` | 400 | — | `1.65` |
| Body (mobile) | `16px` | 400 | — | `1.70` |
| Buttons | `14px` | 700 | `0.01em` | `1.2` |
| Eyebrow | `13px` | 700 | `0.08em` | — |
## Section padding rhythm (live values)
| Section | Padding |
|---|---|
| `#promise`, `#services` | `96px 0` |
| `#values`, `#testimonials`, `#info` | `72px 0` |
| `#newlead` | `80px 0` |
| `#instagram` | `60px` |
## Card hover behaviour (live values)
```css
/* Service and testimonial cards */
transform: translateY(-2px);
filter: brightness(1.02);
box-shadow: inset 0 0 0 1px rgba(17,20,24,0.06), 0 8px 40px rgba(0,0,0,0.08);
```
Reduced from `translateY(-6px)` — calmer, more restrained per design philosophy.
## Service icon bubble gradient (live)
```css
background: linear-gradient(135deg, #ffd54a, var(--yellow-soft));
```
Angled, warmer gradient — replaces the flat vertical yellow.
## Testimonial card surface (live)
```css
background: linear-gradient(180deg, #ffffff 0%, var(--off-white) 100%);
```
Micro-gradient — avoids flat pure white per surface design rules.
## Scroll reveals (live)
JS (`+layout.svelte`): `IntersectionObserver` targets `.section-heading`, `.service-card`, `.testimonial-card`, `.value-card`. Adds `data-reveal` attribute on mount, toggles `.is-visible` when the element crosses `40px` from the viewport bottom.
CSS (`base.css`): `opacity 0 → 1`, `translateY(20px) → none`, `0.5s`. Wrapped in `prefers-reduced-motion: no-preference`.
## Hero image gradient (live)
`#hero::after` pseudo-element: `linear-gradient(to top, var(--gw-green), transparent)`, `height: 120px`, covering the right 58% of the section. Hidden on mobile.
## Not yet implemented
| Item | Notes |
|---|---|
| Grain / texture | Needs a noise SVG or canvas overlay — skip until photography is consistent |
| `--surface-light` usage | Token is defined; not yet applied to any component |
| `--green-soft` usage | Token is defined; candidate for mega-menu icon hover state |
| More editorial layouts | Structural work — needs a design pass per page |
+2 -2
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.3
FROM node:22-alpine AS builder
ARG APP_VERSION
@@ -9,7 +9,7 @@ COPY package.json ./
RUN npm install
COPY . .
RUN node --experimental-strip-types scripts/export-homepage-content.mjs
RUN node --experimental-strip-types --import="file:///app/scripts/sveltekit-resolver.mjs" scripts/export-homepage-content.mjs
RUN npm run build
FROM node:22-alpine AS runner
+185
View File
@@ -0,0 +1,185 @@
# Marketing Principles for Goodwalk
A working reference for the Goodwalk site rebuild and ongoing marketing decisions. Drawn from Chris Do (The Futur) and Debbie Millman (Design Matters), applied to the goal of acquiring 10 new clients.
# Checklist
* Prioritise emotional trust before visual impressiveness.
* Reduce cognitive load on every screen and interaction.
* Every page should answer: “Am I in the right place?”
* Use whitespace intentionally to create calmness and confidence.
* Interfaces should feel predictable, stable, and effortless.
Avoid clutter, excessive animations, and visual noise.
Design for clarity first, aesthetics second.
Premium experiences rely on restraint, not excess.
Typography hierarchy must immediately guide the eye.
Use fewer colours, but apply them consistently.
Every component should have a clear purpose.
Remove unnecessary borders, labels, and UI chrome.
Make primary actions visually obvious within 2 seconds.
Ensure pages feel fast even before fully loading.
Consistent spacing creates perceived quality and trust.
Use authentic photography over generic stock imagery.
Human faces increase emotional connection and trust.
Testimonials should feel personal and believable, not corporate.
Buttons and CTAs should sound conversational and reassuring.
Interfaces should feel welcoming, not technical.
Avoid overwhelming users with too many choices.
Users should never wonder what happens next.
Design layouts around scanning behaviour, not reading behaviour.
Mobile layouts should feel intentionally designed, not compressed desktop pages.
Use subtle depth, shadows, and contrast to create hierarchy.
Premium brands often use less content, but communicate more clearly.
Calm interfaces increase perceived professionalism.
Align visuals, copy, and interaction style into one consistent tone.
The homepage should communicate trust before features.
Every visual element should reinforce simplicity and confidence.
Reduce form friction wherever possible.
Users should be able to understand the business in under 5 seconds.
Make service quality visually obvious through imagery and spacing.
Avoid sharp transitions or jarring visual elements.
Consistency across pages matters more than visual complexity.
Good UX feels invisible to the user.
Use natural language instead of corporate wording.
Remove anything that feels “template-like”.
Create visual breathing room around important content.
Make interactions feel human, warm, and intentional.
Ensure hover states and animations feel subtle and refined.
Use imagery that reflects real customers and real experiences.
Trust is built through consistency, polish, and predictability.
Pages should feel curated, not crowded.
Premium experiences rely heavily on pacing and rhythm.
Focus attention using contrast, spacing, and hierarchy.
Design should lower anxiety and decision fatigue.
Avoid overexplaining when visuals already communicate meaning.
The best interfaces feel calm, simple, and inevitable.
Every redesign decision should improve trust, clarity, or emotional comfort.
---
## Chris Do's Principles
### 1. Sell the transformation, not the service
People don't buy "dog walking" — they buy peace of mind at work, a tired happy dog, not feeling guilty.
The headline shouldn't be "Professional Dog Walking in Wellington." It should speak to the outcome:
- "Come home to a happy, exercised dog"
- "Your dog's best part of the day, while you're at work"
### 2. Niche down to stand out
"Dog walker" competes with everyone. "Dog walker for working professionals in [suburb] with anxious or reactive dogs" competes with almost no one — and can charge more.
Pick a wedge.
### 3. Price on value, not time
Don't lead with "$25 per walk." Lead with packages and outcomes:
> **The Working Professional Plan** — 3 walks/week, GPS updates, photo reports
Hide the hourly rate. Make it about what they get, not what you do.
### 4. Show, don't tell
Testimonials and proof crush adjectives. "Reliable and caring" is meaningless.
A photo of a muddy grinning dog with a one-line quote from the owner sells:
> "Bowie pulls me to the door when he sees Sarah's car."
### 5. Free is a magnet
Most dog walking sites just have a contact form — that's a closed door. Open one with:
- A free first walk
- A free meet-and-greet
- A downloadable "Is your dog getting enough exercise?" checklist
Get people into the funnel.
---
## Debbie Millman's Principles
### 1. Brand is a story people tell themselves about you
Branding is deliberate differentiation through storytelling.
What's the Goodwalk story? Why do you do this? Are you the ex-vet-nurse who only walks small dogs? The runner who takes high-energy breeds on actual trail runs?
That story belongs on the homepage, not buried on About.
### 2. Consistency builds trust
One voice, one visual identity, everywhere:
- Website
- Instagram
- Car magnet
- The message sent when running 5 minutes late
Owners are handing you keys to their house and the life of their dog. Visual and verbal consistency signals "I am organised and reliable" before you've said a word.
### 3. Design is a tool for clarity, not decoration
Debbie often quotes Massimo Vignelli — design should make the message clearer.
In 3 seconds, can a stranger answer:
- What do you do?
- Who is it for?
- How do I book?
If they have to scroll or think, you're losing them.
---
## Applied: A Plan for 10 New Clients
A site rewrite with these principles in mind.
### 1. Homepage hero
- Outcome-focused headline
- One strong photo of a happy dog mid-walk
- One button: **"Book a free meet-and-greet"**
### 2. Pick a niche and say it out loud
Even just "for [your suburb] working professionals" narrows the field and helps you rank.
### 3. Three packages, not an hourly rate
Make the middle one the obvious choice (the "decoy effect" — Chris talks about this).
### 4. Three testimonials with photos and dog names
Real names, real dogs. Not "J.S. — Customer."
### 5. One story section
Who you are, why you do this, why someone should trust you with their dog and their house key.
### 6. Lead magnet
A free PDF like "How much exercise does your dog actually need?" in exchange for an email. Then you have a list to follow up with.
### 7. Kill booking friction
One-click to a calendar or a WhatsApp link. Not a 7-field form.
---
## Quick Reference Checklist
- [ ] Headline sells the outcome, not the service
- [ ] Niche is named explicitly on the homepage
- [ ] Pricing presented as packages, not hourly
- [ ] At least 3 testimonials with real names, dog names, and photos
- [ ] Founder story visible on homepage
- [ ] Lead magnet (PDF or free meet-and-greet) above the fold
- [ ] Booking is one click — calendar link or WhatsApp
- [ ] Visual and verbal identity consistent across site, Instagram, and comms
- [ ] In 3 seconds: what / who / how-to-book is obvious
+332
View File
@@ -0,0 +1,332 @@
# Mobile Polish — Conversion & Comfort Audit Tracker
## New Rescan Items — Mobile Conversion Opportunities
Fresh opportunities from a second mobile-first pass over the main site.
These are intentionally only the new items, kept separate from the
existing audit below.
### High — conversion strategy and flow
- [ ] **Hero CTA hierarchy still prioritises browsing over booking**
- Files: `src/lib/content/homepage.ts:37-39`, `src/lib/components/HeroSection.svelte`
- Current: the yellow primary CTA is `Explore our services →`, while
`Book a Meet & Greet` is secondary.
- Why: on mobile, high-intent users should be able to choose the next
step immediately. Making the exploratory path more visually dominant
adds friction before the user reaches the lead form.
- Opportunity: test flipping the hierarchy on mobile so booking
becomes the primary CTA and service exploration becomes secondary.
- [ ] **Homepage social proof appears too late in the scroll**
- File: `src/routes/+page.svelte:143-147`
- Current order: `Services -> Values -> Testimonials -> Booking`.
- Why: testimonials are one of the strongest conversion levers, but
on mobile they arrive after several large sections. Users are asked
to keep scrolling before seeing the strongest emotional proof.
- Opportunity: move testimonials above values on the homepage, or
surface one featured review snippet earlier in the page.
- [ ] **Hero still relies on the next section for trust**
- Files: `src/lib/content/homepage.ts:43-49`, `src/lib/components/HeroSection.svelte`,
`src/lib/components/IntroStrip.svelte`
- Current: the hero presents the headline and CTAs, but the review
proof sits in the intro strip below.
- Why: on mobile, the hero needs to answer both "what is this?" and
"can I trust them?" before the user scrolls away. Separating those
two jobs weakens the first decision moment.
- Opportunity: add a compact review/trust chip directly under the
hero subtitle or near the hero CTAs on mobile.
- [ ] **Booking flow asks for dog details before it captures the lead**
- File: `src/lib/components/BookingSection.svelte:298-441`
- Current: step 1 asks for dog name, location, message, and services;
contact details are only requested in step 2.
- Why: this is a higher-friction sequence on mobile. Users often feel
more comfortable giving owner details first, then expanding into pet
specifics once they have mentally committed.
- Opportunity: test reversing the step order so step 1 captures name,
email, and phone first, then dog details second.
### Medium — mobile persuasion and CTA timing
- [ ] **Sticky mobile CTA appears on a fixed pixel threshold rather than page context**
- File: `src/lib/components/MobileBookBar.svelte:26-37`
- Current: visibility is driven by `SHOW_AFTER_PX = 480` and
`HIDE_BELOW_PX = 120`.
- Why: a fixed threshold will feel early on some phones and late on
others. It also ignores whether the hero or booking section is
actually in view.
- Opportunity: switch to an `IntersectionObserver` tied to the hero or
booking section so the bar appears based on user context rather than
raw scroll position.
- [ ] **Testimonials section pushes users off-site before finishing the proof story**
- File: `src/lib/components/TestimonialsSection.svelte:128-134`
- Current: the Instagram CTA appears near the top of the testimonials
section, before the user has fully consumed the review content.
- Why: on mobile, sending users to Instagram this early interrupts the
conversion journey and competes with the booking path.
- Opportunity: demote the Instagram CTA below the carousel, or replace
it with a tighter trust-oriented proof CTA higher up.
- [ ] **Mobile pricing pages lose the consultative "not sure?" nudge**
- File: `src/lib/components/PricingPage.svelte:12-19`
- Current: the meet-and-greet reminder prompt is gated behind
`min-width: 769px`, so desktop gets a tailored nudge and mobile
does not.
- Why: mobile users are more likely to feel overwhelmed by stacked
pricing cards, not less. Removing the consultative reassurance on
the smallest screens is directionally backwards for conversion.
- Opportunity: add an inline mobile prompt after the first pricing
section that says, in effect, "Not sure which option fits? Book a
free Meet & Greet and well help you choose."
### Medium — stacked-page CTA noise
- [ ] **Stacked pricing/service cards repeat the same CTA too many times**
- Files: `src/lib/components/PricingPage.svelte:141-159`,
`src/lib/components/ServiceLandingPage.svelte:94-112`
- Current: when cards collapse to one column on mobile, each card
keeps a full "Book a Meet & Greet" button.
- Why: the repetition turns persuasive choice architecture into visual
noise. Instead of helping the user decide, the page starts feeling
like a stack of repeated asks.
- Opportunity: treat this as a shared mobile pattern across pricing
and service pages. Keep one strong CTA per section, let the popular
card carry the primary action, and demote the rest.
Findings from a focused mobile-experience review (≤768px, with extra
attention to 375px small-phones). Desktop is considered done. Each item
records the where, why, and the concrete change.
> Important context for prioritisation: a dog-walking business is a
> jobs-to-be-done service that users research on the couch. Mobile
> conversion lower-bound is "they Meet & Greet". So the dial-movers
> are: thumb-reach for the booking CTA, legibility, friction-free
> form, and trust signals visible on the first scroll.
---
## High — direct conversion impact
- [x] **Hero buttons stack awkwardly at ~375px**
- File: `src/lib/styles/responsive.css` (new ≤480px block)
- Implementation: At `@media (max-width: 480px)` the hero buttons
flip to `flex-direction: column; gap: 12px;` and each button
becomes full-width with padding `16px 24px`. The 768px-specific
`padding-right: 18px` on the buttons row and the
`margin-right: 12px` on `:last-child` are both reset so the two
CTAs read as a clean stacked stack.
- Why: At 375px the side-by-side primary + outline CTAs were wrapping
unevenly and the primary's prominence collapsed.
- [x] **Mobile header phone button is low-contrast and small**
- File: `src/lib/styles/responsive.css:88-100`
- Implementation: Background bumped from `rgba(33, 48, 33, 0.06)`
`0.10`; padding 9px 12px → 11px 14px (and at ≤480px, 10px 12px);
`font-weight: 600`; `min-height: 44px` to meet the touch-target
minimum; icon size also bumped from 13px → 14px.
- Why: The tap-to-call on the mobile header is one of the highest
intent actions on the site. It now reads as a button rather than a
barely-visible label.
- [x] **Booking form inputs trigger iOS zoom-on-focus**
- File: `src/lib/styles/responsive.css:453-462`
- Implementation: Input `font-size: 15px``16px` at ≤768px. Comment
added explaining the iOS-Safari 16px threshold so a future edit
doesn't accidentally drop it again.
- Why: Below 16px Safari auto-zooms on focus; the page jolts every
time a field is tapped. Critical at the booking conversion step.
- [ ] **Testimonial carousel arrows hard to reach at 375px**
- File: `src/lib/components/TestimonialsSection.svelte` (mobile rules
lines ~640-660)
- Current: arrows pinned to `bottom: 24px` inside a stage with
`padding-bottom: 116px`. Visually at the bottom of a tall card —
requires deliberate stretching.
- Why: The carousel feels passive. Users don't realise they can advance
it; testimonials sell — losing that engagement matters.
- Fix: Lift arrows on small screens:
```css
@media (max-width: 480px) {
.testimonial-arrow { bottom: 80px; }
}
```
- [x] **No persistent "Book Meet & Greet" CTA on mobile after hero scrolls past**
- Files: `src/lib/components/MobileBookBar.svelte` (new),
`src/routes/+layout.svelte` (mount), `src/lib/styles/responsive.css`
(`body { padding-bottom: 64px }` at ≤768px).
- Implementation v2 — *Airbnb-style soft-container, scroll-triggered*:
- **Soft container**: a translucent white tray (`rgba(255, 255,
255, 0.96)` with backdrop-filter blur) sits flush at the bottom
with a thin top hairline and a soft shadow. The brand-yellow CTA
pill lives *inside* the tray, not as the tray itself — so the
action stays unmistakable but the surrounding chrome is calm.
- **Scroll-triggered**: the bar slides up + fades in only after the
user has scrolled ~480px (≈ one mobile viewport, hero out of
view). Slides back out below ~120px to avoid flicker near the
top. `prefers-reduced-motion` respected — the slide is dropped,
only the opacity fade remains.
- **Hidden on `/contact-us` and `/booking`** (already in the form).
- **Resets on navigation**: `afterNavigate` resets `visible = false`
so each new page starts hidden until the user has earned it again.
- **Accessibility**: `aria-hidden` toggles with visibility; CTA
`tabindex` flips to `-1` while hidden so keyboard users don't
stumble onto an off-screen control; `pointer-events: none` while
hidden so the user can't accidentally tap it during the fade.
- **Safe-area aware**: `env(safe-area-inset-bottom)` so iPhones
with the home-bar gesture area get correct spacing.
- Body bottom-padding of 64px on mobile keeps the footer from sitting
behind the bar when it's visible at the bottom of long pages.
- Why: Sticky mobile CTAs are a validated conversion pattern (Airbnb,
Booking.com, etc.), but the v1 full-yellow bar was tonally wrong
for Goodwalk — it competed with the calm brand voice and dominated
the screen permanently. v2 keeps the conversion benefit (one-tap
booking, always one swipe away after engagement) without yelling.
## Medium — legibility & polish
- [x] **Hero title can wrap awkwardly at 375px**
- File: `src/lib/styles/responsive.css` (≤480px block)
- Implementation: At ≤480px the desktop H1 drops to 32px /
line-height 1.12 and the dedicated `.hero-heading-mobile` element
drops to 30px / 1.12 (it was 33.5px). The two-line "Unleashing Fun
in / Your Dog's Day!" now sits comfortably with breathing room
above the subtitle.
- Why: Previous 38px / 33.5px sizings were a hair too big for 375px;
line-2 felt cramped against the subtitle.
- [x] **Body text 15px feels small on mobile (and on ultra-wide desktop)**
- File: `src/lib/styles/responsive.css` (≤768px body rule)
- Implementation: `body { font-size: 16px; }` at ≤768px, with a
comment noting the iOS-Safari 16px zoom threshold so this rule
isn't accidentally undone.
- Desktop (≥769px) still inherits 15px from `base.css` for now — the
ultra-wide bump is tracked separately at the bottom of this file
so it can be reasoned about independently (clamp vs. breakpoint).
- Why: 16px is the modern legibility standard on mobile, dovetails
with the iOS zoom-on-focus rule for inputs, and reduces read
fatigue on long pages (about, FAQ answers, legal).
- [ ] **FAQ summary tap target too small**
- File: `src/lib/styles/sections.css` (`.faq summary` rules)
- Current: just text height (~22-26px) — below the 44px minimum.
- Fix:
```css
.faq summary {
padding: 12px 0;
min-height: 44px;
display: flex; align-items: center;
}
```
- [ ] **Booking service-option chips wrap awkwardly at 375px**
- File: `src/lib/styles/responsive.css:468-470`
- Fix at ≤480px: 2-up grid with tighter gap:
```css
.booking-service-options { gap: 10px 12px; }
.booking-toggle-option { flex: 1 1 calc(50% - 6px); }
```
- [ ] **Footer social icons spaced too tight for thumbs**
- File: `src/lib/styles/sections.css` (`.social-links { gap: 14px }`)
- Current 14px gap with 40px icons → centres are 54px apart. Apple
HIG wants 8px+ between targets after the 44px minimum.
- Fix:
```css
@media (max-width: 768px) { .social-links { gap: 18px; } }
```
- [ ] **Pricing cards stack to 1-col with multiple "Book" buttons in a row**
- File: `src/lib/components/PricingPage.svelte` (and ServiceLandingPage
plan grids)
- Why: After stacking, the user sees Plan → Book → Plan → Book → Plan
→ Book in vertical sequence. The repetition reads as noise rather
than choice; the "popular" anchor disappears.
- Fix (opinionated): on mobile, only the popular plan keeps its CTA
button. Other plans show a smaller "Choose this plan" link instead,
or no per-card CTA at all (a single CTA appears under the grid):
```css
@media (max-width: 768px) {
.pricing-plan-card:not(.pricing-plan-popular) .pricing-plan-cta {
display: none;
}
}
```
Then keep the existing under-grid `.service-plan-reassurance` pill
and add a single "Book a Meet & Greet" button below it.
- [ ] **Mobile nav header is too tall — eats above-the-fold real estate**
- File: `src/lib/styles/responsive.css:74`
- Current: `nav { padding: 20px 24px; }` + 25px logo = ~65px header.
On iPhone 13 (844px), this leaves ~380px for hero before scroll —
less than what the Goodwalk dog-image needs to feel like a hero.
- Fix at ≤480px:
```css
nav { padding: 14px 20px; }
.logo img { height: 22px; }
```
## Low — incremental polish
- [ ] **Hero top padding generous at 375px**
- File: `src/lib/styles/responsive.css:179`
- Reduce hero `padding-top` from 50px to 32px at ≤480px.
- [ ] **Intro trust badge feels edge-to-edge at 375px**
- File: `src/lib/styles/responsive.css:296-301`
- Add `padding: 18px 16px; margin: 0 12px;` at ≤480px.
- [ ] **Testimonial quote mark too large at 375px**
- File: `src/lib/components/TestimonialsSection.svelte:~595`
- Current: 44px. Reduce to 36px at ≤480px.
- [ ] **Booking form labels could shrink slightly at 375px**
- File: `src/lib/styles/responsive.css:450`
- Optional: 16px → 15px at ≤480px to give field width back to the
input value (where it actually matters for legibility).
- [ ] **No scroll-to-top affordance on long pages (booking, pricing)**
- Currently absent. Low priority but helpful when users have scrolled
past the booking form and want to re-read service details. Could be
folded into the same sticky-book-bar work above (one bar, both jobs).
## Open from elsewhere
- [ ] **Ultra-wide desktop body font feels small** *(noted by user)*
- Currently `body { font-size: 15px }` ([base.css:15](src/lib/styles/base.css#L15)).
On ≥1800px screens with the `--max-w` already widening (per the
1800px breakpoint), 15px in long-form sections (about, FAQ answers,
legal) becomes uncomfortable.
- Suggested fix: bump to `clamp(15px, 0.95vw, 17px)` on `body`, OR
introduce a `@media (min-width: 1600px) { body { font-size: 17px; } }`.
Either keeps the desktop ≤1599px experience identical and only
expands type when there's genuinely more reading width.
## Deliberately not actioning
- **Drop reveal animations on mobile to "save bandwidth".** They're
IntersectionObserver-driven, cost nothing perceivable, and add brand
polish. Removing them would make the mobile site feel cheaper for no
measurable performance gain.
- **Replace the testimonial carousel with a stacked list on mobile.**
Tempting (carousels famously hide content), but the carousel is
central to the brand's "see real dogs" pitch. Better to fix the arrow
reachability and let the autoplay do the work.
---
## Suggested order of attack
If you want one batch that moves the dial: **High items 1, 2, 3, 5**
together is roughly an hour of work — they hit the hero, the header
tap-to-call, the booking form's biggest mobile bug (zoom-on-focus), and
add the sticky CTA. That's the package I'd ship first. Item 4 (carousel
arrows) is a one-line fix once you're already in `responsive.css`.
The Medium list is best as a second pass — body-text bump,
header-padding reduction, FAQ tap-target, and pricing-card-CTA dedupe
all compound into a noticeably more "intentional on mobile" feel
without any structural change.
+424
View File
@@ -0,0 +1,424 @@
goodwalk.co.nz — Full SEO Audit Report
Date: 12 May 2026 | Site: Auckland dog walking service (SvelteKit + NGINX) | Auditor: 9 specialist subagents run in parallel
---
SEO Health Score: 60 / 100
┌──────────────────────────┬────────┬───────┬───────────┐
│ Category │ Weight │ Score │ Weighted │
├──────────────────────────┼────────┼───────┼───────────┤
│ Content Quality │ 23% │ 68 │ 15.6 │
├──────────────────────────┼────────┼───────┼───────────┤
│ Technical SEO │ 22% │ 52 │ 11.4 │
├──────────────────────────┼────────┼───────┼───────────┤
│ On-Page SEO │ 20% │ 62 │ 12.4 │
├──────────────────────────┼────────┼───────┼───────────┤
│ Schema / Structured Data │ 10% │ 58 │ 5.8 │
├──────────────────────────┼────────┼───────┼───────────┤
│ Performance (CWV) │ 10% │ 62 │ 6.2 │
├──────────────────────────┼────────┼───────┼───────────┤
│ AI Search Readiness │ 10% │ 51 │ 5.1 │
├──────────────────────────┼────────┼───────┼───────────┤
│ Images │ 5% │ 65 │ 3.3 │
├──────────────────────────┼────────┼───────┼───────────┤
│ TOTAL │ │ │ 59.8 → 60 │
└──────────────────────────┴────────┴───────┴───────────┘
▎ The score is dragged down by one dominant structural issue: all 17 location pages are noindex. Remove that single directive and the score jumps ~8 points immediately.
---
CRITICAL Issues (Fix Immediately)
C-1 — All 17 location pages are noindex
File: src/routes/locations/[suburb]/+page.svelte line ~23
Every suburb page (/locations/mt-eden, /locations/kingsland, etc.) has noindex set. These are Goodwalk's primary local SEO assets — suburb-specific content, Service schema, BreadcrumbList, targeted H1s — and none of
it is visible to Google. This is the largest single SEO issue on the site.
Fix: Remove noindex from the <SeoHead> call. One line change, high impact.
C-2 — Location pages missing from sitemap
File: src/routes/sitemap.xml/+server.ts
The sitemap deliberately excludes /locations/*. After unblocking indexation (C-1), Google discovery will be slow without sitemap inclusion.
Fix: Import locationPages from $lib/content/locations and generate dynamic sitemap entries for all 17 suburb URLs.
C-3 — reviewCount in schema is 4 when business has 30+ Google reviews
File: src/routes/+page.svelte ~line 101
reviewCount is hardcoded to String(data.content.testimonials.length) which resolves to "4". The site claims "30+ five-star Google reviews" in visible copy. Emitting reviewCount: "4" suppresses rich result eligibility
and creates a factual schema/content discrepancy.
Fix: Change to a static "30" floor value or a content-managed field.
---
HIGH Priority (Fix Within 1 Week)
H-1 — Homepage suburb list not linked to location pages
File: src/lib/content/homepage.ts, InfoSection component
The info.suburbs string is plain text — not hyperlinks. Even after fixing C-1, Google won't easily discover or distribute PageRank to location pages without internal links from the homepage. This is the fastest way to
give location pages authority.
Fix: Render each suburb as <a href="/locations/{slug}"> in the InfoSection.
H-2 — Font Awesome npm import is killing performance
File: src/routes/+layout.svelte
Three FontAwesome CSS files (fontawesome.min.css, solid.min.css, brands.min.css) are imported globally for ~8 icons. Combined with 9 @fontsource weight files (Readex Pro × 4, Unbounded × 4, Fredoka × 1), this is
estimated at 600900 KB uncompressed CSS + WOFF2 on every page. This is the single largest performance bottleneck and the highest-ROI fix.
Fix: Replace Font Awesome with inline SVG components for the specific icons used. Drop unused font weights.
H-3 — Hero image preload is broken
File: src/lib/components/SeoHead.svelte
<link rel="preload" as="image"> points to the raw .png file, but @sveltejs/enhanced-img generates AVIF/WebP variants that the browser actually uses. The preload wastes bandwidth fetching the PNG while the actual LCP
element loads later. The fetchpriority="high" on the <enhanced:img> already handles this correctly.
Fix: Remove the <link rel="preload"> from SeoHead when preloadImage=true — rely solely on fetchpriority="high" on the image element.
H-4 — Schema missing @id node stitching
File: src/routes/+page.svelte, src/routes/[slug]/+page.svelte, src/lib/seo.ts
No schema block uses @id URIs, so Google cannot link the LocalBusiness entity to the Service blocks across pages — they're treated as unrelated graphs. This weakens Knowledge Graph entity consolidation significantly.
Fix:
- Add "@id": "https://www.goodwalk.co.nz/#business" to the LocalBusiness block
- Reference it as "provider": {"@id": "https://www.goodwalk.co.nz/#business"} in every Service block
H-5 — @type: LocalBusiness too generic — missing PetCareService
File: src/routes/+page.svelte ~line 41
Using ["LocalBusiness", "PetCareService"] as the @type improves entity disambiguation for pet-service queries. No breaking change.
H-6 — No NZ directory citations
No codebase evidence of listings on:
- Yellow Pages NZ (yellow.co.nz) — highest DA, commonly cited by Google NZ
- Finda NZ (finda.co.nz) — pet services category exists
- NoCowboys (nocowboys.co.nz) — NZ-specific service directory
- Localist (localist.co.nz) — Auckland local business focus
- Neighbourly (neighbourly.co.nz) — suburb-level community platform
Per Whitespark 2026, 3 of the top 5 AI visibility factors are citation signals. For a local NZ service business this gap is material.
H-7 — Service pages missing from desktop navigation
The desktop nav only has #services (anchor), /our-pricing, and /about. Direct /pack-walks, /dog-walking, /puppy-visits URLs are not in the desktop nav — only the mobile nav and footer.
---
MEDIUM Priority (Fix Within 1 Month)
M-1 — All three service pages below 800-word threshold
Pack Walks (~580 words), 1:1 Walks (~530 words), Puppy Visits (~510 words) all fall below the service page minimum. Missing from each: FAQ section, process explanation unique to that service, breed/situation examples.
M-2 — H1 lacks "dog walking Auckland" keyword
The hero H1 is "Come home to a calm, happy dog" — emotionally led but not keyword-anchored. "Auckland Central" appears in the subtitle but the primary query phrase "dog walking Auckland" is absent from any rendered H1
on the site. Google's local ranking algorithm still weights explicit keyword presence in H1.
M-3 — FAQ answers not self-contained for AI citation
All FAQ answers require context to make sense. For AI Overviews, each answer needs the entity name and full context embedded in the answer itself. Example: "All walks are paid for a week in advance, via invoice" →
should read "Goodwalk charges for walks one week in advance via invoice."
M-4 — www vs non-www in sitemap vs canonical domain
The sitemap uses https://www.goodwalk.co.nz as the base. Confirm this is the canonical domain (it should be — NGINX enforces the www redirect). The audit was requested for https://goodwalk.co.nz (no www). If both are
correct and consistent, no action needed.
M-5 — lastmod always set to today's date
File: src/routes/sitemap.xml/+server.ts line ~24
new Date().toISOString() at request time means every URL always shows today as last-modified. Google treats this as an unreliable freshness signal (may look manipulative). Replace with static per-route dates
reflecting actual content changes.
M-6 — No Content Security Policy header
File: nginx/goodwalk.co.nz.svelte.conf.example
No Content-Security-Policy header is set. GA4 is loaded from www.googletagmanager.com without a CSP allowlist. Also missing Permissions-Policy. No immediate SEO impact but a security posture gap.
M-7 — Phone format inconsistent across schema
Homepage schema: +64-22-642-1011 (dashes). seo.ts provider: +64226421011 (no dashes). Standardise all schema telephone to E.164 without hyphens: +64226421011.
M-8 — No CDN / Brotli compression
All traffic routes to an Auckland VPS with no edge caching. PageSpeed mobile lab scores will be penalised by ~150300ms TTFB from geography. NGINX has Gzip at level 6 but no Brotli (1525% better compression).
Short-term: enable Brotli or raise Gzip to level 9. Medium-term: add Cloudflare free tier.
M-9 — Add AI crawler permissions to robots.txt
File: src/routes/robots.txt/+server.ts
The robots.txt exists but has no AI crawler-specific directives (GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot). Explicitly allowing these signals GEO awareness and removes ambiguity.
M-10 — No llms.txt
A minimal static/llms.txt declaring key pages and a business summary would improve citability on Perplexity and emerging LLM crawlers. Low effort, growing importance.
M-11 — No business summary in AI citation window (140160 words)
No single paragraph on the site answers who/what/where/when/how-much in one citable block. GEO agent provided a ready-to-use 148-word draft in its findings.
M-12 — areaServed on homepage uses strings, not Place objects
File: src/routes/+page.svelte lines 5775
Homepage hardcodes suburb strings directly. The buildAreaServed() utility in seo.ts already returns correctly typed Place objects — it just needs to be called on the homepage too.
M-13 — FAQPage schema on commercial site (Google rich results — limited value)
Files: src/routes/+page.svelte, src/routes/[slug]/+page.svelte
Google restricted FAQPage rich results to government/healthcare sites (Aug 2023). The FAQPage blocks produce no Google rich results for Goodwalk. However, the schema does provide AI/LLM citation benefit — it's the
structured content that Perplexity and Google AI Overviews prefer to extract. Recommendation: Keep FAQPage schema but note it won't produce visual rich results in standard SERPs.
---
LOW Priority (Backlog)
┌────────────────────────────────────────────────┬───────────────────────┬──────────────────────────────────────────────────────────┐
│ Issue │ File │ Notes │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Add priceRange: "$$" to LocalBusiness schema │ +page.svelte │ Appears in knowledge panels │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Add Person schema for Alessandra (founder) │ +page.svelte │ E-E-A-T signal for founder-led business │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Add SearchAction to WebSite schema │ +page.svelte │ Sitelinks Searchbox potential │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Add ContactPage schema to /contact-us │ [slug]/+page.svelte │ Missing structured data on key conversion page │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Add seller to AggregateOffer on service pages │ [slug]/+page.svelte │ Link back to LocalBusiness entity │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Remove @context from nested Review objects │ +page.svelte │ Redundant, technically incorrect placement │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Mobile testimonial carousel CLS │ responsive.css │ Set min-height on mobile stage for longest quote │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ disallow: /api/ in robots.txt │ robots.txt/+server.ts │ Keeps crawl budget on content pages │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ disallow: /contract in robots.txt │ Same │ Zero indexing value, wastes crawl budget │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ HSTS preload directive │ NGINX config │ Required for HSTS preload list submission │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Move large images to src/lib/images/ │ /static/images/ │ Puppy visits image is 3,327×2,217px, served uncompressed │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ terms-and-conditions, privacy-policy → noindex │ static-pages.ts │ Currently indexed; minor │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ IndexNow protocol │ New route │ Bing/Yandex faster indexation │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Founding year in content │ Content files │ "Serving Auckland since [year]" adds trust signal │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Second review platform (NoCowboys NZ) │ GBP/footer │ Review diversity reduces single-platform risk │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ YouTube presence │ External │ Highest-correlation AI citation signal — entirely absent │
├────────────────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────────────┤
│ Question-format H2/H3 headings │ Content files │ Current headings are descriptive, not interrogative │
└────────────────────────────────────────────────┴───────────────────────┴──────────────────────────────────────────────────────────┘
---
Backlink Profile
The backlinks agent was unable to run Python API scripts (Bash access not available in this session). However it identified the following key link building opportunities:
Unconfirmed NZ directory gaps (verify manually):
- Yellow Pages NZ, Finda NZ, NoCowboys, Localist, Neighbourly, Dogz Online NZ, PetPages NZ
Known competitor backlink sources to audit:
Search site:yellow.co.nz "dog walking auckland" and site:nocowboys.co.nz "dog walking" to find listings Goodwalk is missing.
To get a full scored backlink report, run python scripts/commoncrawl_graph.py goodwalk.co.nz --json via the terminal.
---
What's Working Well
┌───────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Strength │ Detail │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Full SSR │ SvelteKit SSR with adapter-node — all content in initial HTML, crawlers receive complete pages │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Security headers │ HTTPS, HSTS, X-Frame-Options, X-Content-Type-Options, HSTS all present │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Mobile implementation │ overflow-x: clip, iOS zoom prevention, safe-area-inset-bottom, aria-expanded — thorough │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ LocalBusiness schema │ Comprehensive: opening hours, offer catalog, reviews, sameAs, area served — strong foundation │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ max-snippet:-1 │ Correctly set — allows Google to use any text length for AI Overviews │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ LCP optimisation signals │ fetchpriority="high", loading="eager", decoding="async" on hero image │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ SSR structured data │ JSON-LD is server-rendered in initial HTML, not injected by JS │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Legacy redirects │ /about-us, /booking, /home-stays all 301 correctly via SvelteKit │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Canonical consistency │ siteUrl and NGINX redirect align on https://www.goodwalk.co.nz │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ geo.region / hreflang │ NZ-AUK geo meta and en-NZ hreflang correctly implemented │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Location page content quality │ 17 pages with genuinely unique suburb intros, named parks, leash notes — passes doorway test │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Brand differentiation │ "Tiny Gang," small dog specialist, solo operator with named walker — strong and citable │
└───────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────┘
---
Prioritised Action Plan
WEEK 1 — Critical + quick wins
[C-1] Remove noindex from /locations/[suburb]/+page.svelte → 5 min
[C-2] Add location pages to sitemap.xml/+server.ts → 30 min
[C-3] Fix reviewCount: 4 → 30 in AggregateRating → 5 min
[H-1] Link suburb names on homepage InfoSection to location pages → 1 hr
[H-3] Remove broken <link rel="preload"> from SeoHead → 15 min
[H-4] Add @id anchors to LocalBusiness and Service schemas → 30 min
[H-5] Change @type to ["LocalBusiness", "PetCareService"] → 5 min
[M-7] Standardise telephone to +64226421011 in all schema → 10 min
WEEK 2 — Performance + citations
[H-2] Replace Font Awesome with inline SVG icons → 3 hrs
[H-6] Create NZ directory listings (Yellow Pages, Finda, NoCowboys, Localist, Neighbourly)
[M-9] Add AI crawler directives to robots.txt → 15 min
[M-12] Fix areaServed → call buildAreaServed() on homepage → 15 min
[M-5] Replace dynamic lastmod with static per-route dates → 30 min
MONTH 1 — Content + AI readiness
[M-1] Expand service pages to 800+ words each (add FAQ, process, examples)
[M-2] Update H1 or subtitle to include "dog walking Auckland"
[M-3] Rewrite FAQ answers to be self-contained, entity-named
[M-10] Create static/llms.txt → 30 min
[M-11] Add 148-word business summary paragraph to About page
[M-6] Add Content-Security-Policy and Permissions-Policy headers
BACKLOG — Nice to have
Add Person schema for Alessandra
Add priceRange, SearchAction, ContactPage schema
Move large images to src/lib/images/ for AVIF/WebP pipeline
Enable Brotli on NGINX
Add Cloudflare CDN
Build YouTube presence
Second review platform (NoCowboys NZ)
---
▎ SXO agent is still running — I'll append its search experience / intent-match findings when it completes.
---
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Built by agricidaniel — Join the AI Marketing Hub community
🆓 Free → https://www.skool.com/ai-marketing-hub
⚡ Pro → https://www.skool.com/ai-marketing-hub-pro
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✻ Cogitated for 4m 23s
● Agent "SXO search experience optimization for goodwalk.co.nz" completed
● The SXO agent is in — here are the additional findings it surfaced:
---
SXO Findings — Search Experience Optimization
SXO Gap Score: 68 / 100
Page-Type Analysis
The site's structure is largely correct — single-provider service pages are the right type for branded and long-tail queries. The dominant structural problem is the same one found in Technical SEO: the 17 location
pages are noindexed, which means Goodwalk cannot compete for the high-value suburb-specific queries ("dog walker Mt Eden", "dog walking Ponsonby") where a dedicated location page would beat both a homepage and a
directory listing.
For the head terms "dog walker Auckland" / "dog walking Auckland", the SERP is dominated by Local Pack results (~4055% of clicks) and aggregator directories (Bark.com, NoCowboys). A single-provider homepage cannot
displace aggregators on these terms — the real wins are in the Local Pack (GBP) and in long-tail suburb + service queries where location pages and specific service pages can rank.
Additional Issues Surfaced by SXO
HIGH — Homepage <title> wastes 7 characters with "Home |"
Current: "Home | Auckland Dog Walking | Goodwalk"
The "Home |" prefix is dead space and signals a generic page in the SERP snippet. Replace with:
"Auckland Dog Walker | Pack Walks & 1:1 Walks | Goodwalk"
HIGH — Homepage meta description missing price anchor, suburbs, and trust signal
Current: "At Goodwalk, we offer Tiny Gang pack walks and one on one dog walking services throughout Auckland..."
A competitor writing "Trusted by 30+ Auckland families. Pack walks from $49.50. Free Meet & Greet. Covering Mt Eden, Ponsonby, Grey Lynn & more." will outperform on click-through rate.
HIGH — "Always Alessandra" message is buried 23 scrolls deep
The single-named-walker proposition is the strongest brand differentiator but appears in the Promise section, not above the fold. Moving it into or immediately below the hero would improve conversion for all personas.
MEDIUM — "Reactive/anxious dog" use case not addressed
Kate's testimonial mentions "slightly hyper and anxious dog" but nowhere on the site do the words "reactive", "anxious", or "nervous dog" appear as a heading or FAQ answer. This is a high-intent search pattern ("dog
walker for anxious dog Auckland") that the site could own with a single FAQ item on the 1:1 Walks page.
MEDIUM — Price anchor missing from homepage above-the-fold
Price-sensitive users won't scroll to pricing. Add "Pack walks from $49.50" to the homepage hero or intro strip.
MEDIUM — Phone number not in hero or header
(022) 642 1011 only appears in footer and About page. For a mobile service business, a visible tel: link in the header reduces friction for users who want to call before committing to a form.
MEDIUM — /our-pricing missing question-format H2
Adding <h2>How much does dog walking cost in Auckland?</h2> to the pricing page directly targets a PAA (People Also Ask) box question and could earn a Featured Snippet position.
LOW — /dog-walking page targets "1:1 walks" but users search "private dog walker"
The slug (/dog-walking) is excellent, but the H1 and title target "1:1 Dog Walks" — a low-volume phrase. "Private dog walker Auckland" has meaningfully higher search volume and would be a stronger secondary keyword
target for this page.
CONFIRM — MobileBookBar feature flag
The sticky mobile CTA uses isMobileCtaButtonEnabled() feature flag. Confirm this is active in production — if disabled, the site is missing a high-conversion mobile element.
---
Updated Priority Action Plan (with SXO additions)
WEEK 1 — Critical fixes + zero-effort wins
[C-1] Remove noindex from location pages → 5 min
[C-2] Add location pages to sitemap → 30 min
[C-3] Fix reviewCount: 4 → 30 in AggregateRating schema → 5 min
[SXO] Rewrite homepage <title> (remove "Home |") → 5 min
[SXO] Rewrite homepage meta description (add suburbs, price, reviews) → 15 min
[H-1] Link suburb names on homepage to location pages → 1 hr
[H-3] Remove broken hero image <link rel="preload"> → 15 min
[H-4] Add @id anchors to LocalBusiness + Service schema → 30 min
[H-5] Change @type to ["LocalBusiness", "PetCareService"] → 5 min
[M-7] Standardise telephone to +64226421011 in all schema → 10 min
WEEK 2 — Performance + conversion + citations
[H-2] Replace Font Awesome with inline SVG icons → 3 hrs
[SXO] Move "always Alessandra" message above the fold → 30 min
[SXO] Add price anchor ("Pack walks from $49.50") to homepage hero → 15 min
[SXO] Add phone number to header with tel: link → 15 min
[SXO] Add reactive/anxious dog FAQ to homepage + 1:1 Walks page → 30 min
[H-6] Build NZ directory listings (Yellow Pages, Finda, NoCowboys, Localist)
[M-9] Add AI crawler directives to robots.txt → 15 min
[M-5] Replace dynamic lastmod with static per-route dates → 30 min
MONTH 1 — Content depth + AI readiness
[M-1] Expand service pages to 800+ words (FAQ, process, examples)
[SXO] Add question-format H2 to /our-pricing page → 10 min
[SXO] Add "private dog walker Auckland" keyword to /dog-walking → 30 min
[M-3] Rewrite FAQ answers to be self-contained + entity-named
[M-10] Create static/llms.txt → 30 min
[M-11] Add 148-word business summary paragraph to About page
[M-6] Add CSP and Permissions-Policy headers
BACKLOG
Add Person schema for Alessandra + priceRange + SearchAction
Move large images to src/lib/images/ for AVIF/WebP pipeline
Enable Brotli on NGINX / add Cloudflare CDN
Build NoCowboys review profile
Build YouTube presence
Blog / educational content for informational queries
---
The complete audit is now in. All 10 specialist agents have reported. The dominant theme across every analysis: remove noindex from the location pages — it's a one-line change that unlocks the site's entire local SEO
strategy.
---
+143
View File
@@ -0,0 +1,143 @@
# UX Polish — Conversion Audit Tracker
Findings from the senior-marketing-lens audit, with completion status. Each
item has a one-line rationale and the file/line where the change lives (or
will live).
> Only commit to "We'll reply within 24 hours" if Aless can actually hold
> to it. If response time is more like 1-2 business days, soften to
> "within one business day".
---
## High — direct conversion impact
- [x] **Hero primary CTA: "Learn more" → "Explore our services →"**
- File: `src/lib/content/homepage.ts:38`
- Why: "Learn more" is the lowest-intent CTA that exists.
- [x] **Promise CTA: "See our services" → "Book a free Meet & Greet"**
- File: `src/lib/content/homepage.ts:59`
- Also: target changed from `#services` to `/contact-us` so the CTA goes
to the booking page instead of bouncing back up to a service list.
- Why: After the value prop + happy-dog photo, sending visitors to the
services list is a step backwards. Push them to book.
- [x] **Booking subtitle now states response time**
- File: `src/lib/content/homepage.ts:159-162`
- Old: *"...so we can reach out to arrange your free, no-obligation Meet & Greet."*
- New: *"...We'll reply within 24 hours to arrange your free, no-obligation Meet & Greet."*
- General-enquiry variant updated to match.
- Why: Open-ended "we'll reach out" creates anxiety at submit time.
- [x] 1 **Pricing page — Google rating trust signal above plan grid**
- File: `src/lib/components/PricingPage.svelte`
- Implementation: Pill-styled trust badge inside the green hero,
directly under the subtitle — five yellow stars + "30+ five-star
Google reviews" label + arrow, links out to Google. Styled to read
against the green hero (semi-transparent white pill) rather than
reusing the cream IntroStrip, which would have clashed.
- Why: Visitors land on pricing mid-decision; trust signal now appears
before the plan grid.
- [x] 2 **Service plan CTAs — add free / no-obligation reassurance**
- File: `src/lib/components/ServiceLandingPage.svelte`
- Implementation: A subtle green pill *"Every booking starts with a
free, no-obligation Meet & Greet."* (yellow shield-heart icon) sits
centred directly under the plan grid on every service page, above the
Extras block. Reuses the brand-tinted-pill aesthetic so it feels
native, not tacked on.
- Why: The "Book a Meet & Greet" buttons under each plan didn't carry
risk-reversal phrasing in their immediate context. Now they do.
## Medium — trust + polish
- [x] 3 **Quantify the Google rating wherever it appears**
- Files: `src/lib/content/homepage.ts:46`,
`src/lib/components/Footer.svelte:89`,
`src/lib/components/TestimonialsSection.svelte:200`,
`src/lib/components/PricingPage.svelte` (new pricing-trust pill).
- Implementation: "All 5 star reviews on Google!" → "30+ five-star
Google reviews" everywhere. Aless confirmed 30+ as the count.
- Why: A specific number is dramatically more credible than "all".
- [x] 4 **Lean into the "limited spots" angle**
- File: `src/lib/content/pack-walks.ts` (added `scarcityNote` to the
`pricing` block); `src/lib/types.ts` (added optional `scarcityNote?:
string` to ServicePageContent.pricing); rendered in
`src/lib/components/ServiceLandingPage.svelte` directly under the
plan grid as a yellow-tinted pill with a clock icon.
- Copy: *"We keep packs small (4-8 dogs) — popular days fill up fast."*
- Only set on Pack Walks (the 4-8 number is specific to that service);
the field is optional so 1:1 Walks and Puppy Visits get nothing.
- Why: Real, honest scarcity. The 4-8 cap is already a fact; saying it
out loud nudges decision-making.
- [ ] **About page — quantify Aless's expertise**
- File: `src/lib/content/about.ts:29-30`
- Why: "years of experience" is the weakest possible claim. Replace with
concrete numbers Aless can stand behind: years operating, dogs in
rotation, first-aid certification.
- [x] 5 **Pack Walks pricing intro — lead with the differentiator**
- File: `src/lib/content/pack-walks.ts:23-24`
- Implementation: Old intro led with "Our pack walks are a permanent
booking of at least one walk day a week..." (commitment ask first).
New intro leads with the benefits: *"Small packs of 4-8 dogs, 2-hour
outings at Auckland's scenic dog parks and beaches, with free pick-up
and drop-off included. We reinforce recall, car manners, and leash
etiquette while your dog plays. Booked as a permanent weekly slot —
gift your dog the best life!"*
- Why: Buyers scan for benefits before commitments. Lead-with-policy
framing creates resistance; lead-with-benefit framing builds desire.
- [ ] **FAQs — reframe from policy to reassurance**
- File: `src/lib/content/homepage.ts:180-205`
- Why: Answers are correct but read like terms & conditions. Lead with
the *why* (the benefit/reassurance), then the *what*.
## Low — incremental polish
- [x] **Home services-card CTAs: "Learn more" → outcome-oriented**
- File: `src/lib/components/ServicesSection.svelte:29`
- Implementation: Visible label is now derived from the service title —
*"See Pack Walks pricing →"*, *"See 1:1 Walks pricing →"*, *"See
Puppy Visits pricing →"*. The previously-added screen-reader-only
"about <Service>" span was removed since the visible label now carries
that context for everyone, not just assistive tech users.
- Why: "Learn more" was the lowest-intent CTA on the page; the new
label states the destination and the next step.
- [x] **Testimonials intro blurb — sharper jobs-to-be-done framing**
- File: `src/lib/components/TestimonialsSection.svelte:10-11`
- Old: *"Happy owners, even happier dogs. Our Auckland dog walking
clients love what the Tiny Gang brings to their dog's routine — and
you can see why. Follow along on Instagram for daily adventures..."*
- New: *"Busy parents get peace of mind. Dogs come home tired and
happy. See why 30+ Auckland families trust the Tiny Gang — follow
along on Instagram for daily adventures, wagging tails and the odd
zoomie."*
- Why: Leads with the two outcomes buyers actually care about (peace of
mind for them, exercise for the dog), keeps the brand voice + 30+
review proof point, then makes the Instagram nudge feel like a
follow-on rather than the lead.
- [x] **Surface "Reliability / on-time" earlier**
- File: `src/lib/content/homepage.ts:37` (hero subtitle)
- Old: *"Trusted, professional dog walking across Auckland Central..."*
- New: *"Trusted, on-time dog walking across Auckland Central..."*
- Why: Reliability/punctuality is the #1 anxiety for busy parents
booking a service that visits their home. Pulling "on-time" into the
hero subtitle (one-word swap, no length cost) puts the reassurance
above the fold.
## Deliberately not actioning
- **Pack Walks H1 rewrite to "Small-dog pack walks designed for calm,
confident groups."** *"Join our Tiny Gang!"* is doing brand work — it's
memorable and reinforces a phrase used everywhere else. Rewriting kills
the most distinctive asset for marginal headline clarity.
- **Booking submit button: "Send" → "Book my Meet & Greet".** The form
also handles general enquiries, so a "book my…" label would feel wrong
on a complaint email. Better fix would be to switch the label by
`enquiryType` — keep "Send my booking" / "Send my enquiry" contextually.
+2 -1
View File
@@ -1,4 +1,4 @@
APP_VERSION=4.0.2
APP_VERSION=4.2.3
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -13,6 +13,7 @@ CLIENT_BCC=mattcohen0@gmail.com
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
REPLY_TO=info@goodwalk.co.nz
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200
+27 -1
View File
@@ -132,10 +132,13 @@ function Export-HomepageContent {
throw "Homepage content export script not found: $scriptPath"
}
$resolverPath = Join-Path $ProjectPath 'scripts\sveltekit-resolver.mjs'
Push-Location $ProjectPath
try {
Invoke-External -FilePath 'node' -Arguments @(
'--experimental-strip-types',
"--import=$(([uri]::new([System.IO.Path]::GetFullPath($resolverPath))).AbsoluteUri)",
$scriptPath,
$OutputPath
)
@@ -145,6 +148,24 @@ function Export-HomepageContent {
}
}
function New-UnixScriptCopy {
param(
[string]$SourcePath
)
if (-not (Test-Path -LiteralPath $SourcePath)) {
throw "Script not found: $SourcePath"
}
$tempFileName = 'goodwalk-deploy-helper-{0}.sh' -f ([System.Guid]::NewGuid().ToString('N'))
$tempPath = Join-Path ([System.IO.Path]::GetTempPath()) $tempFileName
$content = [System.IO.File]::ReadAllText($SourcePath)
$normalized = $content.Replace("`r`n", "`n").Replace("`r", "`n")
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($tempPath, $normalized, $utf8NoBom)
return $tempPath
}
function Invoke-SiteCheck {
param([string]$Url)
@@ -242,6 +263,7 @@ if (-not $Force) {
}
$archivePath = $null
$uploadHelperPath = $null
try {
Write-Host ''
@@ -255,7 +277,8 @@ try {
Write-Host ''
Write-Host '[deploy] Uploading remote helper'
Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($LocalRemoteHelperPath, $scpHelperTarget))
$uploadHelperPath = New-UnixScriptCopy -SourcePath $LocalRemoteHelperPath
Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($uploadHelperPath, $scpHelperTarget))
Write-Host ''
Write-Host '[deploy] Uploading application archive'
@@ -310,4 +333,7 @@ finally {
if ($archivePath -and (Test-Path -LiteralPath $archivePath)) {
Remove-Item -LiteralPath $archivePath -Force
}
if ($uploadHelperPath -and (Test-Path -LiteralPath $uploadHelperPath)) {
Remove-Item -LiteralPath $uploadHelperPath -Force
}
}
+6 -5
View File
@@ -3,14 +3,15 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
container_name: goodwalk_svelte_app
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: 3000
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -25,15 +26,15 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
container_name: goodwalk_svelte_mail_api
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
CLIENT_BCC: ${CLIENT_BCC:-}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <info@goodwalk.co.nz>}
REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
FORM_MIN_SECONDS: ${FORM_MIN_SECONDS:-4}
+5 -4
View File
@@ -3,13 +3,14 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: ${APP_PORT:-3000}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -19,9 +20,9 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.3
FROM python:3.12-slim
ARG APP_VERSION
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
{
"mattcohen0@gmail.com": {
"fullName": "Matt Test",
"phone": "02124347477",
"dogName": "Geoffrey"
},
"natalie@desseinparke.com": {
"fullName":"Natalie Parke",
"phone": "021616200",
"dogName": "Ziggy"
}
}
+307
View File
@@ -4,3 +4,310 @@
2026-05-02 09:07:45 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL']
2026-05-02 11:16:43 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
2026-05-02 11:16:43 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL']
11/05/2026 18:00:06 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:00:06 New Zealand Standard Time CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL']
11/05/2026 18:01:59 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:01:59 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:01:59 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:02:43 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:02:43 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:02:43 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:03:33 New Zealand Standard Time INFO mail-api: [8d525af8] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Matt test onboardin services=['Pack Walks'] page='http://10.0.0.124:5173/'
11/05/2026 18:03:33 New Zealand Standard Time DEBUG mail-api: [8d525af8] full payload: {'fullName': 'Matt', 'email': 'mattcohen0@gmail.com', 'phone': '1212', 'website': '', 'formStartedAt': 1778479391809, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778479391809, 'firstInteractionAt': 1778479393568, 'sendClickedAt': 1778479409699, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Matt test onboardin', 'location': 'test', 'message': 'test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']}
11/05/2026 18:03:33 New Zealand Standard Time DEBUG urllib3.connectionpool: Starting new HTTPS connection (1): api.resend.com:443
11/05/2026 18:03:34 New Zealand Standard Time DEBUG urllib3.connectionpool: https://api.resend.com:443 "POST /emails HTTP/1.1" 401 75
11/05/2026 18:03:34 New Zealand Standard Time WARNING mail-api: [8d525af8] client_email send failed (attempt 1/3, 617ms): ResendError: API key is invalid (status=401)
Traceback (most recent call last):
File "C:\Users\mattc\gw-svelte\gw-svelte\mail-api\main.py", line 1298, in _send_email
result = await asyncio.to_thread(resend.Emails.send, payload)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\asyncio\threads.py", line 25, in to_thread
return await loop.run_in_executor(None, func_call)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 86, in run
result = ctx.run(self.task)
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 73, in run
return fn(*args, **kwargs)
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\emails\_emails.py", line 286, in send
).perform_with_content()
~~~~~~~~~~~~~~~~~~~~^^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 49, in perform_with_content
resp = self.perform()
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 37, in perform
raise_for_code_and_type(
~~~~~~~~~~~~~~~~~~~~~~~^
code=data.get("statusCode") or 500,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
headers=self._response_headers,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\exceptions.py", line 270, in raise_for_code_and_type
raise ResendError(
...<5 lines>...
)
resend.exceptions.ResendError: API key is invalid
11/05/2026 18:03:34 New Zealand Standard Time INFO mail-api: [8d525af8] client_email: non-retryable status 401, aborting retries
11/05/2026 18:03:34 New Zealand Standard Time DEBUG urllib3.connectionpool: Starting new HTTPS connection (1): api.resend.com:443
11/05/2026 18:03:34 New Zealand Standard Time DEBUG urllib3.connectionpool: https://api.resend.com:443 "POST /emails HTTP/1.1" 401 75
11/05/2026 18:03:34 New Zealand Standard Time WARNING mail-api: [8d525af8] owner_email send failed (attempt 1/3, 490ms): ResendError: API key is invalid (status=401)
Traceback (most recent call last):
File "C:\Users\mattc\gw-svelte\gw-svelte\mail-api\main.py", line 1298, in _send_email
result = await asyncio.to_thread(resend.Emails.send, payload)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\asyncio\threads.py", line 25, in to_thread
return await loop.run_in_executor(None, func_call)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 86, in run
result = ctx.run(self.task)
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 73, in run
return fn(*args, **kwargs)
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\emails\_emails.py", line 286, in send
).perform_with_content()
~~~~~~~~~~~~~~~~~~~~^^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 49, in perform_with_content
resp = self.perform()
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 37, in perform
raise_for_code_and_type(
~~~~~~~~~~~~~~~~~~~~~~~^
code=data.get("statusCode") or 500,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
headers=self._response_headers,
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\exceptions.py", line 270, in raise_for_code_and_type
raise ResendError(
...<5 lines>...
)
resend.exceptions.ResendError: API key is invalid
11/05/2026 18:03:34 New Zealand Standard Time INFO mail-api: [8d525af8] owner_email: non-retryable status 401, aborting retries
11/05/2026 18:03:34 New Zealand Standard Time ERROR mail-api: [8d525af8] both emails failed after retries: [{'label': 'client_email', 'error_type': 'ResendError', 'error': 'API key is invalid', 'status': 401}, {'label': 'owner_email', 'error_type': 'ResendError', 'error': 'API key is invalid', 'status': 401}]
11/05/2026 18:03:34 New Zealand Standard Time INFO mail-api: [8d525af8] POST /submit → 502 (1155ms)
11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:04:16 New Zealand Standard Time INFO mail-api: [445c9a1c] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Matt test onboardin services=['Pack Walks'] page='http://10.0.0.124:5173/'
11/05/2026 18:04:16 New Zealand Standard Time DEBUG mail-api: [445c9a1c] full payload: {'fullName': 'Matt', 'email': 'mattcohen0@gmail.com', 'phone': '1212', 'website': '', 'formStartedAt': 1778479391809, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778479391809, 'firstInteractionAt': 1778479393568, 'sendClickedAt': 1778479452270, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Matt test onboardin', 'location': 'test', 'message': 'test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']}
11/05/2026 18:04:16 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Matt! 🐾'
11/05/2026 18:04:16 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Matt (Matt test onboardin)'
11/05/2026 18:04:16 New Zealand Standard Time INFO mail-api: [445c9a1c] POST /submit → 200 (15ms)
11/05/2026 18:04:48 New Zealand Standard Time INFO mail-api: [44114758] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:04:48 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 756139
11/05/2026 18:04:48 New Zealand Standard Time INFO mail-api: [44114758] POST /auth/request-code → 200 (2ms)
11/05/2026 18:04:55 New Zealand Standard Time INFO mail-api: [e48ac08b] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:04:55 New Zealand Standard Time INFO mail-api: [e48ac08b] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:05:42 New Zealand Standard Time INFO mail-api: [336b95ea] GET /auth/verify → 200 (1ms)
11/05/2026 18:05:44 New Zealand Standard Time INFO mail-api: [d068c1f8] GET /auth/verify → 200 (0ms)
11/05/2026 18:05:47 New Zealand Standard Time INFO mail-api: [44c5d28e] GET /auth/verify → 200 (0ms)
11/05/2026 18:05:52 New Zealand Standard Time INFO mail-api: [c4187d3a] GET /auth/verify → 200 (0ms)
11/05/2026 18:05:59 New Zealand Standard Time INFO mail-api: [1a029963] GET /auth/verify → 200 (0ms)
11/05/2026 18:06:53 New Zealand Standard Time INFO mail-api: [7da26969] GET /auth/verify → 200 (0ms)
11/05/2026 18:06:56 New Zealand Standard Time INFO mail-api: [694d3abf] POST /auth/logout → 200 (1ms)
11/05/2026 18:07:00 New Zealand Standard Time INFO mail-api: [6a7da236] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:07:00 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 193883
11/05/2026 18:07:00 New Zealand Standard Time INFO mail-api: [6a7da236] POST /auth/request-code → 200 (1ms)
11/05/2026 18:07:08 New Zealand Standard Time INFO mail-api: [c4ec2eac] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:07:08 New Zealand Standard Time INFO mail-api: [c4ec2eac] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:07:56 New Zealand Standard Time INFO mail-api: [86d8e5c9] GET /auth/verify → 200 (0ms)
11/05/2026 18:07:58 New Zealand Standard Time INFO mail-api: [d6eb0fef] GET /auth/verify → 200 (0ms)
11/05/2026 18:08:03 New Zealand Standard Time INFO mail-api: [0e022c79] GET /auth/verify → 200 (0ms)
11/05/2026 18:08:16 New Zealand Standard Time INFO mail-api: [65e5d5be] GET /auth/verify → 200 (0ms)
11/05/2026 18:08:18 New Zealand Standard Time INFO mail-api: [d70ef7e4] POST /auth/logout → 200 (0ms)
11/05/2026 18:08:22 New Zealand Standard Time INFO mail-api: [0bbd2c06] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:08:22 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 951035
11/05/2026 18:08:22 New Zealand Standard Time INFO mail-api: [0bbd2c06] POST /auth/request-code → 200 (1ms)
11/05/2026 18:08:31 New Zealand Standard Time INFO mail-api: [61d9c06a] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:08:31 New Zealand Standard Time INFO mail-api: [61d9c06a] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:09:27 New Zealand Standard Time INFO mail-api: [a30c0cef] GET /auth/verify → 200 (0ms)
11/05/2026 18:09:43 New Zealand Standard Time INFO mail-api: [05bdfd29] POST /auth/logout → 200 (0ms)
11/05/2026 18:09:48 New Zealand Standard Time INFO mail-api: [16862886] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:09:48 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 975093
11/05/2026 18:09:48 New Zealand Standard Time INFO mail-api: [16862886] POST /auth/request-code → 200 (1ms)
11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:10:16 New Zealand Standard Time INFO mail-api: [56955ef5] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:10:16 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 664020
11/05/2026 18:10:16 New Zealand Standard Time INFO mail-api: [56955ef5] POST /auth/request-code → 200 (3ms)
11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:10:23 New Zealand Standard Time INFO mail-api: [cac84255] POST /auth/verify-code → 400 (2ms)
11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:11:25 New Zealand Standard Time INFO mail-api: [6bec1b20] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:11:25 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 442224
11/05/2026 18:11:25 New Zealand Standard Time INFO mail-api: [6bec1b20] POST /auth/request-code → 200 (2ms)
11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:11:44 New Zealand Standard Time INFO mail-api: [f9c95e4d] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:11:44 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 326405
11/05/2026 18:11:44 New Zealand Standard Time INFO mail-api: [f9c95e4d] POST /auth/request-code → 200 (2ms)
11/05/2026 18:11:52 New Zealand Standard Time INFO mail-api: [a0e2cf00] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:11:52 New Zealand Standard Time INFO mail-api: [a0e2cf00] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:11:52 New Zealand Standard Time INFO mail-api: [b631b0f2] GET /auth/verify → 200 (1ms)
11/05/2026 18:13:07 New Zealand Standard Time INFO mail-api: [240a8117] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Doug services=['Pack Walks'] page='http://10.0.0.124:5173/'
11/05/2026 18:13:07 New Zealand Standard Time DEBUG mail-api: [240a8117] full payload: {'fullName': 'Tobias Cohen', 'email': 'mattcohen0@gmail.com', 'phone': '021548278', 'website': '', 'formStartedAt': 1778479962856, 'visitStartedAt': 1778479962856, 'pageEnteredAt': 1778479962856, 'firstInteractionAt': 1778479965665, 'sendClickedAt': 1778479983701, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Doug', 'location': 'Herne bay', 'message': '', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/']}
11/05/2026 18:13:07 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Tobias! 🐾'
11/05/2026 18:13:07 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Tobias Cohen (Doug)'
11/05/2026 18:13:07 New Zealand Standard Time INFO mail-api: [240a8117] POST /submit → 200 (9ms)
11/05/2026 18:13:25 New Zealand Standard Time INFO mail-api: [d95e1762] POST /auth/logout → 200 (1ms)
11/05/2026 18:13:26 New Zealand Standard Time INFO mail-api: [38dd6d5c] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:13:26 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 156979
11/05/2026 18:13:26 New Zealand Standard Time INFO mail-api: [38dd6d5c] POST /auth/request-code → 200 (1ms)
11/05/2026 18:13:33 New Zealand Standard Time INFO mail-api: [c6266ea4] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:13:33 New Zealand Standard Time INFO mail-api: [c6266ea4] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:13:33 New Zealand Standard Time INFO mail-api: [848a669f] GET /auth/verify → 200 (0ms)
11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:20:27 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:20:27 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:20:27 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:20:28 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:25:51 New Zealand Standard Time INFO mail-api: [8a6a1510] GET /auth/verify → 401 (2ms)
11/05/2026 18:26:51 New Zealand Standard Time INFO mail-api: [12f8d959] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Geoffrey services=['Pack Walks'] page='http://10.0.0.124:5173/'
11/05/2026 18:26:51 New Zealand Standard Time DEBUG mail-api: [12f8d959] full payload: {'fullName': 'Matt Test', 'email': 'mattcohen0@gmail.com', 'phone': '02124347477', 'website': '', 'formStartedAt': 1778480790866, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778480790866, 'firstInteractionAt': 1778480793847, 'sendClickedAt': 1778480808084, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Geoffrey', 'location': 'Matty', 'message': 'Test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']}
11/05/2026 18:26:51 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Matt! 🐾'
11/05/2026 18:26:51 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Matt Test (Geoffrey)'
11/05/2026 18:26:51 New Zealand Standard Time INFO mail-api: [12f8d959] POST /submit → 200 (18ms)
11/05/2026 18:27:13 New Zealand Standard Time INFO mail-api: [dba46f8b] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Geoffrey services=['Pack Walks'] page='http://10.0.0.124:5173/'
11/05/2026 18:27:13 New Zealand Standard Time DEBUG mail-api: [dba46f8b] full payload: {'fullName': 'Matt Test', 'email': 'mattcohen0@gmail.com', 'phone': '02124347477', 'website': '', 'formStartedAt': 1778480790866, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778480790866, 'firstInteractionAt': 1778480793847, 'sendClickedAt': 1778480830112, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Geoffrey', 'location': 'Matty', 'message': 'Test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']}
11/05/2026 18:27:13 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Matt! 🐾'
11/05/2026 18:27:13 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Matt Test (Geoffrey)'
11/05/2026 18:27:13 New Zealand Standard Time INFO mail-api: [dba46f8b] POST /submit → 200 (2ms)
11/05/2026 18:28:06 New Zealand Standard Time INFO mail-api: [4edf00fe] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:28:06 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 416532
11/05/2026 18:28:06 New Zealand Standard Time INFO mail-api: [4edf00fe] POST /auth/request-code → 200 (2ms)
11/05/2026 18:28:13 New Zealand Standard Time INFO mail-api: [ec212e61] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:28:13 New Zealand Standard Time INFO mail-api: [ec212e61] POST /auth/verify-code → 200 (2ms)
11/05/2026 18:28:13 New Zealand Standard Time INFO mail-api: [61f0f5db] GET /auth/verify → 200 (0ms)
11/05/2026 18:29:03 New Zealand Standard Time INFO mail-api: [1a4c2779] GET /auth/verify → 200 (0ms)
11/05/2026 18:29:12 New Zealand Standard Time INFO mail-api: [401a9596] GET /auth/verify → 200 (0ms)
11/05/2026 18:29:16 New Zealand Standard Time INFO mail-api: [d162211c] GET /auth/verify → 200 (0ms)
11/05/2026 18:29:33 New Zealand Standard Time INFO mail-api: [c454ccd0] GET /auth/verify → 200 (0ms)
11/05/2026 18:29:39 New Zealand Standard Time INFO mail-api: [1e7a7145] GET /auth/verify → 200 (0ms)
11/05/2026 18:29:43 New Zealand Standard Time INFO mail-api: [02fcf859] GET /auth/verify → 200 (0ms)
11/05/2026 18:30:19 New Zealand Standard Time INFO mail-api: [a66a3485] GET /auth/verify → 200 (0ms)
11/05/2026 18:30:25 New Zealand Standard Time INFO mail-api: [7ae73440] GET /auth/verify → 200 (0ms)
11/05/2026 18:30:28 New Zealand Standard Time INFO mail-api: [9e46aa4c] GET /auth/verify → 200 (0ms)
11/05/2026 18:30:47 New Zealand Standard Time INFO mail-api: [7e3b4735] GET /auth/verify → 200 (0ms)
11/05/2026 18:30:52 New Zealand Standard Time INFO mail-api: [5ff0bc97] GET /auth/verify → 200 (1ms)
11/05/2026 18:31:00 New Zealand Standard Time INFO mail-api: [aa2f5411] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:34 New Zealand Standard Time INFO mail-api: [8916510c] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:38 New Zealand Standard Time INFO mail-api: [86b218b7] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:38 New Zealand Standard Time INFO mail-api: [ee963004] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:41 New Zealand Standard Time INFO mail-api: [407e5303] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:45 New Zealand Standard Time INFO mail-api: [c1dcda74] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:47 New Zealand Standard Time INFO mail-api: [a6fcbd3d] GET /auth/verify → 200 (1ms)
11/05/2026 18:32:50 New Zealand Standard Time INFO mail-api: [21cce6ef] GET /auth/verify → 200 (0ms)
11/05/2026 18:32:59 New Zealand Standard Time INFO mail-api: [b7859bff] GET /auth/verify → 200 (0ms)
11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 18:34:58 New Zealand Standard Time INFO mail-api: [2d611be5] GET /auth/verify → 401 (1ms)
11/05/2026 18:35:51 New Zealand Standard Time INFO mail-api: [e7ab563c] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:35:51 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 530175
11/05/2026 18:35:51 New Zealand Standard Time INFO mail-api: [e7ab563c] POST /auth/request-code → 200 (2ms)
11/05/2026 18:35:59 New Zealand Standard Time INFO mail-api: [28ed2c2e] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:35:59 New Zealand Standard Time INFO mail-api: [28ed2c2e] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:35:59 New Zealand Standard Time INFO mail-api: [b5fe5a79] GET /auth/verify → 200 (0ms)
11/05/2026 18:36:41 New Zealand Standard Time INFO mail-api: [368ab712] GET /auth/verify → 200 (0ms)
11/05/2026 18:36:50 New Zealand Standard Time INFO mail-api: [3bb325b5] GET /auth/verify → 200 (0ms)
11/05/2026 18:36:54 New Zealand Standard Time INFO mail-api: [412c0290] GET /auth/verify → 200 (0ms)
11/05/2026 18:37:27 New Zealand Standard Time INFO mail-api: [de1e1806] GET /auth/verify → 200 (0ms)
11/05/2026 18:37:32 New Zealand Standard Time INFO mail-api: [a139f936] GET /auth/verify → 200 (0ms)
11/05/2026 18:37:40 New Zealand Standard Time INFO mail-api: [5b2635d9] GET /auth/verify → 200 (0ms)
11/05/2026 18:37:45 New Zealand Standard Time INFO mail-api: [b621f259] GET /auth/verify → 200 (0ms)
11/05/2026 18:37:58 New Zealand Standard Time INFO mail-api: [58357018] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:00 New Zealand Standard Time INFO mail-api: [df724db7] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:01 New Zealand Standard Time INFO mail-api: [8bad66cb] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:02 New Zealand Standard Time INFO mail-api: [aac47018] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:03 New Zealand Standard Time INFO mail-api: [34ebe14e] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:05 New Zealand Standard Time INFO mail-api: [e9248145] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:05 New Zealand Standard Time INFO mail-api: [c00e4c1f] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:07 New Zealand Standard Time INFO mail-api: [bdd58356] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:50 New Zealand Standard Time INFO mail-api: [16f6b8ba] GET /auth/verify → 200 (0ms)
11/05/2026 18:38:51 New Zealand Standard Time INFO mail-api: [a46f7171] POST /auth/logout → 200 (1ms)
11/05/2026 18:41:41 New Zealand Standard Time INFO mail-api: [24fca012] auth: unknown email=mattco0en@gmail.com ip=127.0.0.1
11/05/2026 18:41:41 New Zealand Standard Time WARNING mail-api: [24fca012] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=1
11/05/2026 18:41:41 New Zealand Standard Time INFO mail-api: [24fca012] POST /auth/request-code → 403 (1ms)
11/05/2026 18:41:51 New Zealand Standard Time INFO mail-api: [4b292ea6] auth: code issued for email=mattcohen0@gmail.com
11/05/2026 18:41:51 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 759209
11/05/2026 18:41:51 New Zealand Standard Time INFO mail-api: [4b292ea6] POST /auth/request-code → 200 (1ms)
11/05/2026 18:42:03 New Zealand Standard Time INFO mail-api: [b2213391] auth: session created for email=mattcohen0@gmail.com
11/05/2026 18:42:03 New Zealand Standard Time INFO mail-api: [b2213391] POST /auth/verify-code → 200 (1ms)
11/05/2026 18:42:03 New Zealand Standard Time INFO mail-api: [5d05ac03] GET /auth/verify → 200 (0ms)
11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s)
11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
11/05/2026 19:05:47 New Zealand Standard Time INFO mail-api: [0c1cdd9c] GET /auth/verify → 401 (2ms)
+1175 -20
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -14,6 +14,20 @@ server {
}
}
server {
listen 80;
server_name onboarding.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
location / {
return 301 https://onboarding.goodwalk.co.nz$request_uri;
}
}
server {
listen 443 ssl;
server_name goodwalk.co.nz;
@@ -116,3 +130,78 @@ server {
proxy_set_header Connection "upgrade";
}
}
server {
listen 443 ssl;
server_name onboarding.goodwalk.co.nz;
ssl_certificate /etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
resolver 127.0.0.11 ipv6=off valid=30s;
location ~* /\.(git|env|htaccess) {
deny all;
}
location /api/onboarding-submit {
if (-f /etc/nginx/conf.d/maintenance.flag) {
return 503;
}
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
limit_req zone=goodwalk_limit burst=10 nodelay;
proxy_pass http://$goodwalk_mail_api/onboarding-submit;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/auth/ {
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
limit_req zone=goodwalk_limit burst=10 nodelay;
rewrite ^/api/auth/(.*)$ /auth/$1 break;
proxy_pass http://$goodwalk_mail_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
if (-f /etc/nginx/conf.d/maintenance.flag) {
return 503;
}
set $goodwalk_frontend goodwalk_svelte_app:3000;
proxy_pass http://$goodwalk_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
+51
View File
@@ -12,6 +12,19 @@ server {
}
}
server {
listen 80;
server_name onboarding.goodwalk.co.nz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name goodwalk.co.nz www.goodwalk.co.nz;
@@ -49,3 +62,41 @@ server {
proxy_set_header Connection "upgrade";
}
}
server {
listen 443 ssl;
server_name onboarding.goodwalk.co.nz;
ssl_certificate /etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
location /api/onboarding-submit {
proxy_pass http://mail-api:8000/onboarding-submit;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
+753 -45
View File
@@ -1,20 +1,30 @@
{
"name": "goodwalk-svelte-port",
"version": "4.0.2",
"version": "4.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodwalk-svelte-port",
"version": "4.0.2",
"version": "4.2.3",
"dependencies": {
"canvas-confetti": "^1.9.4",
"pg": "^8.13.1"
},
"devDependencies": {
"@fontsource/fredoka": "^5.2.10",
"@fontsource/noto-sans": "^5.2.10",
"@fontsource/plus-jakarta-sans": "^5.2.8",
"@fontsource/poppins": "^5.2.7",
"@fontsource/readex-pro": "^5.2.11",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-sans-3": "^5.2.9",
"@fontsource/unbounded": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.2.0",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.59.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/canvas-confetti": "^1.9.0",
@@ -274,6 +284,17 @@
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -734,6 +755,586 @@
}
}
},
"node_modules/@fontsource/fredoka": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource/fredoka/-/fredoka-5.2.10.tgz",
"integrity": "sha512-DyzPCmTf0PkBbAu+gNvnPzfD/dYIXlyp+Zcb76jEQyt/vmRKGZgkH8FFF9W2sICDaA5p+GLk1XGFjWh5PPX+lg==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/noto-sans": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.2.10.tgz",
"integrity": "sha512-J58RVfS/C0Z2VBF+PoU260Tx8cdRGYuS+e3yQe4hYaIYDl0sEVn5CzlLo5zVRvQD0HaIUTV8AZMfqR7rtdEpqQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/plus-jakarta-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
"integrity": "sha512-P5qE49fqdeD+7DXH1KBxmMPlB17LTz1zvBhFH0tFzfnYTKVJVyb0pR6plh0ZGXxcB+Oayb54FZZw3V42/DawTw==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/poppins": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz",
"integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/readex-pro": {
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/@fontsource/readex-pro/-/readex-pro-5.2.11.tgz",
"integrity": "sha512-MOPXkNp5mJsU05j9/vpiarDtFzW+WDgbTHwia2CS/SokEmDQ94WUFSP0ryhv/f90RsgU4LktnFKiDz7E3EUfoQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/roboto": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz",
"integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/source-sans-3": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource/source-sans-3/-/source-sans-3-5.2.9.tgz",
"integrity": "sha512-u3ymIq4rfmCCyB9MEw/sFR5lPVJ1yTNXmIMbUz+9kVCFIHvNtfzXOEBuvkg3Tk0zhmioPeJ28ZK5smZ7TurezQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/unbounded": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/unbounded/-/unbounded-5.2.8.tgz",
"integrity": "sha512-tBHtKB+s5tKvQXEWy5h86sDY5hzVLsEbW6TGphSjUY9b/gawUPvXv5vFS4ZdPbGDcvSqMMy6eXkqdcsKI29dig==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz",
"integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==",
"dev": true,
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1270,6 +1871,25 @@
"@sveltejs/kit": "^2.4.0"
}
},
"node_modules/@sveltejs/enhanced-img": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.10.4.tgz",
"integrity": "sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.5",
"sharp": "^0.34.1",
"svelte-parse-markup": "^0.1.5",
"vite-imagetools": "^9.0.3",
"zimmerframe": "^1.1.2"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0",
"svelte": "^5.0.0",
"vite": "^6.3.0 || >=7.0.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.59.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.0.tgz",
@@ -1313,43 +1933,42 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
"magic-string": "^0.30.17",
"vitefu": "^1.0.6"
"magic-string": "^0.30.21",
"obug": "^2.1.0",
"vitefu": "^1.1.1"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
"svelte": "^5.0.0",
"vite": "^6.0.0"
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.7"
"obug": "^2.1.0"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@testing-library/dom": {
@@ -1835,24 +2454,6 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -1880,6 +2481,16 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/devalue": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
@@ -2077,6 +2688,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/imagetools-core": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/imagetools-core/-/imagetools-core-9.1.0.tgz",
"integrity": "sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -2259,13 +2880,6 @@
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2663,6 +3277,19 @@
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@@ -2670,6 +3297,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -2803,6 +3475,19 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-parse-markup": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.5.tgz",
"integrity": "sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://bjornlu.com/sponsor"
},
"peerDependencies": {
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
"node_modules/svelte/node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -2920,6 +3605,14 @@
"node": ">=20"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3026,6 +3719,21 @@
}
}
},
"node_modules/vite-imagetools": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/vite-imagetools/-/vite-imagetools-9.0.3.tgz",
"integrity": "sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.5",
"imagetools-core": "^9.1.0",
"sharp": "^0.34.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+12 -2
View File
@@ -1,6 +1,6 @@
{
"name": "goodwalk-svelte-port",
"version": "4.2.0",
"version": "4.2.3",
"private": true,
"type": "module",
"scripts": {
@@ -16,9 +16,19 @@
"pg": "^8.13.1"
},
"devDependencies": {
"@fontsource/fredoka": "^5.2.10",
"@fontsource/noto-sans": "^5.2.10",
"@fontsource/plus-jakarta-sans": "^5.2.8",
"@fontsource/poppins": "^5.2.7",
"@fontsource/readex-pro": "^5.2.11",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-sans-3": "^5.2.9",
"@fontsource/unbounded": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.2.0",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.59.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/canvas-confetti": "^1.9.0",
+45
View File
@@ -0,0 +1,45 @@
import sharp from 'sharp';
import { writeFile, unlink, stat } from 'node:fs/promises';
import { join } from 'node:path';
// Opaque-photo PNGs to convert to JPG (verified via metadata: no alpha)
const targets = [
'archie-auckland-dog-walking-review',
'monty-auckland-dog-walking-review',
'otis-auckland-dog-walking-review',
'wallace-auckland-dog-walking-review',
'one-on-one-dog-portrait-1',
'one-on-one-dog-portrait-2',
'one-on-one-dog-portrait-3',
'small-medium-dogs-pack-walk',
'auckland-pack-walk-small-dogs-group',
'founder-image-aless-goodwalk'
];
const dirs = ['src/lib/images', 'static/images'];
const MAX_WIDTH = 1600;
let totalOrig = 0;
let totalNew = 0;
for (const dir of dirs) {
for (const name of targets) {
const png = join(dir, name + '.png');
const jpg = join(dir, name + '.jpg');
try {
const orig = (await stat(png)).size;
const buf = await sharp(png)
.rotate()
.resize({ width: MAX_WIDTH, withoutEnlargement: true })
.jpeg({ quality: 82, mozjpeg: true, progressive: true })
.toBuffer();
await writeFile(jpg, buf);
await unlink(png);
totalOrig += orig;
totalNew += buf.length;
console.log(`${name.padEnd(45)} ${(orig/1024).toFixed(0).padStart(5)}KB png → ${(buf.length/1024).toFixed(0).padStart(4)}KB jpg`);
} catch (err) {
console.error('FAILED', png, err.message);
}
}
}
console.log(`\nTotal: ${(totalOrig/1024/1024).toFixed(2)} MB → ${(totalNew/1024/1024).toFixed(2)} MB`);
+16
View File
@@ -429,6 +429,22 @@ if (( nginx_args_present )); then
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
# Pre-flight: SSL certificate for onboarding.goodwalk.co.nz must exist before
# nginx can reload — the config references this cert path directly.
ONBOARDING_CERT="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem"
ONBOARDING_KEY="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem"
if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then
fail "SSL certificate for onboarding.goodwalk.co.nz is not present on this server.
Expected: $ONBOARDING_CERT
One-time setup on the droplet:
1. Ensure the DNS A record for onboarding.goodwalk.co.nz points to this server's IP
2. Bring nginx up so the ACME webroot is reachable, then obtain the certificate:
certbot certonly --webroot -w /var/www/certbot \\
-d onboarding.goodwalk.co.nz \\
--non-interactive --agree-tos -m info@goodwalk.co.nz
3. Re-run this deploy script"
fi
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
+16
View File
@@ -287,6 +287,22 @@ if (( nginx_args_present )); then
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
# Pre-flight: SSL certificate for onboarding.goodwalk.co.nz must exist before
# nginx can reload — the config references this cert path directly.
ONBOARDING_CERT="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem"
ONBOARDING_KEY="/etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem"
if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then
fail "SSL certificate for onboarding.goodwalk.co.nz is not present on this server.
Expected: $ONBOARDING_CERT
One-time setup on the droplet:
1. Ensure the DNS A record for onboarding.goodwalk.co.nz points to this server's IP
2. Bring nginx up so the ACME webroot is reachable, then obtain the certificate:
certbot certonly --webroot -w /var/www/certbot \\
-d onboarding.goodwalk.co.nz \\
--non-interactive --agree-tos -m info@goodwalk.co.nz
3. Re-run this deploy script"
fi
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
+72
View File
@@ -0,0 +1,72 @@
import sharp from 'sharp';
import { readdir, stat, rename } from 'node:fs/promises';
import { join, extname, basename } from 'node:path';
const MAX_WIDTH = 1600;
const MIN_BYTES_TO_OPTIMISE = 250 * 1024;
const dirs = ['src/lib/images', 'static/images'];
async function optimiseFile(file) {
const ext = extname(file).toLowerCase();
const input = await sharp(file, { failOn: 'none' }).rotate();
const meta = await input.metadata();
const width = meta.width ?? 0;
const targetWidth = width > MAX_WIDTH ? MAX_WIDTH : width;
const pipeline = sharp(file, { failOn: 'none' })
.rotate()
.resize({ width: targetWidth, withoutEnlargement: true });
let buf;
if (ext === '.png') {
if (meta.hasAlpha) {
buf = await pipeline
.png({ palette: true, quality: 88, compressionLevel: 9, effort: 10 })
.toBuffer();
} else {
buf = await pipeline
.png({ palette: true, quality: 82, compressionLevel: 9, effort: 10 })
.toBuffer();
}
} else if (ext === '.jpg' || ext === '.jpeg') {
buf = await pipeline.jpeg({ quality: 82, mozjpeg: true }).toBuffer();
} else if (ext === '.webp') {
buf = await pipeline.webp({ quality: 80, effort: 6 }).toBuffer();
} else {
return null;
}
const original = (await stat(file)).size;
if (buf.length >= original) return { file, original, optimised: original, skipped: true };
const tmp = file + '.opt.tmp';
await sharp(buf).toFile(tmp);
await rename(tmp, file);
return { file, original, optimised: buf.length, skipped: false };
}
let totalOrig = 0;
let totalNew = 0;
for (const dir of dirs) {
const entries = await readdir(dir);
for (const name of entries) {
if (!/\.(png|jpe?g|webp)$/i.test(name)) continue;
const file = join(dir, name);
const s = await stat(file);
if (s.size < MIN_BYTES_TO_OPTIMISE) continue;
try {
const res = await optimiseFile(file);
if (!res) continue;
totalOrig += res.original;
totalNew += res.optimised;
const pct = ((1 - res.optimised / res.original) * 100).toFixed(0);
const flag = res.skipped ? ' (skipped: no gain)' : '';
console.log(
`${basename(file).padEnd(58)} ${(res.original / 1024).toFixed(0).padStart(5)}KB → ${(res.optimised / 1024).toFixed(0).padStart(5)}KB (-${pct}%)${flag}`
);
} catch (err) {
console.error('FAILED', file, err.message);
}
}
}
console.log(`\nTotal: ${(totalOrig / 1024 / 1024).toFixed(2)} MB → ${(totalNew / 1024 / 1024).toFixed(2)} MB`);
+24
View File
@@ -0,0 +1,24 @@
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { existsSync } from 'node:fs';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const libRoot = path.join(projectRoot, 'src', 'lib');
export function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('$lib/')) {
const relative = specifier.slice('$lib/'.length);
const candidates = [
path.join(libRoot, relative + '.ts'),
path.join(libRoot, relative + '.js'),
path.join(libRoot, relative, 'index.ts'),
path.join(libRoot, relative, 'index.js'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return nextResolve(pathToFileURL(candidate).href, context);
}
}
}
return nextResolve(specifier, context);
}
+9
View File
@@ -0,0 +1,9 @@
import { register } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
import path from 'node:path';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
register(
pathToFileURL(path.join(scriptDir, 'sveltekit-hooks.mjs')).href,
pathToFileURL(scriptDir + '/')
);
+67 -50
View File
@@ -7,54 +7,75 @@
<link rel="icon" href="/images/goodwalk-favicon-32.png" sizes="32x32" type="image/png" />
<link rel="icon" href="/images/goodwalk-favicon-192.png" sizes="192x192" type="image/png" />
<link rel="apple-touch-icon" href="/images/goodwalk-favicon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
/>
<link
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
rel="stylesheet"
media="print"
onload="this.media='all'"
/>
<style>
.no-js-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 20, 24, 0.58);
}
.no-js-card {
width: min(100%, 560px);
padding: 32px 28px;
border-radius: 24px;
background: #fff;
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.2);
text-align: left;
font-family: 'Readex Pro', system-ui, sans-serif;
color: var(--gw-green);
}
.no-js-kicker {
display: inline-block;
margin-bottom: 14px;
padding: 6px 10px;
border-radius: 999px;
background: #f3ecd9;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gw-green);
}
.no-js-card h1 {
margin: 0 0 12px;
font-family: 'Unbounded', system-ui, sans-serif;
font-size: clamp(28px, 4vw, 38px);
line-height: 1.05;
letter-spacing: -0.04em;
color: var(--gw-green);
}
.no-js-card p {
margin: 0;
font-size: 16px;
line-height: 1.65;
color: #4c5056;
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
rel="stylesheet"
/>
<div class="no-js-overlay" role="alertdialog" aria-labelledby="no-js-title" aria-describedby="no-js-copy">
<div class="no-js-card">
<span class="no-js-kicker">JavaScript Required</span>
<h1 id="no-js-title">Please enable JavaScript to use this site.</h1>
<p id="no-js-copy">
Goodwalk relies on JavaScript for key parts of the site, including the booking and contact forms.
Please turn JavaScript back on in your browser settings and reload the page.
</p>
</div>
</div>
</noscript>
<link
rel="preconnect"
href="https://cdnjs.cloudflare.com"
crossorigin="anonymous"
/>
<link
rel="preload"
as="style"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
media="print"
onload="this.media='all'"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<noscript>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</noscript>
<!-- Google Analytics -->
<div style="display: contents">%sveltekit.body%</div>
<!-- Google Analytics — deferred to end of body so it never blocks the critical render -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-K7TLSFJVP1"></script>
<script>
window.dataLayer = window.dataLayer || [];
@@ -62,9 +83,5 @@
gtag('js', new Date());
gtag('config', 'G-K7TLSFJVP1');
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
export function accordion(node: HTMLElement) {
function handleToggle(event: Event) {
const target = event.target;
if (!(target instanceof HTMLDetailsElement) || !target.open || !node.contains(target)) {
return;
}
const details = node.querySelectorAll('details');
for (const item of details) {
if (item !== target) {
item.open = false;
}
}
}
node.addEventListener('toggle', handleToggle, true);
return {
destroy() {
node.removeEventListener('toggle', handleToggle, true);
}
};
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { reveal } from './reveal';
class TestIntersectionObserver {
static instances: TestIntersectionObserver[] = [];
callback: IntersectionObserverCallback;
disconnect = vi.fn();
observe = vi.fn();
unobserve = vi.fn();
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
TestIntersectionObserver.instances.push(this);
}
trigger(target: Element, isIntersecting: boolean) {
this.callback(
[{ isIntersecting, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
}
describe('reveal action', () => {
afterEach(() => {
TestIntersectionObserver.instances = [];
vi.unstubAllGlobals();
});
it('toggles visibility as the element enters and leaves the viewport', () => {
vi.stubGlobal('IntersectionObserver', TestIntersectionObserver);
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
} as MediaQueryList);
const node = document.createElement('div');
document.body.appendChild(node);
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue({
width: 100,
height: 100,
top: window.innerHeight + 100,
right: 100,
bottom: window.innerHeight + 200,
left: 0,
x: 0,
y: window.innerHeight + 100,
toJSON() {
return {};
}
} as DOMRect);
const action = reveal(node, { delay: 40, distance: 32 });
const observer = TestIntersectionObserver.instances[0];
expect(node.classList.contains('reveal-ready')).toBe(true);
expect(node.classList.contains('reveal-visible')).toBe(false);
expect(node.style.getPropertyValue('--reveal-delay')).toBe('40ms');
expect(node.style.getPropertyValue('--reveal-distance')).toBe('32px');
observer.trigger(node, true);
expect(node.classList.contains('reveal-visible')).toBe(true);
observer.trigger(node, false);
expect(node.classList.contains('reveal-visible')).toBe(false);
action.destroy();
expect(observer.disconnect).toHaveBeenCalledTimes(1);
});
});
+9 -4
View File
@@ -47,13 +47,18 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
if (entry.isIntersecting) {
node.classList.add('reveal-visible');
continue;
}
node.classList.add('reveal-visible');
observer.disconnect();
break;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const rect = entry.boundingClientRect;
const fullyOutOfView = rect.bottom <= 0 || rect.top >= viewportHeight;
if (fullyOutOfView) {
node.classList.remove('reveal-visible');
}
}
},
{
+328 -140
View File
@@ -1,68 +1,153 @@
<script lang="ts">
import { accordion } from '$lib/actions/accordion';
import { reveal } from '$lib/actions/reveal';
import ServicesSection from '$lib/components/ServicesSection.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
import CtaCard from '$lib/components/CtaCard.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { AboutPageContent } from '$lib/types';
export let content: SiteSharedContent;
export let pageContent: AboutPageContent;
$: standardSections = pageContent.sections.filter((s) => s.accent !== 'founder');
$: founderSection = pageContent.sections.find((s) => s.accent === 'founder') ?? null;
const founderHeadingLead = 'Meet Aless,';
const founderHeadingHighlight = 'the heart of Goodwalk';
</script>
<main class="about-page">
<section class="about-hero">
<div class="about-inner">
<h1>{pageContent.title}</h1>
</div>
</section>
{#each pageContent.sections as section}
<!-- ── Hero ── -->
<PageHeader
variant="green"
eyebrow="About Goodwalk"
title={pageContent.title}
subtitle="Small dog specialists serving Auckland Central. A team your dog knows by name."
>
<div class="ph-chips">
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="ph-chip ph-chip--link"
>
<span class="about-chip-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
<span class="ph-chip">Auckland Central</span>
<span class="ph-chip">Small dog specialists</span>
</div>
</PageHeader>
<!-- ── Standard sections (Who we are, Our impact) ── -->
{#each standardSections as section}
{@const enhanced = getEnhancedImage(section.imageUrl)}
<section
use:reveal
class:about-section-gradient={section.accent === 'gradient'}
class="about-section reveal-block"
class:about-section-gradient={section.accent === 'gradient'}
>
<div class:about-section-reverse={section.reverse} class="about-inner about-section-grid">
<div class="page-inner about-section-grid" class:about-section-reverse={section.reverse}>
<div class="about-copy">
{#if section.eyebrow}
<span class="about-eyebrow">{section.eyebrow}</span>
{/if}
<h2>{section.title}</h2>
{#each section.body as paragraph}
<p>{paragraph}</p>
{/each}
</div>
<div class="about-media">
<img
src={section.imageUrl}
alt={section.imageAlt}
width={getImageMetadata(section.imageUrl)?.width}
height={getImageMetadata(section.imageUrl)?.height}
loading="lazy"
decoding="async"
/>
{#if enhanced}
<enhanced:img src={enhanced} alt={section.imageAlt} loading="lazy" decoding="async" />
{:else}
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" decoding="async" />
{/if}
</div>
</div>
</section>
{/each}
<ServicesSection services={content.services} heading={pageContent.servicesTitle} />
<section use:reveal={{ delay: 70 }} class="about-contact reveal-block">
<div class="about-inner">
<div class="about-contact-card">
<h2>{pageContent.contact.title}</h2>
<div class="about-contact-grid">
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
{pageContent.contact.email}
</a>
<a class="btn btn-yellow" href={pageContent.contact.cta.href}>
{pageContent.contact.cta.label}
</a>
<a class="about-contact-link" href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}>
{pageContent.contact.phone}
</a>
<!-- ── Founder section ── -->
{#if founderSection}
{@const founderEnhanced = getEnhancedImage(founderSection.imageUrl)}
<section use:reveal={{ delay: 50 }} class="about-founder reveal-block">
<div class="page-inner about-founder-grid">
<div class="about-founder-media">
{#if founderEnhanced}
<enhanced:img
src={founderEnhanced}
alt={founderSection.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img
src={founderSection.imageUrl}
alt={founderSection.imageAlt}
loading="lazy"
decoding="async"
/>
{/if}
</div>
<div class="about-founder-copy">
{#if founderSection.eyebrow}
<span class="about-eyebrow">{founderSection.eyebrow}</span>
{/if}
<h2 class="about-founder-heading">
<span class="about-founder-heading-desktop">
<span class="about-founder-title-main">{founderHeadingLead}</span>
<br />
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
</span>
<span class="about-founder-heading-mobile">
<span class="about-founder-title-main">{founderHeadingLead}</span>
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
</span>
</h2>
{#each founderSection.body as paragraph}
<p>{paragraph}</p>
{/each}
<a href="/contact-us" class="btn btn-green btn-mobile-center">Book a free Meet &amp; Greet</a>
</div>
</div>
</section>
{/if}
<!-- ── FAQs ── -->
{#if pageContent.faqs && pageContent.faqs.length}
<section use:reveal={{ delay: 30 }} class="about-faq reveal-block">
<div class="page-inner">
<div class="about-faq-header">
<span class="about-eyebrow">FAQ</span>
<h2>{pageContent.faqTitle ?? 'Common questions'}</h2>
</div>
<div use:accordion class="faq about-faq-list">
{#each pageContent.faqs as item}
<details>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
{/each}
</div>
</div>
</section>
{/if}
<!-- ── Contact CTA ── -->
<section use:reveal={{ delay: 50 }} class="about-contact reveal-block">
<div class="page-inner">
<CtaCard
title={pageContent.contact.title}
description="Questions, pricing, or your first Meet &amp; Greet start here and we'll reply within 24 hours."
ctaHref={pageContent.contact.cta.href}
ctaLabel={pageContent.contact.cta.label}
email={pageContent.contact.email}
phone={pageContent.contact.phone}
showIcons={true}
/>
</div>
</section>
</main>
<style>
@@ -70,53 +155,43 @@
background: var(--off-white);
}
.about-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
/* ── Eyebrow ── */
.about-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.about-hero {
padding: 72px 0 40px;
}
.about-hero h1,
.about-copy h2,
.about-contact-card h2 {
margin: 0;
font-family: var(--font-head);
font-size: clamp(34px, 4vw, 56px);
line-height: 1.05;
letter-spacing: -0.04em;
color: #000;
}
.about-hero h1 {
text-align: center;
.about-chip-stars {
color: var(--yellow);
letter-spacing: 1px;
font-size: 13px;
}
/* ── Standard sections ── */
.about-section {
padding: 0 0 88px;
padding: 88px 0;
}
.about-section-gradient {
margin: 0 24px 88px;
padding: 40px 0;
border-radius: 36px;
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
}
.about-section-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 44px;
gap: 60px;
align-items: center;
}
.about-section-reverse {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.about-section-reverse .about-copy {
order: 2;
}
@@ -126,78 +201,178 @@
}
.about-copy h2 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #0d1a0d;
}
.about-copy p {
margin: 18px 0 0;
margin: 12px 0 0;
color: #34363a;
font-size: 17px;
line-height: 1.75;
}
.about-media {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 28px;
box-shadow: 0 16px 48px rgba(17, 20, 24, 0.1);
}
.about-media img {
display: block;
width: 100%;
max-width: 460px;
aspect-ratio: 4 / 3;
height: 100%;
object-fit: cover;
object-position: center top;
}
/* ── Founder section ── */
.about-founder {
padding: 88px 0;
background: #fff;
}
.about-founder-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 64px;
align-items: center;
}
.about-founder-media img {
display: block;
width: 100%;
max-width: 400px;
height: auto;
margin-left: auto;
margin-right: auto;
border-radius: 28px;
object-fit: cover;
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
box-shadow: 0 24px 56px rgba(17, 20, 24, 0.12);
margin: 0 auto;
}
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
transition:
opacity 0.55s ease,
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--reveal-delay, 0ms);
.about-founder-copy h2 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(30px, 3.5vw, 44px);
font-weight: 800;
line-height: 1.06;
letter-spacing: -0.03em;
text-wrap: balance;
color: #0d1a0d;
}
:global(.reveal-visible.reveal-block) {
opacity: 1;
transform: translate3d(0, 0, 0);
.about-founder-heading-desktop {
display: block;
}
.about-founder-heading-mobile {
display: none;
}
.about-founder-heading-mobile .about-founder-title-main,
.about-founder-heading-mobile .about-founder-title-highlight {
display: block;
}
.about-founder-title-main {
color: #0d1a0d;
}
.about-founder-title-highlight {
position: relative;
display: inline-block;
color: #0d1a0d;
}
.about-founder-title-highlight::after {
content: '';
position: absolute;
left: 0;
right: -6px;
bottom: -16px;
height: 24px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
center/contain no-repeat;
transform-origin: left center;
animation: about-founder-underline-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both;
}
@keyframes about-founder-underline-draw {
0% {
opacity: 0;
transform: scaleX(0.2) translateY(6px) rotate(-1.5deg);
}
65% {
opacity: 1;
transform: scaleX(1.04) translateY(0) rotate(0deg);
}
100% {
opacity: 1;
transform: scaleX(1) translateY(0) rotate(0deg);
}
}
.about-founder-copy p {
margin: 14px 0 0;
color: #34363a;
font-size: 17px;
line-height: 1.75;
}
.about-founder-copy .btn {
display: flex;
width: fit-content;
margin: 28px auto 0;
}
/* ── FAQs ── */
.about-faq {
padding: 80px 0;
background: var(--off-white);
}
.about-faq-header {
text-align: center;
margin-bottom: 40px;
}
.about-faq-header h2 {
margin: 0;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #0d1a0d;
}
.about-faq-list {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Contact CTA ── */
.about-contact {
padding: 0 0 88px;
}
.about-contact-card {
border-radius: 36px;
background: #fff;
padding: 42px 48px;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
text-align: center;
}
.about-contact-card h2 {
font-size: clamp(28px, 3vw, 42px);
}
.about-contact-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
align-items: center;
margin-top: 28px;
}
.about-contact-link {
color: #34363a;
font-size: 20px;
font-weight: 600;
text-decoration: none;
}
/* ── Tablet ── */
@media (max-width: 1024px) {
.about-section-grid,
.about-section-reverse {
.about-founder-grid {
grid-template-columns: 1fr;
gap: 36px;
}
.about-section-reverse .about-copy,
@@ -205,38 +380,23 @@
order: initial;
}
.about-contact-grid {
grid-template-columns: 1fr;
.about-founder-media img {
max-width: 420px;
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.about-inner {
padding: 0 24px;
}
.about-hero {
padding: 56px 0 24px;
}
.about-section,
.about-contact {
padding-bottom: 64px;
}
.about-section-gradient {
margin: 0 12px 64px;
padding: 28px 0;
border-radius: 28px;
.about-section {
padding: 60px 0;
}
.about-section-grid {
gap: 24px;
gap: 28px;
}
.about-copy h2,
.about-contact-card h2 {
font-size: 30px;
.about-copy h2 {
font-size: 28px;
}
.about-copy p {
@@ -244,16 +404,44 @@
line-height: 1.7;
}
.about-contact-card {
padding: 30px 24px;
.about-founder {
padding: 60px 0;
}
.about-contact-grid {
margin-top: 22px;
.about-founder-grid {
gap: 28px;
}
.about-contact-link {
font-size: 18px;
.about-founder-copy h2 {
font-size: 26px;
line-height: 1.02;
}
.about-founder-heading-desktop {
display: none;
}
.about-founder-heading-mobile {
display: block;
}
.about-founder-copy p {
font-size: 16px;
line-height: 1.7;
}
.about-faq {
padding: 60px 0;
}
.about-contact {
padding-bottom: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.about-founder-title-highlight::after {
animation: none;
}
}
</style>
+28 -61
View File
@@ -1,40 +1,41 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import Icon from '$lib/components/Icon.svelte';
import type { BookingContent } from '$lib/types';
import InfoSection from '$lib/components/InfoSection.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import type { BookingContent, InfoContent } from '$lib/types';
export let booking: BookingContent;
export let info: InfoContent;
export let allowGeneralEnquiry = false;
const email = 'info@goodwalk.co.nz';
const phone = '(022) 642 1011';
const phoneHref = `tel:${phone.replace(/[^0-9+]/g, '')}`;
</script>
<main class="booking-page">
<section class="booking-page-hero">
<div class="booking-page-inner">
<h1>Contact Us</h1>
<p class="booking-page-sub">
{#if allowGeneralEnquiry}
Fill in the form below to book a Meet &amp; Greet or send a general enquiry.
{:else}
Fill in the form below and we'll be in touch to arrange a free introduction.
{/if}
</p>
<div class="booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link">
<Icon name="fas fa-envelope" />
{email}
</a>
<a href="tel:{phone.replace(/[^0-9+]/g, '')}" class="booking-contact-link">
<Icon name="fas fa-phone" />
{phone}
</a>
</div>
<PageHeader
variant="green"
title="Contact Us"
subtitle={allowGeneralEnquiry
? "Book a Meet & Greet or send a general enquiry. We'll come back within 24 hours."
: "Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."}
>
<div class="booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link">
<Icon name="fas fa-envelope" />
{email}
</a>
<a href={phoneHref} class="booking-contact-link">
<Icon name="fas fa-phone" />
{phone}
</a>
</div>
</section>
</PageHeader>
<BookingSection {booking} {allowGeneralEnquiry} />
<InfoSection {info} />
</main>
<style>
@@ -42,42 +43,13 @@
background: var(--off-white);
}
.booking-page-hero {
background: var(--green);
color: #fff;
padding: 64px 0 72px;
}
.booking-page-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
text-align: center;
}
.booking-page-hero h1 {
margin: 0 0 14px;
font-family: var(--font-head);
font-size: clamp(32px, 4vw, 52px);
line-height: 1.05;
letter-spacing: -0.04em;
color: #fff;
}
.booking-page-sub {
margin: 0 auto 32px;
max-width: 480px;
font-size: 16px;
line-height: 1.6;
opacity: 0.8;
}
.booking-page-contact {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
margin-top: 28px;
}
.booking-contact-link {
@@ -88,8 +60,11 @@
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
font-family: var(--font-head);
font-size: 14px;
font-weight: 600;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
color: #fff;
transition: background 0.2s;
}
@@ -99,14 +74,6 @@
}
@media (max-width: 768px) {
.booking-page-hero {
padding: 48px 0 56px;
}
.booking-page-inner {
padding: 0 24px;
}
.booking-page-contact {
gap: 12px;
}
+390 -146
View File
@@ -9,6 +9,40 @@
export let booking: BookingContent;
export let allowGeneralEnquiry = false;
type EnquiryType = 'booking' | 'general';
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const requestedServiceStorageKey = 'goodwalk_requested_service';
const maxJourneyEntries = 8;
const servicePrompts: Record<
string,
{
intro: string;
messageLabel: string;
messagePlaceholder: string;
}
> = {
'Pack Walks': {
intro:
'Tell us about your dog, your area, and how they feel around other dogs so we can see if Pack Walks are the right fit.',
messageLabel: 'Pack Walks fit',
messagePlaceholder:
'How old is your dog, how do they feel in groups, and is there anything about confidence, recall, or social behaviour we should know?'
},
'1:1 Walks': {
intro:
'Tell us about your dog, your area, and what you want from one-on-one walks so we can plan the right routine.',
messageLabel: '1:1 walk needs',
messagePlaceholder:
'Tell us about your dogs size, pace, leash manners, confidence, and anything else that would help us tailor a one-on-one walk.'
},
'Puppy Visits': {
intro:
'Tell us about your puppy, your area, and the kind of support you need at home so we can plan the right visit.',
messageLabel: 'Puppy visit details',
messagePlaceholder:
'Tell us your puppys age, routine, toilet needs, feeding schedule, and anything important we should know before visiting.'
}
};
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
@@ -23,6 +57,12 @@
let selectedServices: string[] = [];
let website = '';
let formStartedAt = 0;
let visitStartedAt = 0;
let pageEnteredAt = 0;
let firstInteractionAt = 0;
let sendClickedAt = 0;
let stepChanges = 0;
let journey: string[] = [];
let fullNameInput: HTMLInputElement;
let emailInput: HTMLInputElement;
@@ -67,24 +107,53 @@
const defaultGeneralSubtitle =
'Almost there — just your contact details so we can reply properly to your message.';
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
$: primarySelectedService = selectedServices[0] ?? '';
$: activeServicePrompt = servicePrompts[primarySelectedService];
$: dogIntro = activeServicePrompt?.intro || booking.dogIntro?.trim() || defaultDogIntro;
$: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
$: hasServices = booking.serviceOptions.length > 0;
$: if (!allowGeneralEnquiry && enquiryType === 'general') {
enquiryType = 'booking';
}
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
$: ownerSubtitle = isGeneralEnquiry
$: ownerIntro = isGeneralEnquiry
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
: booking.subtitle;
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
$: firstStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: firstStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: bookingEyebrow = isGeneralEnquiry ? 'Friendly contact' : primarySelectedService || 'Free Meet & Greet';
$: bookingIntro = isGeneralEnquiry
? 'Send us the details and well point you in the right direction.'
: 'Tell us a little about your dog first. Well come back within 24 hours with the right next step.';
$: detailsMessageLabel = isGeneralEnquiry
? 'Your Message'
: activeServicePrompt?.messageLabel || 'About Your Dog';
$: detailsMessagePlaceholder = isGeneralEnquiry
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
: activeServicePrompt?.messagePlaceholder || 'Describe your pet, any special needs, or anything we should know.';
$: successPetName = petName.trim() || 'your dog';
onMount(() => {
formStartedAt = Date.now();
const now = Date.now();
formStartedAt = now;
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
applyRequestedService();
const handleRequestedService = (event: Event) => {
const customEvent = event as CustomEvent<{ service?: string }>;
applyRequestedService(customEvent.detail?.service?.trim());
};
window.addEventListener('goodwalk:service-selected', handleRequestedService as EventListener);
return () => {
window.removeEventListener('goodwalk:service-selected', handleRequestedService as EventListener);
};
});
function splitBookingTitle(title: string) {
@@ -107,9 +176,100 @@
}
}
function readOrCreateVisitStartedAt(fallback: number) {
try {
const raw = window.sessionStorage.getItem(visitStartedStorageKey);
const parsed = raw ? Number(raw) : NaN;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
window.sessionStorage.setItem(visitStartedStorageKey, String(fallback));
} catch {
return fallback;
}
return fallback;
}
function updateJourneySnapshot(pathname: string, search: string) {
const nextEntry = `${pathname}${search}`;
try {
const raw = window.sessionStorage.getItem(journeyStorageKey);
const previous = raw ? (JSON.parse(raw) as string[]) : [];
const cleaned = previous.filter((value) => typeof value === 'string' && value.trim());
const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry];
const nextJourney = deduped.slice(-maxJourneyEntries);
window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney));
return nextJourney;
} catch {
return [nextEntry];
}
}
function noteInteraction() {
if (!firstInteractionAt) {
firstInteractionAt = Date.now();
}
}
function setStep(nextStep: number, trackTransition = false) {
if (step !== nextStep && trackTransition) {
stepChanges += 1;
}
step = nextStep;
errors = {};
}
function applyRequestedService(service?: string) {
const requestedService =
service ||
(() => {
try {
return window.sessionStorage.getItem(requestedServiceStorageKey)?.trim() || '';
} catch {
return '';
}
})();
if (!requestedService || !booking.serviceOptions.includes(requestedService)) {
return;
}
selectedServices = [requestedService];
try {
window.sessionStorage.removeItem(requestedServiceStorageKey);
} catch {
// Ignore storage cleanup failures.
}
}
function sortSelectedServices(services: string[]) {
return [...services].sort((a, b) => {
const indexA = booking.serviceOptions.indexOf(a);
const indexB = booking.serviceOptions.indexOf(b);
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}
function toggleService(service: string, checked: boolean) {
noteInteraction();
if (checked) {
selectedServices = [...selectedServices, service];
selectedServices = sortSelectedServices([
...selectedServices.filter((item) => item !== service),
service
]);
return;
}
@@ -117,6 +277,7 @@
}
function setEnquiryType(nextType: EnquiryType) {
noteInteraction();
enquiryType = nextType;
if (nextType === 'general') {
petName = '';
@@ -126,7 +287,37 @@
errors = {};
}
function validateFirstStep(): boolean {
function validateOwnerStep(): boolean {
const next: Record<string, string> = {};
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
errors = next;
if (next.fullName) {
fullNameInput?.focus();
return false;
}
if (next.email) {
emailInput?.focus();
return false;
}
if (next.phone) {
phoneInput?.focus();
return false;
}
return true;
}
function validateDetailsStep(): boolean {
const next: Record<string, string> = {};
if (isGeneralEnquiry) {
@@ -156,9 +347,9 @@
}
function goToOwnerStep() {
if (!validateFirstStep()) return;
errors = {};
step = 2;
noteInteraction();
if (!validateDetailsStep()) return;
setStep(2, true);
}
async function handleSubmit(event: SubmitEvent) {
@@ -169,22 +360,13 @@
return;
}
const next: Record<string, string> = {};
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
if (Object.keys(next).length > 0) {
errors = next;
if (next.fullName) fullNameInput?.focus();
else if (next.email) emailInput?.focus();
else if (next.phone) phoneInput?.focus();
if (!validateOwnerStep()) {
return;
}
errors = {};
noteInteraction();
sendClickedAt = Date.now();
submitting = true;
submitErrorDetail = '';
showErrorModal = false;
@@ -194,26 +376,33 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enquiryType,
fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
referrer: document.referrer,
page: window.location.href,
})
enquiryType,
fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
referrer: document.referrer,
page: window.location.href
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const detail = typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
const detail =
typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
throw new Error(detail);
}
@@ -229,7 +418,6 @@
<section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
<div class="form-inner">
{#if submitted}
<SuccessModal
firstName={fullName.split(' ')[0]}
@@ -250,19 +438,32 @@
{/if}
<div class="booking-header">
<span class="booking-eyebrow">{bookingEyebrow}</span>
<h2 class="booking-title">
<span class="booking-title-plain">{headingParts.plain}</span>{' '}<span class="booking-title-highlight">{headingParts.highlight}</span>
<span class="booking-title-plain">{headingParts.plain}</span>{' '}
<span class="booking-title-highlight">{headingParts.highlight}</span>
</h2>
<p class="booking-intro">{bookingIntro}</p>
<div class="booking-trust-row" aria-label="Booking highlights">
<span class="booking-trust-chip">
<Icon name="fas fa-comment-dots" />
Reply within 24 hours
</span>
<span class="booking-trust-chip">
<Icon name="fas fa-paw" />
Free, no-obligation Meet &amp; Greet
</span>
</div>
<div class="booking-stepper" aria-label="Booking form steps">
<button
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => (step = 1)}
on:click={() => setStep(1, step !== 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{firstStepLabel}</span>
<span class="booking-step-label">{detailsStepLabel}</span>
</button>
<span class="booking-step-divider" aria-hidden="true"></span>
<button
@@ -282,6 +483,9 @@
id="bookingForm"
novalidate
on:submit={handleSubmit}
on:focusin={noteInteraction}
on:input={noteInteraction}
on:change={noteInteraction}
>
<div class="booking-honeypot" aria-hidden="true">
<label for="website">Website</label>
@@ -296,12 +500,16 @@
</div>
{#if step === 1}
<input type="hidden" name="enquiryType" value={enquiryType} />
<div class="booking-panel">
{#if firstStepIntro}
<div class="booking-panel-banner">{firstStepIntro}</div>
{#if detailsStepIntro}
<div class="booking-panel-banner">{detailsStepIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(firstStepIntro)} class="booking-card-grid booking-card-grid-dog">
<div
class:booking-card-grid-with-banner={Boolean(detailsStepIntro)}
class="booking-card-grid booking-card-grid-dog"
>
{#if allowGeneralEnquiry}
<div class="booking-field-card booking-field-card-full">
<label>
@@ -338,126 +546,156 @@
{/if}
{#if !isGeneralEnquiry}
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Dog's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-dog">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Dog's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.location}>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
</label>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Suburb, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
</p>
{/if}
</div>
<div
class="booking-field-stack booking-field-stack-full"
class:booking-field-stack-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={detailsMessagePlaceholder}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</p>
{/if}
</div>
{#if hasServices}
<div class="booking-field-stack booking-field-stack-full">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
{#if isGeneralEnquiry}
<div
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
<span class="booking-required">*</span>
</label>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Suburb, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={detailsMessagePlaceholder}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
{errors.message}
</p>
{/if}
</div>
{/if}
<div class="booking-field-card booking-field-card-full" class:booking-field-card-invalid={errors.message}>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={isGeneralEnquiry
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
: 'Describe your pet, any special needs, or anything we should know.'}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</p>
{/if}
</div>
</div>
{#if hasServices && !isGeneralEnquiry}
<div class="booking-service-row">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
<div class="booking-actions booking-actions-next">
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToOwnerStep}>
{ownerStepLabel}
Next: {ownerStepLabel.toLowerCase()}
<Icon name="fas fa-arrow-right" />
</button>
<p class="booking-next-note">No payment, no pressure, just the right starting point for your dog.</p>
</div>
{:else}
<input type="hidden" name="enquiryType" value={enquiryType} />
{#if !isGeneralEnquiry}
<input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} />
{/if}
<input type="hidden" name="fullName" value={fullName} />
<input type="hidden" name="email" value={email} />
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} />
<input type="hidden" name="message" value={message} />
{#if !isGeneralEnquiry}
{#each selectedServices as service}
<input type="hidden" name="services" value={service} />
{/each}
{/if}
<div class="booking-panel">
{#if ownerSubtitle}
<div class="booking-panel-banner">{ownerSubtitle}</div>
{#if ownerIntro}
<div class="booking-panel-banner">{ownerIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(ownerSubtitle)} class="booking-card-grid booking-card-grid-owner">
<div
class:booking-card-grid-with-banner={Boolean(ownerIntro)}
class="booking-card-grid booking-card-grid-owner"
>
<div class="booking-field-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-owner">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
@@ -542,12 +780,18 @@
<button
type="button"
class="btn btn-outline btn-outline-green"
on:click={() => { step = 1; errors = {}; }}
on:click={() => setStep(1, true)}
>
Back
</button>
<button type="submit" class="btn btn-yellow booking-submit-button" disabled={submitting}>
{#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if}
{#if submitting}
Sending…
{:else if isGeneralEnquiry}
Send enquiry <Icon name="fas fa-arrow-right" />
{:else}
Request Meet &amp; Greet <Icon name="fas fa-arrow-right" />
{/if}
</button>
</div>
{/if}
+48 -70
View File
@@ -3,8 +3,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import BookingSection from './BookingSection.svelte';
import { homepageContent } from '$lib/content/homepage';
async function fillOwnerStep() {
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
}
async function fillDogStep() {
await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services'));
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/Pack Walks fit/i), {
target: { value: 'Loves small group walks.' }
});
}
async function moveToOwnerStep(container: HTMLElement) {
await fillDogStep();
await fireEvent.click(container.querySelector('.booking-next-button')!);
}
describe('BookingSection', () => {
beforeEach(() => {
window.sessionStorage.clear();
Object.defineProperty(document, 'referrer', {
configurable: true,
value: 'https://www.google.com/'
@@ -29,14 +61,7 @@ describe('BookingSection', () => {
booking: homepageContent.booking
});
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await moveToOwnerStep(container);
await fireEvent.click(container.querySelector('.booking-submit-button')!);
expect(screen.getByText('Please enter your full name')).toBeInTheDocument();
@@ -55,30 +80,8 @@ describe('BookingSection', () => {
booking: homepageContent.booking
});
await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services'));
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
target: { value: 'Loves small group walks.' }
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
await moveToOwnerStep(container);
await fillOwnerStep();
await fireEvent.click(container.querySelector('.booking-submit-button')!);
@@ -102,9 +105,15 @@ describe('BookingSection', () => {
message: 'Loves small group walks.',
services: ['Pack Walks', 'Other Services'],
website: '',
referrer: 'https://www.google.com/'
referrer: 'https://www.google.com/',
stepChanges: 1,
journey: [window.location.pathname]
});
expect(payload.formStartedAt).toEqual(expect.any(Number));
expect(payload.visitStartedAt).toEqual(expect.any(Number));
expect(payload.pageEnteredAt).toEqual(expect.any(Number));
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
expect(payload.sendClickedAt).toEqual(expect.any(Number));
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
@@ -128,15 +137,7 @@ describe('BookingSection', () => {
allowGeneralEnquiry: true
});
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Grey Lynn' }
});
await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText(/General enquiry/i));
expect(screen.queryByLabelText(/Dog's Name/i)).not.toBeInTheDocument();
expect(screen.queryByText('Pack Walks')).not.toBeInTheDocument();
@@ -148,17 +149,7 @@ describe('BookingSection', () => {
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
await fillOwnerStep();
await fireEvent.click(container.querySelector('.booking-submit-button')!);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
@@ -172,7 +163,9 @@ describe('BookingSection', () => {
petName: '',
location: '',
message: 'I would like to discuss a business partnership.',
services: []
services: [],
stepChanges: 1,
journey: [window.location.pathname]
});
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
@@ -192,23 +185,8 @@ describe('BookingSection', () => {
booking: homepageContent.booking
});
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
await moveToOwnerStep(container);
await fillOwnerStep();
await fireEvent.click(container.querySelector('.booking-submit-button')!);
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
export let eyebrow = 'Get in touch';
export let title: string;
export let description: string;
export let ctaHref: string;
export let ctaLabel: string;
export let email: string | undefined = undefined;
export let phone: string | undefined = undefined;
export let phoneHref: string | undefined = undefined;
export let showIcons = false;
$: resolvedPhoneHref = phoneHref ?? (phone ? `tel:${phone.replace(/[^0-9+]/g, '')}` : undefined);
</script>
<div class="cta-card">
<span class="cta-card__eyebrow">{eyebrow}</span>
<h2>{title}</h2>
<p class="cta-card__desc">{description}</p>
<a class="btn btn-yellow btn-mobile-center cta-card__btn" href={ctaHref}>{ctaLabel}</a>
{#if email || phone}
<div class="cta-card__links">
{#if email}
<a class="cta-card__link" href="mailto:{email}">
{#if showIcons}<Icon name="fas fa-envelope" />{/if}
{email}
</a>
{/if}
{#if phone && resolvedPhoneHref}
<a class="cta-card__link" href={resolvedPhoneHref}>
{#if showIcons}<Icon name="fas fa-phone" />{/if}
{phone}
</a>
{/if}
</div>
{/if}
</div>
<style>
.cta-card {
background: var(--gw-green);
color: #fff;
border-radius: 28px;
padding: 56px 48px;
text-align: center;
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.18);
}
.cta-card__eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cta-card h2 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 42px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #fff;
}
.cta-card__desc {
max-width: 460px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.75);
font-size: 16px;
line-height: 1.6;
}
.cta-card__btn {
display: flex;
width: fit-content;
margin: 0 auto;
}
.cta-card__links {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
margin-top: 22px;
}
.cta-card__link {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.72);
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: color 0.18s ease;
}
.cta-card__link:hover {
color: #fff;
}
@media (max-width: 768px) {
.cta-card {
padding: 36px 24px;
border-radius: 24px;
}
.cta-card__links {
flex-direction: column;
align-items: center;
gap: 14px;
}
}
</style>
+12 -113
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import ModalShell from '$lib/components/ModalShell.svelte';
export let email = 'info@goodwalk.co.nz';
export let enquiryType: 'booking' | 'general' = 'booking';
export let onClose: () => void;
@@ -11,29 +13,12 @@
`?subject=${encodeURIComponent(isGeneralEnquiry ? 'General enquiry' : 'Booking enquiry')}` +
`&body=${encodeURIComponent(
isGeneralEnquiry
? 'Hi Aless,\n\nI tried to submit the contact form but it didnt go through. Here are my details:\n\nName:\nPhone:\nMessage:\n\nThanks!'
: 'Hi Aless,\n\nI tried to submit the booking form but it didnt go through. Here are my details:\n\nName:\nPhone:\nDogs name:\nLocation:\n\nThanks!'
? "Hi Aless,\n\nI tried to submit the contact form but it didn't go through. Here are my details:\n\nName:\nPhone:\nMessage:\n\nThanks!"
: "Hi Aless,\n\nI tried to submit the booking form but it didn't go through. Here are my details:\n\nName:\nPhone:\nDog's name:\nLocation:\n\nThanks!"
)}`;
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="error-modal-heading"
on:click|self={onClose}
on:keydown={handleKeydown}
tabindex="-1"
>
<div class="modal-card">
<button class="modal-close" type="button" aria-label="Close" on:click={onClose}>
&#x2715;
</button>
<ModalShell {onClose} ariaLabelledBy="error-modal-heading">
<div class="modal-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 9v4" />
@@ -69,61 +54,9 @@
</button>
{/if}
</div>
</div>
</div>
</ModalShell>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(10, 20, 10, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: backdrop-in 0.25s ease;
}
.modal-card {
position: relative;
width: 100%;
max-width: 480px;
padding: 52px 48px 40px;
background: #fff;
border-radius: 24px;
box-shadow: 0 24px 80px rgba(10, 20, 10, 0.22);
text-align: center;
animation: card-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-close {
position: absolute;
top: 18px;
right: 20px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #f2f2f0;
color: #888;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s ease,
color 0.15s ease;
}
.modal-close:hover {
background: #e8e8e4;
color: #333;
}
.modal-icon {
width: 64px;
height: 64px;
@@ -141,7 +74,7 @@
margin: 0 0 12px;
font-size: 24px;
font-weight: 700;
color: #213021;
color: var(--gw-green);
line-height: 1.25;
}
@@ -162,7 +95,7 @@
border-radius: 14px;
background: #f7f6f1;
border: 1px solid #ebe9df;
color: #213021;
color: var(--gw-green);
text-decoration: none;
transition:
background 0.15s ease,
@@ -187,7 +120,7 @@
.modal-email-address {
font-size: 17px;
font-weight: 600;
color: #213021;
color: var(--gw-green);
word-break: break-all;
}
@@ -226,7 +159,7 @@
}
.modal-btn-primary {
background: #213021;
background: var(--gw-green);
color: #ffd100;
}
@@ -237,50 +170,16 @@
.modal-btn-secondary {
background: transparent;
color: #213021;
color: var(--gw-green);
border: 1px solid #d4d2c6;
}
.modal-btn-secondary:hover {
background: #f2f2f0;
border-color: #213021;
}
@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes card-in {
from {
opacity: 0;
transform: scale(0.88) translateY(16px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes bounce-in {
from {
opacity: 0;
transform: scale(0.4);
}
to {
opacity: 1;
transform: scale(1);
}
border-color: var(--gw-green);
}
@media (max-width: 480px) {
.modal-card {
padding: 44px 28px 32px;
}
.modal-actions {
flex-direction: column;
}
+29 -37
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import type { FooterContent, LinkItem } from '$lib/types';
import { locationPages } from '$lib/content/locations';
export let footer: FooterContent;
const socialLinks: LinkItem[] = [
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
@@ -32,12 +34,10 @@
<footer>
<div class="footer-inner">
<div class="footer-brand">
<img
src="/images/goodwalk-auckland-dog-walking-logo.png"
<enhanced:img
src="$lib/images/goodwalk-auckland-dog-walking-logo.png"
alt="Goodwalk Auckland dog walking service logo"
class="footer-logo"
width="241"
height="48"
loading="lazy"
decoding="async"
/>
@@ -53,6 +53,23 @@
<Icon name="fab fa-google" />
</a>
</div>
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
{/if}
</div>
{/if}
</div>
<div class="footer-explore">
@@ -72,39 +89,13 @@
</ul>
</div>
<div class="footer-action">
<p class="footer-col-label">Get Started</p>
<a href="/contact-us" class="footer-book-btn">
Book a Meet &amp; Greet
<Icon name="fas fa-arrow-right" />
</a>
<p class="footer-book-note">Free, no-obligation introduction</p>
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="footer-reviews"
>
<Icon name="fab fa-google" />
<span>See our 5&#9733; Google reviews</span>
</a>
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
{/if}
</div>
{/if}
<div class="footer-locations">
<p class="footer-col-label">Areas we serve</p>
<ul class="footer-nav">
{#each locationPages as loc}
<li><a href="/locations/{loc.slug}">{loc.suburb}</a></li>
{/each}
</ul>
</div>
</div>
@@ -114,5 +105,6 @@
<a href="/terms-and-conditions">Terms &amp; Conditions</a>
<a href="/privacy-policy">Privacy Policy</a>
</nav>
<a href="#" class="footer-back-top" aria-label="Back to top">↑ Back to top</a>
</div>
</footer>
@@ -0,0 +1,254 @@
<script lang="ts">
import { getEnhancedImage } from '$lib/enhanced-images';
import type { FounderStoryContent } from '$lib/types';
export let founderStory: FounderStoryContent;
$: founderStoryEnhanced = getEnhancedImage(founderStory.imageUrl);
</script>
<section id="promise">
<div class="promise-inner">
<div class="promise-text">
<span class="promise-kicker">Founder story</span>
<div class="promise-mobile-intro">
<div class="promise-mobile-avatar">
{#if founderStoryEnhanced}
<enhanced:img
src={founderStoryEnhanced}
alt={founderStory.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img src={founderStory.imageUrl} alt={founderStory.imageAlt} loading="lazy" decoding="async" />
{/if}
</div>
<p class="promise-mobile-caption">Auckland Central walks led personally by Aless.</p>
</div>
<h2 class="promise-heading">
<span class="promise-heading-desktop">
<span class="promise-title-main">{founderStory.title}</span>
<br />
<span class="promise-title-highlight">{founderStory.subtitle}</span>
</span>
<span class="promise-heading-mobile">
<span class="promise-title-main">{founderStory.title}</span>
<span class="promise-title-highlight">{founderStory.subtitle}</span>
</span>
</h2>
{#each founderStory.body as paragraph, idx}
<p>
{paragraph}
{#if idx === founderStory.body.length - 1}
<strong>{founderStory.emphasis}</strong>
{/if}
</p>
{/each}
<a href={founderStory.cta.href} class="btn btn-green">{founderStory.cta.label}</a>
</div>
<div class="promise-img">
<div class="promise-img-frame">
{#if founderStoryEnhanced}
<enhanced:img
src={founderStoryEnhanced}
alt={founderStory.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img src={founderStory.imageUrl} alt={founderStory.imageAlt} loading="lazy" decoding="async" />
{/if}
</div>
</div>
</div>
</section>
<style>
.promise-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.promise-heading {
margin: 0 0 22px;
max-width: 14ch;
}
.promise-mobile-intro {
display: none;
}
.promise-heading-desktop {
display: block;
}
.promise-heading-mobile {
display: none;
}
.promise-heading-mobile .promise-title-main,
.promise-heading-mobile .promise-title-highlight {
display: block;
}
.promise-title-main {
display: block;
margin-bottom: 8px;
color: rgba(13, 26, 13, 0.68);
font-family: var(--font-head);
font-size: clamp(15px, 1.3vw, 18px);
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.15;
}
.promise-title-highlight {
display: block;
color: #0d1a0d;
font-size: clamp(42px, 5.2vw, 64px);
font-weight: 800;
letter-spacing: -0.05em;
line-height: 0.96;
text-wrap: balance;
}
.promise-text {
position: relative;
z-index: 2;
}
@media (max-width: 768px) {
#promise {
padding-top: 42px;
padding-bottom: var(--space-section-featured-y);
}
.promise-kicker {
margin-bottom: 12px;
padding: 7px 12px;
font-size: 11px;
}
.promise-text {
width: 100%;
margin-top: 0;
padding: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.promise-heading {
max-width: none;
margin-bottom: 22px;
text-align: center;
}
.promise-heading-desktop {
display: none;
}
.promise-heading-mobile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.promise-heading-mobile .promise-title-main,
.promise-heading-mobile .promise-title-highlight {
display: inline-block;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.promise-mobile-intro {
display: flex;
align-items: center;
gap: 14px;
margin: 0 0 16px;
padding: 14px 16px;
border-radius: 22px;
background: linear-gradient(180deg, #fbf6e8 0%, #efe4c8 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.65),
0 10px 22px rgba(17, 20, 24, 0.06),
inset 0 0 0 1px rgba(242, 191, 47, 0.12);
}
.promise-mobile-avatar {
flex: 0 0 auto;
width: 68px;
height: 68px;
overflow: hidden;
border-radius: 20px;
box-shadow: 0 8px 18px rgba(17, 20, 24, 0.08);
}
.promise-mobile-avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
.promise-mobile-caption {
margin: 0;
color: #34363a;
font-family: var(--font-head);
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.promise-title-main {
margin-bottom: 0;
font-size: 14px;
letter-spacing: 0.02em;
}
.promise-title-highlight {
font-size: clamp(36px, 11vw, 54px);
line-height: 0.98;
}
.promise-text p,
.promise-text .btn {
margin-left: 0;
margin-right: 0;
}
.promise-text p {
text-align: left;
}
.promise-text .btn {
width: 100%;
justify-content: center;
}
.promise-img {
display: none;
}
}
</style>
+91 -22
View File
@@ -3,10 +3,18 @@
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import type { NavigationContent } from '$lib/types';
import type { Picture } from '@sveltejs/enhanced-img';
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
import logoMobile from '$lib/images/goodwalk-auckland-dog-walking-logo-mobile.png?enhanced';
const desktop = logoDesktop as Picture;
const mobile = logoMobile as Picture;
export let navigation: NavigationContent;
let mobileMenuOpen = false;
let headerElement: HTMLElement;
let mobileMenuTop = 0;
const mobilePhoneDisplay = '(022) 642 1011';
const mobilePhoneHref = '+64226421011';
@@ -26,6 +34,28 @@
mobileMenuOpen = !mobileMenuOpen;
}
function updateMobileMenuTop() {
if (!headerElement) return;
mobileMenuTop = Math.max(headerElement.getBoundingClientRect().bottom, 0);
}
function mobileLinkIcon(href: string) {
if (href === '/') return 'fas fa-house';
if (href === '/pack-walks') return 'fas fa-paw';
if (href === '/dog-walking') return 'fas fa-person-walking';
if (href === '/puppy-visits') return 'fas fa-dog';
if (href === '/our-pricing') return 'fas fa-tags';
if (href === '/about') return 'fas fa-heart';
if (href === '/contact-us') return 'fas fa-envelope';
return 'fas fa-arrow-right';
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && mobileMenuOpen) {
closeMenu();
}
}
function normalizePath(path: string) {
if (!path || path === '/') {
return '/';
@@ -68,20 +98,37 @@
}
function handleViewportChange() {
updateMobileMenuTop();
if (window.innerWidth > 768) {
mobileMenuOpen = false;
}
}
$: if (mobileMenuOpen && typeof window !== 'undefined') {
updateMobileMenuTop();
}
$: if (typeof document !== 'undefined') {
document.body.classList.toggle('mobile-menu-open', mobileMenuOpen);
}
onMount(() => {
handleViewportChange();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', updateMobileMenuTop, { passive: true });
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('resize', handleViewportChange);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', updateMobileMenuTop);
window.removeEventListener('keydown', handleKeydown);
document.body.classList.remove('mobile-menu-open');
};
});
</script>
<header>
<header bind:this={headerElement}>
<nav>
<ul class="nav-links">
{#each navigation.desktopLinks as link, i}
@@ -141,15 +188,14 @@
<a href="/" class="logo" aria-label="Goodwalk Auckland Dog Walking, home">
<picture>
<source
media="(max-width: 768px)"
srcset="/images/goodwalk-auckland-dog-walking-logo-mobile.png"
/>
{#if mobile.sources?.webp}
<source type="image/webp" srcset={mobile.sources.webp} />
{/if}
<img
src="/images/goodwalk-auckland-dog-walking-logo.png"
src={mobile.img.src}
alt="Goodwalk Auckland dog walking service logo"
width="241"
height="48"
width={mobile.img.w}
height={mobile.img.h}
decoding="async"
/>
</picture>
@@ -190,18 +236,41 @@
</button>
</nav>
<div class:open={mobileMenuOpen} class="mobile-menu" id="mobile-menu">
{#each navigation.mobileLinks as link}
<a
href={link.href}
target={linkTarget(link.external)}
rel={linkRel(link.external)}
aria-current={ariaCurrent(link.href)}
class:mobile-link-active={isActiveLink(link.href)}
on:click={closeMenu}
>
{link.label}
</a>
{/each}
{#if $page.url.pathname === '/'}
<div class="nav-ribbon">
<span class="nav-ribbon-item"><Icon name="fas fa-paw" />Small &amp; Medium Dog Specialists</span>
<span class="nav-ribbon-divider"></span>
<span class="nav-ribbon-item"><Icon name="fas fa-handshake" />Free Meet &amp; Greet</span>
<span class="nav-ribbon-divider"></span>
<span class="nav-ribbon-item"><Icon name="fas fa-van-shuttle" />Free Pickup &amp; Drop-off</span>
</div>
{/if}
<div
class:open={mobileMenuOpen}
class="mobile-menu-shell"
style={`--mobile-menu-top: ${mobileMenuTop}px;`}
on:click={closeMenu}
>
<div class="mobile-menu" id="mobile-menu" on:click|stopPropagation>
<div class="mobile-menu-links">
{#each navigation.mobileLinks as link}
<a
href={link.href}
target={linkTarget(link.external)}
rel={linkRel(link.external)}
aria-current={ariaCurrent(link.href)}
class:mobile-link-active={isActiveLink(link.href)}
on:click={closeMenu}
>
<span class="mobile-menu-link-icon">
<Icon name={mobileLinkIcon(link.href)} />
</span>
<span class="mobile-menu-link-label">{link.label}</span>
<Icon name="fas fa-arrow-right" className="mobile-menu-link-arrow" />
</a>
{/each}
</div>
</div>
</div>
</header>
+26 -2
View File
@@ -28,16 +28,40 @@ describe('Header', () => {
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
const mobileMenu = container.querySelector('.mobile-menu') as HTMLDivElement;
const mobileMenuShell = container.querySelector('.mobile-menu-shell') as HTMLDivElement;
const firstMobileLink = mobileMenu.querySelector('a') as HTMLAnchorElement;
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
await fireEvent.click(menuToggle);
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
expect(mobileMenu.classList.contains('open')).toBe(true);
expect(mobileMenuShell.classList.contains('open')).toBe(true);
await fireEvent.click(firstMobileLink);
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
expect(mobileMenu.classList.contains('open')).toBe(false);
expect(mobileMenuShell.classList.contains('open')).toBe(false);
});
it('closes the mobile menu when tapping outside the menu panel', async () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 390
});
const { container } = render(Header, {
navigation: homepageContent.navigation
});
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
const mobileMenuShell = container.querySelector('.mobile-menu-shell') as HTMLDivElement;
await fireEvent.click(menuToggle);
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
expect(mobileMenuShell.classList.contains('open')).toBe(true);
await fireEvent.click(mobileMenuShell);
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
expect(mobileMenuShell.classList.contains('open')).toBe(false);
});
});
+97 -19
View File
@@ -1,12 +1,17 @@
<script lang="ts">
import { getImageMetadata } from '$lib/image-metadata';
import type { HeroContent } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { CallToAction, HeroContent } from '$lib/types';
export let hero: HeroContent;
export let reviewCta: CallToAction | undefined = undefined;
$: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
$: heroImage = getImageMetadata(hero.imageUrl);
$: mobileLead = mobileTitle.includes(hero.highlight)
? mobileTitle.slice(0, mobileTitle.lastIndexOf(hero.highlight))
: mobileTitle;
$: heroEnhanced = getEnhancedImage(hero.imageUrl);
function splitTitle(title: string) {
const trimmed = title.trim();
@@ -23,11 +28,44 @@
connector: ''
};
}
function linkTarget(external?: boolean) {
return external ? '_blank' : undefined;
}
function linkRel(external?: boolean) {
return external ? 'noopener' : undefined;
}
</script>
<section id="hero">
<!-- hero-img is a direct child of #hero so it can be absolutely
positioned relative to the section on mobile without being
constrained by hero-inner's stacking context -->
<div class="hero-img">
<picture>
{#if hero.desktopImageUrl}
<source media="(min-width: 769px)" srcset={hero.desktopImageUrl} />
{/if}
<img
src={hero.imageUrl}
alt={hero.imageAlt}
loading="eager"
fetchpriority="high"
/>
</picture>
</div>
{#if hero.floatingPill}
<div class="hero-floating-pill">{hero.floatingPill}</div>
{/if}
<div class="hero-inner">
<div class="hero-text">
{#if hero.kicker}
<p class="hero-kicker">{hero.kicker}</p>
{/if}
<h1 class="hero-heading">
<span class="hero-heading-desktop">
<span class="hero-title-main">{titleParts.lead}</span>
@@ -37,29 +75,69 @@
<br />
<span class="hero-title-highlight">{hero.highlight}</span>
</span>
<span class="hero-heading-mobile">{mobileTitle}</span>
<span class="hero-heading-mobile">
{mobileLead}<span class="hero-title-highlight">{hero.highlight}</span>
</span>
</h1>
{#if hero.subtitle}
<p class="hero-subtitle">{hero.subtitle}</p>
<p class="hero-subtitle hero-subtitle-desktop">{hero.subtitle}</p>
{/if}
{#if hero.subtitleChips && hero.subtitleChips.length}
<div class="hero-chips">
{#each hero.subtitleChips as chip}
<span class="hero-chip">
<Icon name={chip.icon} />
{chip.label}
</span>
{/each}
</div>
{/if}
{#if reviewCta}
<a
class="hero-trust-chip"
href={reviewCta.href}
target={reviewCta.external ? '_blank' : undefined}
rel={reviewCta.external ? 'noopener' : undefined}
aria-label="Read our five-star Google reviews"
>
<img
class="hero-trust-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span class="hero-trust-stars" aria-hidden="true">
{#each Array(5) as _}
<Icon name="fas fa-star" />
{/each}
</span>
<span>{reviewCta.label}</span>
</a>
{/if}
<div class="hero-buttons">
<a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a>
<a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a>
<a
href={hero.primaryCta.href}
target={linkTarget(hero.primaryCta.external)}
rel={linkRel(hero.primaryCta.external)}
class="btn btn-yellow"
>
{hero.primaryCta.label}
</a>
<a
href={hero.secondaryCta.href}
target={linkTarget(hero.secondaryCta.external)}
rel={linkRel(hero.secondaryCta.external)}
class="btn btn-outline"
>
{hero.secondaryCta.label}
<Icon name="fas fa-arrow-down" className="hero-cta-arrow" />
</a>
</div>
</div>
<div class="hero-img">
<img
src={hero.imageUrl}
alt={hero.imageAlt}
width={heroImage?.width}
height={heroImage?.height}
loading="eager"
fetchpriority="high"
decoding="async"
/>
</div>
</div>
</section>
+304
View File
@@ -0,0 +1,304 @@
<script lang="ts">
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import type { HowItWorksContent } from '$lib/types';
export let content: HowItWorksContent;
</script>
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
<div class="hiw-inner">
<div class="hiw-header">
<span class="hiw-eyebrow">Getting started</span>
<h2 class="section-heading">{content.title}</h2>
{#if content.intro}
<p class="hiw-intro">{content.intro}</p>
{/if}
</div>
<div class="hiw-steps">
{#each content.steps as step, index}
<div class="hiw-step">
<div class="hiw-step-meta">
<span class="hiw-phase">{step.phase}</span>
<span class="hiw-num">0{index + 1}</span>
</div>
<div class="hiw-icon-wrap">
<Icon name={step.icon} className="hiw-step-icon" />
</div>
<h3 class="hiw-title">{step.title}</h3>
<p class="hiw-body">{step.body}</p>
{#if step.benefit}
<span class="hiw-benefit">
<Icon name="fas fa-check" className="hiw-check-icon" />
{step.benefit}
</span>
{/if}
</div>
{/each}
</div>
<div class="hiw-cta">
<a href="#newlead" class="btn btn-green btn-mobile-center">Book your free Meet &amp; Greet</a>
<p class="hiw-cta-note">Free, no-obligation. We reply within 24 hours.</p>
</div>
</div>
</section>
<style>
#how-it-works {
background: var(--off-white);
padding: var(--space-section-page-y) 0;
}
.hiw-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
}
/* ── Header ── */
.hiw-header {
text-align: center;
margin-bottom: 56px;
}
.hiw-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.hiw-intro {
max-width: 580px;
margin: 16px auto 0;
color: #4c5056;
font-size: 16px;
line-height: 1.65;
}
/* ── Steps grid ── */
.hiw-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
position: relative;
}
.hiw-step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 40px 40px 36px;
background: #fff;
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
transition: box-shadow 0.22s ease, transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.hiw-step:first-child {
border-radius: 28px 0 0 28px;
}
.hiw-step:last-child {
border-radius: 0 28px 28px 0;
}
.hiw-step + .hiw-step {
border-left: none;
}
@media (hover: hover) {
.hiw-step:hover {
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.09);
transform: translateY(-4px);
z-index: 1;
}
}
/* ── Step meta (phase + number) ── */
.hiw-step-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 26px;
}
.hiw-phase {
display: inline-block;
padding: 5px 13px;
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hiw-num {
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
color: rgba(33, 48, 33, 0.28);
letter-spacing: 0.04em;
}
/* ── Icon ── */
.hiw-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: 22px;
border-radius: 20px;
background: var(--gw-green);
box-shadow: 0 10px 28px rgba(33, 48, 33, 0.2);
}
.hiw-icon-wrap :global(.hiw-step-icon) {
font-size: 26px;
color: #fff;
}
/* ── Content ── */
.hiw-title {
margin: 0 0 14px;
font-family: var(--font-head);
font-size: 20px;
font-weight: 700;
line-height: 1.2;
color: #0d1a0d;
}
.hiw-body {
margin: 0 0 20px;
color: #4c5056;
font-size: 15px;
line-height: 1.65;
flex: 1;
}
.hiw-benefit {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
color: var(--gw-green);
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.hiw-benefit :global(.hiw-check-icon) {
font-size: 10px;
flex-shrink: 0;
}
/* ── CTA ── */
.hiw-cta {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 52px;
}
.hiw-cta-note {
margin: 0;
color: #888;
font-size: 13px;
}
/* ── Mobile ── */
@media (max-width: 768px) {
.hiw-inner {
padding: 0 var(--space-container-x-mobile);
}
.hiw-header {
margin-bottom: 32px;
}
.hiw-intro {
font-size: 15px;
line-height: 1.55;
}
.hiw-steps {
grid-template-columns: 1fr;
gap: 12px;
}
.hiw-step {
align-items: flex-start;
text-align: left;
padding: 28px 24px;
border-radius: 24px !important;
border: 1px solid rgba(17, 20, 24, 0.06);
}
.hiw-step + .hiw-step {
border-left: 1px solid rgba(17, 20, 24, 0.06);
}
.hiw-step-meta {
justify-content: flex-start;
margin-bottom: 20px;
}
.hiw-icon-wrap {
width: 54px;
height: 54px;
border-radius: 16px;
margin-bottom: 18px;
}
.hiw-icon-wrap :global(.hiw-step-icon) {
font-size: 22px;
}
.hiw-title {
font-size: 18px;
}
.hiw-body {
font-size: 14px;
line-height: 1.6;
}
.hiw-cta {
margin-top: 36px;
}
}
/* ── Reveal ── */
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
transition:
opacity 0.55s ease,
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--reveal-delay, 0ms);
}
:global(.reveal-visible.reveal-block) {
opacity: 1;
transform: translate3d(0, 0, 0);
}
</style>
+157 -9
View File
@@ -1,27 +1,54 @@
<script lang="ts">
import { accordion } from '$lib/actions/accordion';
import Icon from '$lib/components/Icon.svelte';
import { locationPages } from '$lib/content/locations';
import type { InfoContent } from '$lib/types';
export let info: InfoContent;
const slugBySuburb = new Map(locationPages.map((loc) => [loc.suburb, loc.slug]));
$: suburbChips = info.suburbs
.split(',')
.map((suburb) => suburb.trim().replace(/\.$/, ''))
.filter(Boolean)
.map((suburb) => ({ name: suburb, slug: slugBySuburb.get(suburb) ?? null }));
</script>
<section id="info">
<div class="info-inner">
<div class="info-block">
<h2><Icon name="fas fa-location-dot" /> {info.title}</h2>
<p>{info.intro}</p>
<p class="info-copy">{info.suburbs}</p>
<p class="info-copy">
{info.nearbyText}
<a href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
</p>
<h3>{info.hoursLabel}</h3>
<p>{info.hours}</p>
<p class="info-lead">{info.intro}</p>
<p class="info-support">Regular walks across the inner-west and nearby suburbs.</p>
<div class="info-suburb-chips" aria-label="Suburbs we cover">
{#each suburbChips as { name, slug }}
{#if slug}
<a class="info-suburb-chip" href="/locations/{slug}">{name}</a>
{:else}
<span class="info-suburb-chip">{name}</span>
{/if}
{/each}
</div>
<div class="info-nearby-card">
<div class="info-nearby-copy">
<span class="info-nearby-kicker">Nearby but not listed?</span>
<p>{info.nearbyText} There's a good chance we can still help.</p>
</div>
<a class="info-nearby-cta" href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
</div>
<div class="info-hours-card">
<h3>{info.hoursLabel}</h3>
<p>{info.hours}</p>
</div>
</div>
<div class="info-block">
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
<div class="faq">
<div use:accordion class="faq">
{#each info.faqs as faq}
<details>
<summary>{faq.question}</summary>
@@ -32,3 +59,124 @@
</div>
</div>
</section>
<style>
.info-lead {
margin-bottom: 8px;
}
.info-support {
margin: 0 0 22px;
color: #5f6369;
}
.info-suburb-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 26px;
}
.info-suburb-chip {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 8px 14px;
border-radius: 999px;
background: #fff;
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 10px 24px rgba(17, 20, 24, 0.04);
color: var(--gw-green);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
}
a.info-suburb-chip:hover {
box-shadow:
inset 0 0 0 1px rgba(33, 48, 33, 0.25),
0 10px 24px rgba(17, 20, 24, 0.08);
}
.info-nearby-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
padding: 22px 24px;
border-radius: 24px;
background: linear-gradient(180deg, #fffaf0 0%, #f8f1e3 100%);
box-shadow: 0 14px 30px rgba(17, 20, 24, 0.05);
}
.info-nearby-copy p {
margin: 6px 0 0;
color: #4c5056;
}
.info-nearby-kicker {
display: inline-block;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.info-nearby-cta {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 18px;
border-radius: 999px;
background: var(--gw-green);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.info-hours-card {
padding-top: 18px;
border-top: 1px dashed rgba(33, 48, 33, 0.18);
}
.info-hours-card h3 {
margin-top: 0;
}
.info-hours-card p {
margin-bottom: 0;
}
@media (max-width: 768px) {
.info-suburb-chips {
gap: 8px;
margin-bottom: 22px;
}
.info-suburb-chip {
min-height: 36px;
padding: 8px 12px;
font-size: 13px;
}
.info-nearby-card {
flex-direction: column;
align-items: flex-start;
padding: 20px 18px;
}
.info-nearby-cta {
width: 100%;
}
}
</style>
+12 -20
View File
@@ -4,14 +4,13 @@
export let instagram: HomePageContent['instagram'];
const dogCutoutSrc = '/images/smiling-dogs-instagram-cta.png';
</script>
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
<div class="instagram-stage">
<div class="instagram-panel">
<div class="instagram-copy">
<span class="instagram-kicker">Daily walks, happy dogs</span>
<span class="eyebrow instagram-kicker">Daily walks, happy dogs</span>
<h2>{instagram.title}</h2>
<p class="instagram-blurb">See our dogs in action — walks, play, and happy pups</p>
<a href={instagram.href} target="_blank" rel="noopener" class="btn btn-green instagram-button">
@@ -22,14 +21,14 @@
</div>
<div class="instagram-dog-wrap" aria-hidden="true">
<img class="instagram-dog" src={dogCutoutSrc} alt="" loading="lazy" decoding="async" />
<enhanced:img src="$lib/images/dog-cutout.png" alt="" class="instagram-dog" loading="lazy" decoding="async" />
</div>
</div>
</aside>
<style>
#instagram {
overflow: visible;
overflow: hidden;
padding-bottom: 40px;
}
@@ -43,7 +42,7 @@
position: relative;
min-height: 150px;
padding: 24px 320px 24px 44px;
border-radius: 24px;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.52), transparent 42%),
linear-gradient(135deg, rgba(255, 252, 242, 0.98), rgba(255, 243, 198, 0.96));
@@ -63,16 +62,9 @@
}
.instagram-kicker {
display: inline-flex;
margin-bottom: 10px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--green);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
/* All visual styling comes from the shared .eyebrow utility. */
display: inline-block;
margin: 0 0 10px;
}
.instagram-copy :global(h2) {
@@ -96,12 +88,12 @@
.instagram-dog-wrap {
position: absolute;
right: 24px;
bottom: 0;
right: -56px;
bottom: -14px;
display: flex;
align-items: flex-end;
justify-content: center;
width: 280px;
width: 380px;
pointer-events: none;
z-index: 0;
}
@@ -156,8 +148,8 @@
.instagram-dog-wrap {
left: 50%;
right: auto;
bottom: -80px;
width: min(260px, calc(100% - 40px));
bottom: -96px;
width: min(300px, calc(100% - 32px));
transform: translateX(-50%);
}
+8 -6
View File
@@ -83,7 +83,7 @@
.legal-card {
padding: 40px 44px;
border-radius: 32px;
border-radius: 28px;
background: #fff;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
}
@@ -101,11 +101,13 @@
.legal-section h2 {
margin: 0 0 16px;
padding-left: 14px;
border-left: 3px solid var(--green);
border-left: 3px solid var(--gw-green);
font-family: var(--font-head);
font-size: clamp(14px, 1.4vw, 17px);
line-height: 1.3;
letter-spacing: -0.01em;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.02em;
text-wrap: balance;
color: #000;
}
@@ -140,7 +142,7 @@
content: '';
position: absolute;
left: 0;
color: var(--green);
color: var(--gw-green);
font-size: 14px;
line-height: 1.9;
}
@@ -172,7 +174,7 @@
.legal-card {
padding: 28px 22px;
border-radius: 24px;
border-radius: 28px;
}
.legal-section + .legal-section {
+771
View File
@@ -0,0 +1,771 @@
<script lang="ts">
import { sharedServices } from '$lib/content/services';
import { reveal } from '$lib/actions/reveal';
import CtaCard from '$lib/components/CtaCard.svelte';
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import { getSeededTestimonialIndex } from '$lib/testimonials';
import type { LocationPageContent, TestimonialContent } from '$lib/types';
export let location: LocationPageContent;
export let testimonials: TestimonialContent[];
type ParkWithImage = LocationPageContent['parks'][number] & {
image: NonNullable<LocationPageContent['parks'][number]['image']>;
enhanced: ReturnType<typeof getEnhancedImage>;
};
$: featuredTestimonial = testimonials[getSeededTestimonialIndex(testimonials, location.slug)];
$: parksWithImages = location.parks
.filter((park): park is LocationPageContent['parks'][number] & { image: NonNullable<LocationPageContent['parks'][number]['image']> } => Boolean(park.image))
.map(
(park): ParkWithImage => ({
...park,
enhanced: getEnhancedImage(park.image.src)
})
);
$: serviceLinks = sharedServices.map((service) => ({
label: service.title,
href: service.href,
desc: service.locationDescription,
icon: service.icon
}));
$: locationHighlights = [
{
icon: 'fas fa-map-location-dot',
label: 'Local routes',
value: `${location.parks.length}+ parks`,
detail: `Regular walking options in and around ${location.suburb}`
},
{
icon: 'fas fa-paw',
label: 'Services',
value: '3 ways to help',
detail: 'Pack walks, 1:1 walks, and puppy visits'
},
{
icon: 'fas fa-van-shuttle',
label: 'Included',
value: 'Free pickup',
detail: 'Pickup and drop-off across the central suburbs'
}
];
</script>
<main class="loc-page">
<!-- ── Hero ── -->
<section class="loc-hero">
<div class="page-inner">
<span class="loc-hero-eyebrow">Auckland Central Dog Walking</span>
<h1>Dog walkers in {location.suburb}</h1>
<p class="loc-hero-desc">{location.intro}</p>
<div class="loc-hero-actions">
<a href="/contact-us" class="btn btn-yellow btn-mobile-center">Book a free Meet &amp; Greet</a>
<a href="tel:+64226421011" class="loc-hero-phone">or call (022) 642 1011</a>
</div>
<div class="loc-hero-chips">
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="loc-chip loc-chip-link"
>
<span class="loc-chip-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
<span class="loc-chip">Small dog specialists</span>
<span class="loc-chip">Free pickup &amp; drop-off</span>
</div>
</div>
</section>
<section class="loc-highlights" aria-label={`Goodwalk highlights in ${location.suburb}`}>
<div class="page-inner">
<div class="loc-highlights-grid">
{#each locationHighlights as highlight}
<div class="loc-highlight-card">
<div class="loc-highlight-top">
<div class="loc-highlight-icon-wrap">
<Icon name={highlight.icon} className="loc-highlight-icon" />
</div>
<span class="loc-highlight-label">{highlight.label}</span>
</div>
<strong>{highlight.value}</strong>
<p>{highlight.detail}</p>
</div>
{/each}
</div>
</div>
</section>
<!-- ── Parks ── -->
<section use:reveal={{ delay: 30 }} class="loc-parks reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">Where we walk</span>
<h2>Parks &amp; walks in {location.suburb}</h2>
<p class="loc-section-intro">
These are the parks and routes we know well in {location.suburb}. Every walk is planned around your dog's pace, size, and temperament — not just the nearest green space.
</p>
</div>
<div class="loc-parks-grid">
{#each location.parks as park}
<div class="loc-park-card">
<div class="loc-park-icon" aria-hidden="true">🐾</div>
<h3>{park.name}</h3>
<p>{park.description}</p>
{#if park.leashNote}
<span class="loc-park-leash">{park.leashNote}</span>
{/if}
</div>
{/each}
</div>
</div>
</section>
{#if parksWithImages.length > 0}
<section use:reveal={{ delay: 30 }} class="loc-gallery reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">Local parks</span>
<h2>Park photos from {location.suburb}</h2>
<p class="loc-section-intro">
Real images from the parks we mention help each suburb page feel more specific and give search engines clearer local context.
</p>
</div>
<div class="loc-gallery-grid">
{#each parksWithImages as park}
<figure class="loc-gallery-card">
{#if park.enhanced}
<picture>
<img src={park.enhanced.img.src} alt={park.image.alt} loading="lazy" decoding="async" />
</picture>
{:else}
<img src={park.image.src} alt={park.image.alt} loading="lazy" decoding="async" />
{/if}
<figcaption>
<strong>{park.name}</strong>
{#if park.image.caption}
<span>{park.image.caption}</span>
{/if}
</figcaption>
</figure>
{/each}
</div>
</div>
</section>
{/if}
<!-- ── Services ── -->
<section use:reveal={{ delay: 30 }} class="loc-services reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">What we offer</span>
<h2>Goodwalk services in {location.suburb}</h2>
<p class="loc-section-intro">
We offer pack walks, 1:1 walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet &amp; Greet so we can understand your dog and recommend the right fit.
</p>
</div>
<div class="loc-services-grid">
{#each serviceLinks as svc}
<a href={svc.href} class="loc-service-card">
<div class="loc-service-icon-bubble">
<Icon name={svc.icon} className="loc-service-icon" />
</div>
<h3>{svc.label}</h3>
<p>{svc.desc}</p>
<span class="loc-service-link">Learn more →</span>
</a>
{/each}
</div>
</div>
</section>
<!-- ── Testimonial ── -->
{#if featuredTestimonial}
<section use:reveal={{ delay: 30 }} class="loc-review reveal-block">
<div class="page-inner">
<div class="loc-review-card">
<span class="loc-review-stars" aria-hidden="true">★★★★★</span>
<blockquote class="loc-review-quote">"{featuredTestimonial.quote}"</blockquote>
<cite class="loc-review-cite">
{featuredTestimonial.reviewer}
{#if featuredTestimonial.detail}
<span class="loc-review-detail">{featuredTestimonial.detail}</span>
{/if}
</cite>
</div>
</div>
</section>
{/if}
<!-- ── CTA ── -->
<section use:reveal={{ delay: 30 }} class="loc-cta reveal-block">
<div class="page-inner">
<CtaCard
title="Ready to get started in {location.suburb}?"
description="A free Meet & Greet is the first step — no commitment, no pressure. We meet your dog, answer your questions, and see if Goodwalk is the right fit."
ctaHref="/contact-us"
ctaLabel="Book a free Meet & Greet"
email="info@goodwalk.co.nz"
phone="(022) 642 1011"
phoneHref="tel:+64226421011"
/>
</div>
</section>
</main>
<style>
.loc-page {
background: var(--off-white);
}
/* ── Eyebrow ── */
.loc-eyebrow,
.loc-hero-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loc-eyebrow {
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.loc-hero-eyebrow {
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
/* ── Hero ── */
.loc-hero {
background: var(--gw-green);
color: #fff;
padding: 80px 0 112px;
text-align: center;
}
.loc-hero h1 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(36px, 5vw, 64px);
font-weight: 800;
line-height: 1.02;
letter-spacing: -0.04em;
color: #fff;
}
.loc-hero-desc {
max-width: 640px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.65;
}
.loc-hero-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.loc-hero-phone {
color: rgba(255, 255, 255, 0.75);
font-size: 15px;
text-decoration: none;
transition: color 0.18s ease;
}
.loc-hero-phone:hover {
color: #fff;
}
.loc-hero-chips {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.loc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
.loc-chip-link {
text-decoration: none;
transition: background 0.18s ease;
}
.loc-chip-link:hover {
background: rgba(255, 255, 255, 0.18);
}
.loc-chip-stars {
color: var(--yellow);
letter-spacing: 1px;
font-size: 13px;
}
/* ── Highlights ── */
.loc-highlights {
margin-top: -56px;
padding: 0 0 88px;
position: relative;
z-index: 2;
}
.loc-highlights-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.loc-highlight-card {
position: relative;
overflow: hidden;
padding: 24px 24px 22px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(255, 209, 71, 0.22), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, #f7f4ec 100%);
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 18px 44px rgba(13, 26, 13, 0.09);
text-align: left;
}
.loc-highlight-card::after {
content: '';
position: absolute;
right: -18px;
bottom: -18px;
width: 96px;
height: 96px;
border-radius: 50%;
background: rgba(33, 48, 33, 0.05);
pointer-events: none;
}
.loc-highlight-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.loc-highlight-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 18px rgba(255, 209, 71, 0.24);
flex: 0 0 auto;
}
:global(.loc-highlight-icon-wrap .loc-highlight-icon) {
color: var(--gw-green);
font-size: 18px;
}
.loc-highlight-label {
display: inline-block;
color: var(--gw-green);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loc-highlight-card strong {
display: block;
margin: 0 0 8px;
color: #0d1a0d;
font-family: var(--font-head);
font-size: clamp(22px, 2.5vw, 28px);
line-height: 1.05;
letter-spacing: -0.03em;
}
.loc-highlight-card p {
margin: 0;
color: #4c5056;
font-size: 14px;
line-height: 1.6;
}
/* ── Section headers ── */
.loc-section-header {
text-align: center;
margin-bottom: 48px;
}
.loc-section-header h2 {
margin: 0 0 12px;
font-family: var(--font-head);
font-size: clamp(26px, 3vw, 38px);
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
color: #0d1a0d;
}
.loc-section-intro {
max-width: 560px;
margin: 0 auto;
color: #4c5056;
font-size: 16px;
line-height: 1.65;
}
/* ── Parks ── */
.loc-parks {
padding: 0 0 88px;
}
.loc-parks-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.loc-park-card {
background: #fff;
border-radius: 20px;
padding: 32px 28px;
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
}
.loc-park-icon {
font-size: 28px;
margin-bottom: 16px;
}
.loc-park-card h3 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #0d1a0d;
}
.loc-park-card p {
margin: 0 0 14px;
color: #4c5056;
font-size: 15px;
line-height: 1.65;
}
.loc-park-leash {
display: inline-block;
padding: 5px 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
color: var(--gw-green);
font-size: 12px;
font-weight: 600;
}
/* ── Gallery ── */
.loc-gallery {
padding: 0 0 88px;
}
.loc-gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.loc-gallery-card {
margin: 0;
overflow: hidden;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 8px 28px rgba(17, 20, 24, 0.06);
}
.loc-gallery-card picture,
.loc-gallery-card img {
display: block;
width: 100%;
}
.loc-gallery-card img {
aspect-ratio: 4 / 3;
object-fit: cover;
}
.loc-gallery-card figcaption {
display: grid;
gap: 6px;
padding: 18px 20px 20px;
}
.loc-gallery-card strong {
color: #0d1a0d;
font-size: 16px;
line-height: 1.35;
}
.loc-gallery-card span {
color: #4c5056;
font-size: 14px;
line-height: 1.6;
}
/* ── Services ── */
.loc-services {
padding: 0 0 88px;
}
.loc-services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.loc-service-card {
display: flex;
flex-direction: column;
padding: 28px 24px;
background: var(--gw-green);
border-radius: 20px;
text-decoration: none;
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
}
@media (hover: hover) {
.loc-highlight-card:hover {
transform: translateY(-3px);
box-shadow: 0 20px 40px rgba(13, 26, 13, 0.12);
}
.loc-park-card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 32px rgba(17, 20, 24, 0.08);
}
.loc-service-card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 36px rgba(33, 48, 33, 0.2);
}
}
.loc-park-card,
.loc-highlight-card,
.loc-gallery-card,
.loc-service-card {
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
}
.loc-service-icon-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 0 20px;
border-radius: 50%;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 24px rgba(17, 20, 24, 0.16);
}
:global(.loc-service-icon-bubble .loc-service-icon) {
color: var(--gw-green);
font-size: 28px;
}
.loc-service-card h3 {
margin: 0 0 8px;
font-family: var(--font-head);
font-size: 20px;
font-weight: 700;
color: #fff;
}
.loc-service-card p {
margin: 0;
flex: 1;
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
line-height: 1.55;
}
.loc-service-link {
display: inline-block;
margin-top: 18px;
color: var(--yellow);
font-size: 14px;
font-weight: 700;
}
/* ── Review ── */
.loc-review {
padding: 0 0 88px;
}
.loc-review-card {
background: #fff;
border-radius: 24px;
padding: 48px 56px;
text-align: center;
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 8px 32px rgba(17, 20, 24, 0.05);
}
.loc-review-stars {
display: block;
color: var(--yellow);
font-size: 20px;
letter-spacing: 3px;
margin-bottom: 20px;
}
.loc-review-quote {
margin: 0 0 20px;
font-family: var(--font-head);
font-size: clamp(18px, 2.2vw, 24px);
font-weight: 600;
line-height: 1.45;
color: #0d1a0d;
font-style: normal;
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
.loc-review-cite {
font-style: normal;
color: var(--gw-green);
font-weight: 700;
font-size: 15px;
}
.loc-review-detail {
font-weight: 400;
color: #888;
margin-left: 4px;
}
/* ── CTA ── */
.loc-cta {
padding: 0 0 88px;
}
/* ── Tablet ── */
@media (max-width: 1024px) {
.loc-highlights-grid,
.loc-parks-grid,
.loc-gallery-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.loc-hero {
padding: 56px 0 48px;
}
.loc-hero h1 {
font-size: 34px;
}
.loc-hero-desc {
font-size: 15px;
}
.loc-hero-actions {
flex-direction: column;
gap: 12px;
}
.loc-highlights {
margin-top: -24px;
padding-bottom: 60px;
}
.loc-highlights-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.loc-highlight-card {
padding: 20px 18px 18px;
}
.loc-parks {
padding: 60px 0;
}
.loc-parks-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.loc-gallery {
padding-bottom: 60px;
}
.loc-gallery-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.loc-services {
padding-bottom: 60px;
}
.loc-services-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.loc-review {
padding-bottom: 60px;
}
.loc-review-card {
padding: 32px 24px;
}
.loc-cta {
padding-bottom: 60px;
}
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import Icon from '$lib/components/Icon.svelte';
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
/*
* Sticky bottom CTA shown on mobile only.
*
* Pattern is the Airbnb-style "soft container, scroll-triggered" —
* - white card sits flush against the bottom edge with a thin top
* hairline and a soft shadow so it reads as a tray, not a banner;
* - the brand-yellow pill CTA lives inside the card so the action
* is unmistakable but the surrounding chrome stays calm;
* - the bar only appears after the user has scrolled roughly one
* viewport (~hero out of view), so it doesn't compete with the
* in-page hero CTA.
*
* Hidden on the contact / booking flows (no point reminding someone
* to book while they're already on the form).
*/
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
$: pathname = $page.url.pathname;
$: hidden =
pathname === '/contact-us' ||
pathname === '/booking' ||
$page.url.hostname === 'onboarding.goodwalk.co.nz' ||
$page.url.searchParams.get('preview') === 'onboarding';
let visible = false;
let triggerPassed = false;
let bookingInView = false;
let triggerObserver: IntersectionObserver | null = null;
let bookingObserver: IntersectionObserver | null = null;
function refreshVisibility() {
visible = !hidden && triggerPassed && !bookingInView;
}
function cleanupObservers() {
triggerObserver?.disconnect();
bookingObserver?.disconnect();
triggerObserver = null;
bookingObserver = null;
}
async function setupObservers() {
if (!mobileCtaButtonEnabled || typeof window === 'undefined') {
return;
}
await tick();
cleanupObservers();
const triggerEl =
document.getElementById('hero') ?? document.querySelector('main section, section');
const bookingEl = document.getElementById('newlead');
triggerPassed = !triggerEl;
bookingInView = false;
if (triggerEl) {
triggerObserver = new IntersectionObserver(
([entry]) => {
triggerPassed = !entry.isIntersecting && entry.boundingClientRect.top < 0;
refreshVisibility();
},
{ threshold: 0.2 }
);
triggerObserver.observe(triggerEl);
}
if (bookingEl) {
bookingObserver = new IntersectionObserver(
([entry]) => {
bookingInView = entry.isIntersecting;
refreshVisibility();
},
{ threshold: 0.2 }
);
bookingObserver.observe(bookingEl);
}
refreshVisibility();
}
afterNavigate(() => {
if (!mobileCtaButtonEnabled) {
return;
}
visible = false;
triggerPassed = false;
bookingInView = false;
void setupObservers();
});
onMount(() => {
if (!mobileCtaButtonEnabled) {
return;
}
void setupObservers();
return () => {
cleanupObservers();
};
});
</script>
{#if mobileCtaButtonEnabled && !hidden}
<div
class="mobile-book-bar"
class:mobile-book-bar-visible={visible}
aria-hidden={!visible}
>
<a class="mobile-book-bar-cta" href="/contact-us" tabindex={visible ? 0 : -1}>
<Icon name="fas fa-paw" />
<span>Book a free Meet &amp; Greet</span>
<Icon name="fas fa-arrow-right" className="mobile-book-bar-arrow" />
</a>
</div>
{/if}
<style>
.mobile-book-bar {
display: none;
}
@media (max-width: 768px) {
.mobile-book-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
justify-content: center;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(17, 20, 24, 0.08);
box-shadow: 0 -10px 28px rgba(17, 20, 24, 0.1);
opacity: 0;
transform: translateY(110%);
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.mobile-book-bar-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.mobile-book-bar-cta {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 460px;
padding: 13px 22px;
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
box-shadow: 0 8px 18px rgba(255, 209, 0, 0.4);
transition:
background 0.18s ease,
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.mobile-book-bar-cta:active {
transform: translateY(1px) scale(0.995);
background: #e6bb00;
}
:global(.mobile-book-bar-cta .icon) {
font-size: 13px;
}
:global(.mobile-book-bar-cta .mobile-book-bar-arrow) {
font-size: 12px;
opacity: 0.75;
}
@media (prefers-reduced-motion: reduce) {
.mobile-book-bar {
transition: opacity 0.22s ease;
transform: none;
}
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
export let onClose: () => void;
export let ariaLabel: string | undefined = undefined;
export let ariaLabelledBy: string | undefined = undefined;
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
on:click|self={onClose}
on:keydown={handleKeydown}
tabindex="-1"
>
<div class="modal-card">
<button class="modal-close" type="button" aria-label="Close" on:click={onClose}>
&#x2715;
</button>
<slot />
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(10, 20, 10, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: backdrop-in 0.25s ease;
}
.modal-card {
position: relative;
width: 100%;
max-width: 480px;
padding: 52px 48px 44px;
background: #fff;
border-radius: 24px;
box-shadow: 0 24px 80px rgba(10, 20, 10, 0.22);
text-align: center;
animation: card-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-close {
position: absolute;
top: 18px;
right: 20px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #f2f2f0;
color: #888;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s ease,
color 0.15s ease;
}
.modal-close:hover {
background: #e8e8e4;
color: #333;
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes card-in {
from { opacity: 0; transform: scale(0.88) translateY(16px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes bounce-in {
from { opacity: 0; transform: scale(0.4); }
to { opacity: 1; transform: scale(1); }
}
@media (max-width: 480px) {
.modal-card {
padding: 44px 28px 32px;
}
}
</style>
+363
View File
@@ -0,0 +1,363 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
export let context: 'onboarding' | 'contract' = 'onboarding';
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, string>; draft: Record<string, unknown> } }>();
const ownerEmail = 'info@goodwalk.co.nz';
const ownerPhone = '(022) 642 1011';
let stage: 'email' | 'code' = 'email';
let emailValue = '';
let codeValue = '';
let loading = false;
let error = '';
async function requestCode() {
const trimmed = emailValue.trim();
if (!trimmed) { error = 'Please enter your email address'; return; }
loading = true;
error = '';
try {
const res = await fetch('/api/auth/request-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmed }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.detail ?? 'Failed to send code. Please try again.');
stage = 'code';
} catch (e) {
error = e instanceof Error ? e.message : 'Something went wrong';
} finally {
loading = false;
}
}
async function verifyCode() {
const trimmed = codeValue.trim();
if (!trimmed) { error = 'Please enter the code'; return; }
loading = true;
error = '';
try {
const res = await fetch('/api/auth/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue.trim(), code: trimmed }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.detail ?? 'Incorrect code. Please try again.');
try { window.localStorage.setItem('gw_onboarding_session', data.token); } catch { /* ignore */ }
let profile: Record<string, string> = {};
let draft: Record<string, unknown> = {};
try {
const verifyRes = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${data.token}` },
});
if (verifyRes.ok) {
const verifyData = await verifyRes.json();
profile = verifyData.profile ?? {};
draft = verifyData.draft ?? {};
}
} catch { /* ignore */ }
dispatch('authenticated', { email: data.email, profile, draft });
} catch (e) {
error = e instanceof Error ? e.message : 'Something went wrong';
} finally {
loading = false;
}
}
function handleEmailKey(e: KeyboardEvent) {
if (e.key === 'Enter') requestCode();
}
function handleCodeKey(e: KeyboardEvent) {
if (e.key === 'Enter') verifyCode();
}
function goBack() {
stage = 'email';
codeValue = '';
error = '';
}
</script>
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-icon">
<Icon name="fas fa-lock" />
</div>
{#if stage === 'email'}
<h2>Sign in to continue</h2>
<p>Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code.</p>
<div class="auth-field">
<label for="auth-email">Email address</label>
<input
id="auth-email"
type="email"
bind:value={emailValue}
on:keydown={handleEmailKey}
placeholder="you@example.com"
autocomplete="email"
disabled={loading}
/>
</div>
{#if error}
<div class="auth-error">{error}</div>
{/if}
<button class="btn btn-yellow auth-btn" on:click={requestCode} disabled={loading}>
{#if loading}Sending…{:else}Send code <Icon name="fas fa-arrow-right" />{/if}
</button>
{:else}
<h2>Enter your code</h2>
<p>We sent a 6-digit code to <strong>{emailValue}</strong>. It expires in 10 minutes.</p>
<div class="auth-field">
<label for="auth-code">One-time code</label>
<input
id="auth-code"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
bind:value={codeValue}
on:keydown={handleCodeKey}
placeholder="123456"
autocomplete="one-time-code"
disabled={loading}
class="auth-code-input"
/>
</div>
{#if error}
<div class="auth-error">{error}</div>
{/if}
<button class="btn btn-yellow auth-btn" on:click={verifyCode} disabled={loading}>
{#if loading}Verifying…{:else}Verify code <Icon name="fas fa-arrow-right" />{/if}
</button>
<button class="auth-back" on:click={goBack}>
<Icon name="fas fa-arrow-left" /> Use a different email
</button>
{/if}
<div class="auth-help">
<span>Need help?</span>
<a href="mailto:{ownerEmail}">{ownerEmail}</a>
<span>or</span>
<a href="tel:{ownerPhone.replace(/[^0-9+]/g, '')}">{ownerPhone}</a>
</div>
</div>
</div>
<footer class="auth-copyright">
<a href="https://goodwalk.co.nz">goodwalk.co.nz</a>
<span>&middot;</span>
<span>&copy; {new Date().getFullYear()} Goodwalk. All rights reserved.</span>
</footer>
<style>
.auth-wrap {
padding: 32px 28px 64px;
display: flex;
justify-content: center;
}
.auth-card {
width: 100%;
max-width: 480px;
padding: 36px 32px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(33, 48, 33, 0.08);
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.09);
display: flex;
flex-direction: column;
gap: 0;
}
.auth-icon {
width: 52px;
height: 52px;
border-radius: 16px;
background: linear-gradient(180deg, #ffe36b 0%, #ffd100 100%);
color: #213021;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 20px;
}
.auth-card h2 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: clamp(22px, 3vw, 30px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.03em;
color: #213021;
}
.auth-card p {
margin: 0 0 24px;
font-size: 15px;
line-height: 1.65;
color: rgba(33, 48, 33, 0.72);
}
.auth-card p strong {
color: #213021;
font-weight: 700;
}
.auth-field {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.auth-field label {
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
letter-spacing: -0.01em;
color: #213021;
}
.auth-field input {
width: 100%;
padding: 15px 16px;
border: 1px solid rgba(33, 48, 33, 0.14);
border-radius: 18px;
background: #fff;
font: inherit;
font-size: 16px;
color: #213021;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
box-sizing: border-box;
}
.auth-field input:focus {
border-color: rgba(255, 209, 0, 0.9);
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16);
}
.auth-code-input {
font-size: 28px !important;
font-family: var(--font-head) !important;
font-weight: 800 !important;
letter-spacing: 0.22em !important;
text-align: center;
}
.auth-error {
margin-bottom: 14px;
padding: 12px 14px;
border-radius: 14px;
background: #fff3ef;
color: #a43f2c;
font-size: 14px;
line-height: 1.5;
}
.auth-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
.auth-back {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(33, 48, 33, 0.12);
background: transparent;
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
color: rgba(33, 48, 33, 0.65);
cursor: pointer;
transition: background 0.15s;
margin-bottom: 20px;
align-self: flex-start;
}
.auth-back:hover {
background: rgba(33, 48, 33, 0.05);
}
.auth-help {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding-top: 20px;
border-top: 1px solid rgba(33, 48, 33, 0.07);
font-size: 13px;
color: rgba(33, 48, 33, 0.5);
}
.auth-help a {
color: rgba(33, 48, 33, 0.75);
font-weight: 600;
text-decoration: none;
}
.auth-help a:hover {
color: #213021;
}
.auth-copyright {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 28px;
background: #fff;
border-top: 1px solid rgba(0, 0, 0, 0.07);
font-size: 12px;
color: #aaa;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
z-index: 10;
}
.auth-copyright a {
color: #888;
text-decoration: none;
font-weight: 600;
}
.auth-copyright a:hover {
color: #555;
}
@media (max-width: 768px) {
.auth-wrap {
padding: 20px 18px 32px;
}
.auth-card {
padding: 26px 20px;
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
export let email = '';
const dispatch = createEventDispatcher<{ logout: void }>();
let loggingOut = false;
async function logout() {
loggingOut = true;
try {
const token = window.localStorage.getItem('gw_onboarding_session') ?? '';
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => { /* ignore network errors on logout */ });
}
} finally {
try { window.localStorage.removeItem('gw_onboarding_session'); } catch { /* ignore */ }
loggingOut = false;
dispatch('logout');
}
}
</script>
<footer class="ob-footer">
<div class="ob-footer-inner">
<a href="https://goodwalk.co.nz" class="ob-footer-back">
<Icon name="fas fa-arrow-left" />
Back to main site
</a>
<button class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
<Icon name="fas fa-right-from-bracket" />
{loggingOut ? 'Signing out…' : 'Sign out'}
</button>
</div>
</footer>
<style>
.ob-footer {
background: #213021;
margin-top: auto;
}
.ob-footer-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 28px;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.ob-footer-back {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.65);
text-decoration: none;
transition: color 0.15s;
}
.ob-footer-back:hover {
color: #fff;
}
.ob-footer-logout {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent;
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.ob-footer-logout:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
@media (max-width: 768px) {
.ob-footer-inner {
padding: 0 18px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from 'svelte';
export let value = '';
export let disabled = false;
let canvas: HTMLCanvasElement;
let isDrawing = false;
let hasSigned = false;
let activePointerId: number | null = null;
let lines: { x: number; y: number }[][] = [];
function resizeCanvas() {
if (!canvas) return;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(rect.width * ratio));
canvas.height = Math.max(1, Math.round(rect.height * ratio));
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
drawAllLines();
syncValue();
}
function getContext() {
return canvas?.getContext('2d') ?? null;
}
function drawAllLines() {
const ctx = getContext();
if (!ctx || !canvas) return;
const width = canvas.width / Math.max(window.devicePixelRatio || 1, 1);
const height = canvas.height / Math.max(window.devicePixelRatio || 1, 1);
ctx.clearRect(0, 0, width, height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#213021';
ctx.lineWidth = 3;
for (const line of lines) {
if (!line.length) continue;
ctx.beginPath();
ctx.moveTo(line[0].x, line[0].y);
if (line.length === 1) {
ctx.lineTo(line[0].x + 0.01, line[0].y + 0.01);
} else {
for (const point of line.slice(1)) {
ctx.lineTo(point.x, point.y);
}
}
ctx.stroke();
}
}
function pointFromEvent(event: PointerEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
function syncValue() {
value = hasSigned && canvas ? canvas.toDataURL('image/png') : '';
}
function startDrawing(event: PointerEvent) {
if (disabled) return;
activePointerId = event.pointerId;
isDrawing = true;
canvas.setPointerCapture(event.pointerId);
const point = pointFromEvent(event);
lines = [...lines, [point]];
hasSigned = true;
drawAllLines();
syncValue();
}
function continueDrawing(event: PointerEvent) {
if (!isDrawing || disabled || activePointerId !== event.pointerId) return;
const point = pointFromEvent(event);
const nextLines = [...lines];
const currentLine = nextLines[nextLines.length - 1];
if (!currentLine) return;
currentLine.push(point);
lines = nextLines;
drawAllLines();
syncValue();
}
function stopDrawing(event?: PointerEvent) {
if (event && activePointerId === event.pointerId && canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
activePointerId = null;
isDrawing = false;
syncValue();
}
export function clear() {
lines = [];
hasSigned = false;
drawAllLines();
syncValue();
}
onMount(() => {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => {
window.removeEventListener('resize', resizeCanvas);
};
});
</script>
<div class:signature-disabled={disabled} class="signature-shell">
<canvas
bind:this={canvas}
class="signature-canvas"
aria-label="Draw your signature"
on:pointerdown={startDrawing}
on:pointermove={continueDrawing}
on:pointerup={stopDrawing}
on:pointerleave={stopDrawing}
on:pointercancel={stopDrawing}
></canvas>
{#if !value}
<div class="signature-hint" aria-hidden="true">Sign here</div>
{/if}
</div>
<style>
.signature-shell {
position: relative;
width: 100%;
min-height: 180px;
border-radius: 18px;
background: #fff;
overflow: hidden;
}
.signature-shell.signature-disabled {
opacity: 0.7;
}
.signature-canvas {
display: block;
width: 100%;
height: 180px;
touch-action: none;
cursor: crosshair;
}
.signature-disabled .signature-canvas {
cursor: not-allowed;
}
.signature-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-head);
font-size: 24px;
color: rgba(33, 48, 33, 0.22);
pointer-events: none;
}
@media (max-width: 768px) {
.signature-shell {
min-height: 160px;
}
.signature-canvas {
height: 160px;
}
.signature-hint {
font-size: 21px;
}
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
export let variant: 'green' | 'white' = 'green';
export let eyebrow: string | undefined = undefined;
export let title: string;
export let subtitle: string | undefined = undefined;
</script>
<section class="page-header page-header--{variant}" class:page-header--has-media={$$slots.media}>
<div class="ph-inner" class:ph-inner--grid={$$slots.media}>
{#if $$slots.media}
<div class="ph-copy">
{#if eyebrow}<p class="eyebrow">{eyebrow}</p>{/if}
<h1 class="ph-title">{title}</h1>
{#if subtitle}<p class="ph-subtitle">{subtitle}</p>{/if}
<slot />
</div>
<div class="ph-media">
<slot name="media" />
</div>
{:else}
{#if eyebrow}<p class="eyebrow">{eyebrow}</p>{/if}
<h1 class="ph-title">{title}</h1>
{#if subtitle}<p class="ph-subtitle">{subtitle}</p>{/if}
<slot />
{/if}
</div>
</section>
+157 -182
View File
@@ -3,20 +3,22 @@
import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte';
import Icon from '$lib/components/Icon.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import PricingPlanCard from '$lib/components/PricingPlanCard.svelte';
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
import { decoratePlans } from '$lib/utils/pricing';
import type { PricingPageContent, SiteSharedContent } from '$lib/types';
export let content: SiteSharedContent;
export let pageContent: PricingPageContent;
const scrollDepthThreshold = 0.65;
const desktopPromptMediaQuery = '(min-width: 769px)';
let showMeetGreetPrompt = false;
let dismissMeetGreetPrompt = false;
let bookingInView = false;
let promptShown = false;
let canShowDesktopPrompt = false;
const desktopPromptMediaQuery = '(min-width: 769px)';
const scrollDepthThreshold = 0.65;
function revealMeetGreetPrompt() {
if (dismissMeetGreetPrompt || bookingInView || promptShown || !canShowDesktopPrompt) {
@@ -90,18 +92,34 @@
</script>
<main class="pricing-page">
<section class="pricing-page-hero">
<div class="pricing-inner">
<h1>{pageContent.title}</h1>
{#if pageContent.subtitle}
<p class="pricing-page-sub">{pageContent.subtitle}</p>
{/if}
</div>
</section>
<PageHeader variant="green" title={pageContent.title} subtitle={pageContent.subtitle}>
<a
class="pricing-trust"
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
aria-label="Read our 5-star Google reviews"
>
<img
class="pricing-trust-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span class="pricing-trust-stars" aria-hidden="true">
{#each Array(5) as _}
<Icon name="fas fa-star" />
{/each}
</span>
<span class="pricing-trust-label">30+ 5-star Google reviews, trusted by Auckland dog owners</span>
<Icon name="fas fa-arrow-right" className="pricing-trust-arrow" />
</a>
</PageHeader>
{#each pageContent.sections as section}
{#each pageContent.sections as section, index}
<section use:reveal class="pricing-section reveal-block">
<div class="pricing-inner">
<div class="page-inner">
<div class="pricing-section-heading">
{#if section.icon}
<div class="pricing-section-icon">
@@ -117,37 +135,45 @@
class={`btn pricing-section-link ${section.detailCta.variant === 'yellow' ? 'btn-yellow' : section.detailCta.variant === 'outline' ? 'btn-outline' : 'btn-green'}`}
href={section.detailCta.href}
>
{section.detailCta.label}
<span>{section.detailCta.label}</span>
<Icon name="fas fa-arrow-right" />
</a>
{/if}
</div>
<div class:pricing-plan-grid-three={section.plans.length === 3} class="pricing-plan-grid">
{#each section.plans as plan}
<article class:pricing-plan-popular={plan.popular} class="pricing-plan-card">
{#if plan.popular}
<span class="pricing-plan-ribbon">Popular</span>
{/if}
<h3>{plan.title}</h3>
<div class="pricing-plan-price">{plan.price}</div>
<p class="pricing-plan-period">{plan.period}</p>
<ul class="pricing-plan-features">
{#each plan.features as feature}
<li>{feature}</li>
{/each}
</ul>
<a class="btn btn-yellow pricing-plan-cta" href="#newlead">Book a Meet &amp; Greet</a>
</article>
{#each decoratePlans(section.plans) as plan}
<PricingPlanCard {plan} variant="pricing" />
{/each}
</div>
<a class="btn btn-yellow btn-mobile-center pricing-section-mobile-cta" href="#newlead">
Book a Meet &amp; Greet
</a>
{#if index === 0}
<aside class="pricing-mobile-consult" aria-label="Need help choosing the right option?">
<span class="pricing-mobile-consult-kicker">
<Icon name="fas fa-comment-dots" />
Not sure which option fits?
</span>
<p>
Book a free Meet &amp; Greet and well help you choose the right walk or visit for your dog.
</p>
<a class="btn btn-outline btn-outline-green btn-mobile-center pricing-mobile-consult-cta" href="#newlead">
Talk it through with us
</a>
</aside>
{/if}
</div>
</section>
{/each}
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
<TestimonialsSection
heading={pageContent.testimonialsHeading}
testimonials={content.testimonials}
seedKey="/our-pricing"
/>
<BookingSection booking={pageContent.booking} />
{#if showMeetGreetPrompt}
@@ -176,32 +202,49 @@
background: var(--off-white);
}
.pricing-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
}
.pricing-page-hero {
background: var(--green);
padding: 56px 0 64px;
text-align: center;
}
.pricing-page-hero h1 {
margin: 0 0 12px;
font-family: var(--font-head);
font-size: clamp(34px, 4vw, 56px);
line-height: 1.05;
letter-spacing: -0.04em;
.pricing-trust {
display: inline-flex;
align-items: center;
gap: 12px;
margin-top: 22px;
padding: 9px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
text-decoration: none;
transition:
background 0.2s ease,
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.pricing-page-sub {
margin: 0;
font-size: 16px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
.pricing-trust-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.pricing-trust:hover {
background: rgba(255, 255, 255, 0.18);
transform: translateY(-1px);
}
.pricing-trust-stars {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--yellow);
font-size: 13px;
}
:global(.pricing-trust .pricing-trust-arrow) {
font-size: 12px;
opacity: 0.85;
}
.pricing-section-heading h2 {
@@ -209,8 +252,10 @@
text-align: center;
font-family: var(--font-head);
font-size: clamp(24px, 2.8vw, 36px);
line-height: 1.1;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
color: #000;
}
@@ -231,7 +276,7 @@
height: 56px;
margin-bottom: 16px;
border-radius: 16px;
background: var(--green);
background: var(--gw-green);
color: #fff;
font-size: 22px;
}
@@ -251,6 +296,9 @@
}
.pricing-section-link {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 18px;
}
@@ -258,85 +306,9 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pricing-plan-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
background: #fff;
border-radius: 28px;
padding: 30px 26px;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
border-color 0.22s ease;
}
.pricing-plan-popular {
border: 2px solid var(--yellow);
}
.pricing-plan-ribbon {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
padding: 6px 12px;
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pricing-plan-card h3 {
margin: 0;
font-family: var(--font-head);
font-size: 22px;
line-height: 1.2;
color: #000;
}
.pricing-plan-price {
margin-top: 22px;
font-family: var(--font-head);
font-size: 52px;
line-height: 0.95;
letter-spacing: -0.05em;
color: #000;
}
.pricing-plan-period {
margin: 10px 0 0;
color: #5e6167;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pricing-plan-features {
width: 100%;
margin: 24px 0 0;
padding: 0;
list-style: none;
}
.pricing-plan-features li {
padding: 15px 0;
border-top: 1px solid rgba(17, 20, 24, 0.08);
color: #34363a;
font-size: 16px;
line-height: 1.5;
}
.pricing-plan-cta {
margin-top: 24px;
font-family: var(--font-head);
.pricing-section-mobile-cta,
.pricing-mobile-consult {
display: none;
}
.meet-greet-prompt {
@@ -349,7 +321,7 @@
gap: 18px;
width: min(420px, calc(100vw - 32px));
padding: 18px 18px 18px 20px;
border-radius: 24px;
border-radius: 28px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 243, 232, 0.98));
box-shadow:
@@ -372,7 +344,7 @@
padding: 6px 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--green);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
letter-spacing: 0.04em;
@@ -458,31 +430,6 @@
}
}
@media (hover: hover) {
.pricing-plan-card:hover {
transform: translateY(-8px) scale(1.012);
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
}
}
.pricing-plan-card:active {
transform: translateY(-2px) scale(0.992);
}
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
transition:
opacity 0.55s ease,
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--reveal-delay, 0ms);
}
:global(.reveal-visible.reveal-block) {
opacity: 1;
transform: translate3d(0, 0, 0);
}
@media (max-width: 1024px) {
.pricing-plan-grid,
.pricing-plan-grid-three {
@@ -491,16 +438,10 @@
}
@media (max-width: 768px) {
.pricing-inner {
padding: 0 24px;
}
.pricing-page-hero {
padding: 56px 0 20px;
}
.pricing-page-hero h1 {
font-size: 34px;
.pricing-trust {
gap: 10px;
padding: 10px 14px;
font-size: 13px;
}
.pricing-section-heading h2 {
@@ -512,7 +453,7 @@
}
.pricing-section-heading {
margin-bottom: 20px;
margin-bottom: 26px;
}
.pricing-section-blurb {
@@ -520,22 +461,56 @@
line-height: 1.55;
}
.pricing-section-link {
margin-top: 22px;
margin-bottom: 8px;
}
.pricing-plan-grid,
.pricing-plan-grid-three {
grid-template-columns: 1fr;
gap: 18px;
}
.pricing-plan-popular {
order: -1;
.pricing-section-mobile-cta {
display: flex;
width: fit-content;
margin: 18px auto 0;
font-family: var(--font-head);
}
.pricing-plan-card {
padding: 28px 22px;
.pricing-mobile-consult {
display: block;
margin-top: 18px;
padding: 22px 20px;
border-radius: 24px;
background: linear-gradient(180deg, #fffaf0 0%, #f9f4e7 100%);
box-shadow: 0 12px 28px rgba(17, 20, 24, 0.05);
text-align: left;
}
.pricing-plan-price {
font-size: 46px;
.pricing-mobile-consult-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pricing-mobile-consult p {
margin: 0;
color: #34363a;
font-size: 15px;
line-height: 1.55;
}
.pricing-mobile-consult-cta {
margin-top: 16px;
}
.meet-greet-prompt {
@@ -547,7 +522,7 @@
align-items: stretch;
gap: 14px;
padding: 18px 18px 16px;
border-radius: 20px;
border-radius: 28px;
}
.meet-greet-copy p {
+224
View File
@@ -0,0 +1,224 @@
<script lang="ts">
export let plan: {
title: string;
price: string;
period: string;
features: string[];
isPopular: boolean;
mobileOrder: number;
};
export let variant: 'pricing' | 'service' = 'service';
</script>
<article
class="plan-card"
class:plan-card--popular={plan.isPopular}
class:plan-card--pricing={variant === 'pricing'}
class:plan-card--service={variant === 'service'}
style="--mobile-order:{plan.mobileOrder};"
>
{#if plan.isPopular}
<span class="plan-card__ribbon">Popular</span>
{/if}
<h3>{plan.title}</h3>
<div class="plan-card__price">{plan.price}</div>
<p class="plan-card__period">{plan.period}</p>
<ul class="plan-card__features">
{#each plan.features as feature}
<li>{feature}</li>
{/each}
</ul>
<a class="btn btn-yellow plan-card__cta" href="#newlead">Book a Meet &amp; Greet</a>
</article>
<style>
/* ── Base ── */
.plan-card {
position: relative;
display: flex;
flex-direction: column;
border-radius: 28px;
padding: 30px 26px;
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
border-color 0.22s ease;
}
.plan-card--popular {
border: 2px solid var(--yellow);
}
/* ── Service variant ── */
.plan-card--service {
align-items: stretch;
height: 100%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(247, 248, 246, 0.98) 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.045),
0 8px 40px rgba(0, 0, 0, 0.06);
}
/* ── Pricing variant ── */
.plan-card--pricing {
align-items: center;
text-align: center;
background: #fff;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
}
/* ── Ribbon ── */
.plan-card__ribbon {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
padding: 6px 12px;
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* ── Heading ── */
.plan-card h3 {
margin: 0;
font-family: var(--font-head);
font-size: 22px;
line-height: 1.2;
color: #000;
}
/* ── Price ── */
.plan-card__price {
font-family: var(--font-head);
line-height: 1;
}
.plan-card--service .plan-card__price {
margin-top: 20px;
font-size: 44px;
color: var(--gw-green);
}
.plan-card--pricing .plan-card__price {
margin-top: 22px;
font-size: 52px;
line-height: 0.95;
letter-spacing: -0.05em;
color: #000;
}
/* ── Period ── */
.plan-card__period {
margin: 8px 0 0;
font-size: 14px;
text-transform: uppercase;
}
.plan-card--service .plan-card__period {
color: #5d6166;
font-weight: 600;
letter-spacing: 0.06em;
}
.plan-card--pricing .plan-card__period {
color: #5e6167;
letter-spacing: 0.08em;
}
/* ── Features ── */
.plan-card__features {
margin: 24px 0 0;
padding: 0;
list-style: none;
}
.plan-card--service .plan-card__features {
flex: 1 1 auto;
}
/* Service: bullet style */
.plan-card--service .plan-card__features li {
position: relative;
padding-left: 24px;
color: #34363a;
font-size: 15px;
line-height: 1.5;
}
.plan-card--service .plan-card__features li + li {
margin-top: 12px;
}
.plan-card--service .plan-card__features li::before {
content: '•';
position: absolute;
left: 6px;
top: 0;
color: var(--yellow);
font-size: 20px;
line-height: 1;
}
/* Pricing: divider style */
.plan-card--pricing .plan-card__features {
width: 100%;
}
.plan-card--pricing .plan-card__features li {
padding: 15px 0;
border-top: 1px solid rgba(17, 20, 24, 0.08);
color: #34363a;
font-size: 16px;
line-height: 1.5;
}
/* ── CTA ── */
.plan-card__cta {
display: flex;
width: fit-content;
margin: 28px auto 0;
font-family: var(--font-head);
}
/* ── Hover ── */
@media (hover: hover) {
.plan-card--service:hover {
transform: translateY(-2px);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.055),
0 10px 40px rgba(0, 0, 0, 0.08);
filter: brightness(1.015);
}
.plan-card--pricing:hover {
transform: translateY(-6px) scale(1.012);
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
}
}
.plan-card:active {
transform: translateY(-2px) scale(0.992);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.plan-card {
order: var(--mobile-order, 0);
padding: 28px 22px;
}
.plan-card--pricing .plan-card__price {
font-size: 46px;
}
.plan-card__cta {
display: none;
}
}
</style>
-41
View File
@@ -1,41 +0,0 @@
<script lang="ts">
import { getImageMetadata } from '$lib/image-metadata';
import type { PromiseContent } from '$lib/types';
export let promise: PromiseContent;
$: promiseImage = getImageMetadata(promise.imageUrl);
</script>
<section id="promise">
<div class="promise-inner">
<div class="promise-text">
<h2>
{promise.title}<br />
{promise.subtitle}
</h2>
{#each promise.body as paragraph, idx}
<p>
{paragraph}
{#if idx === promise.body.length - 1}
<strong>{promise.emphasis}</strong>
{/if}
</p>
{/each}
<a href={promise.cta.href} class="btn btn-green">{promise.cta.label}</a>
</div>
<div class="promise-img">
<img
src={promise.imageUrl}
alt={promise.imageAlt}
width={promiseImage?.width}
height={promiseImage?.height}
loading="lazy"
decoding="async"
/>
</div>
</div>
</section>
+5 -3
View File
@@ -10,6 +10,7 @@
export let structuredData: Record<string, unknown>[] = [];
export let noindex = false;
export let preloadImage = false;
export let preloadImageUrl = ''; // explicit URL to preload (defaults to the og:image)
const siteName = 'Goodwalk';
const siteUrl = 'https://www.goodwalk.co.nz';
@@ -34,6 +35,7 @@
$: canonicalUrl = absoluteUrl(canonicalPath);
$: imageUrl = absoluteUrl(image);
$: imageMeta = getImageMetadata(image);
$: resolvedPreloadUrl = preloadImageUrl || image;
</script>
<svelte:head>
@@ -49,10 +51,10 @@
<meta name="publisher" content="Goodwalk" />
<meta name="geo.region" content="NZ-AUK" />
<meta name="geo.placename" content="Auckland Central" />
<link rel="canonical" href={canonicalUrl} />
{#if preloadImage}
<link rel="preload" as="image" href={imageUrl} />
{#if preloadImage && resolvedPreloadUrl}
<link rel="preload" as="image" href={resolvedPreloadUrl} fetchpriority="high" />
{/if}
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hreflang="en-NZ" href={canonicalUrl} />
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
+293
View File
@@ -0,0 +1,293 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { CallToAction, HeroChip } from '$lib/types';
export let eyebrow: string;
export let title: string;
export let subtitle: string | undefined = undefined;
export let imageUrl: string;
export let imageAlt: string;
export let chips: HeroChip[] = [];
export let cta: CallToAction | undefined = undefined;
const reviewHref = 'https://g.page/r/CUsvrWPhkYrAEB0/';
$: enhanced = getEnhancedImage(imageUrl);
</script>
<section class="sh">
<!-- Left: brand green copy column -->
<div class="sh-copy">
<p class="sh-eyebrow">{eyebrow}</p>
<h1 class="sh-title">{title}</h1>
{#if subtitle}
<p class="sh-subtitle">{subtitle}</p>
{/if}
{#if chips.length}
<div class="sh-chips">
{#each chips as chip}
<span class="sh-chip">
<Icon name={chip.icon} />
{chip.label}
</span>
{/each}
</div>
{/if}
<div class="sh-actions">
{#if cta}
<a href={cta.href} class="btn btn-yellow sh-cta">{cta.label}</a>
{/if}
<a
href={reviewHref}
class="sh-trust"
target="_blank"
rel="noopener"
aria-label="Read our Google reviews"
>
<span class="sh-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
</div>
</div>
<!-- Right: full-height photo, no card, no shadow, bleeds to viewport edge -->
<div class="sh-media">
{#if enhanced}
<enhanced:img
src={enhanced}
alt={imageAlt}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{:else}
<img
src={imageUrl}
alt={imageAlt}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{/if}
</div>
</section>
<style>
/* ── Full-bleed split — green bleeds left, photo bleeds right, content stays centred ── */
.sh {
display: grid;
grid-template-columns: 1fr 1fr;
position: relative;
z-index: 1;
overflow: hidden;
}
/* ── Copy column ──
Left padding uses --sh-copy-left-pad so ultrawide overrides can live
entirely in responsive.css without touching this component. ── */
.sh-copy {
background: var(--gw-green);
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 80px 56px 80px var(--sh-copy-left-pad, max(40px, calc(50vw - 596px)));
}
/* Subtle yellow warmth on the copy side */
.sh-copy::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 90% 80% at 5% 65%, rgba(255, 209, 0, 0.09) 0%, transparent 70%);
pointer-events: none;
}
/* Service name in Goodwalk Yellow */
.sh-eyebrow {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yellow);
}
.sh-title {
margin: 0 0 14px;
font-family: var(--font-head);
font-size: clamp(30px, 3.2vw, 50px);
font-weight: 800;
line-height: 1.04;
letter-spacing: -0.04em;
color: #fff;
text-wrap: balance;
}
.sh-subtitle {
margin: 0 0 26px;
font-size: 16px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.68);
max-width: 38ch;
}
/* ── Chips ── */
.sh-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 30px;
}
.sh-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
/* ── CTA row ── */
.sh-actions {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.sh-cta {
font-size: 15px;
padding: 12px 24px;
}
.sh-trust {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
font-weight: 600;
text-decoration: none;
line-height: 1.3;
transition: color 0.18s ease;
}
.sh-trust:hover {
color: rgba(255, 255, 255, 0.9);
}
.sh-stars {
color: var(--yellow);
letter-spacing: 2px;
font-size: 12px;
flex: 0 0 auto;
}
/* ── Photo column — fills full height, bleeds to right viewport edge ── */
.sh-media {
position: relative;
overflow: hidden;
/* Minimum height so the section never collapses on short copy */
min-height: 480px;
}
.sh-media::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: clamp(28px, 4.5vw, 76px);
background:
radial-gradient(circle at left center, rgba(255, 209, 0, 0.1) 0%, rgba(255, 209, 0, 0.04) 26%, transparent 62%),
linear-gradient(90deg, rgba(33, 48, 33, 0.76) 0%, rgba(33, 48, 33, 0.34) 46%, rgba(33, 48, 33, 0.08) 78%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.sh-media :global(picture) {
position: absolute;
inset: 0;
display: block;
}
.sh-media :global(img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 25%;
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
}
.sh-media:hover :global(img) {
transform: scale(1.04);
}
/* ── Tablet — formula already floors at 40px here, just reduce vertical padding ── */
@media (max-width: 1024px) {
.sh-copy {
padding-top: 64px;
padding-bottom: 64px;
}
}
/* ── Mobile — stack vertically, photo above copy ── */
@media (max-width: 768px) {
.sh {
grid-template-columns: 1fr;
}
/* Photo goes first on mobile — visual hook before the pitch */
.sh-media {
order: 1;
min-height: 0;
aspect-ratio: 3 / 2;
position: relative;
}
.sh-media::before {
width: 0;
}
.sh-copy {
order: 2;
padding: 44px 24px 48px;
}
.sh-title {
font-size: clamp(28px, 7.5vw, 38px);
}
.sh-subtitle {
font-size: 15px;
}
.sh-actions {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.sh-cta {
width: 100%;
justify-content: center;
}
}
</style>
File diff suppressed because it is too large Load Diff
+72 -3
View File
@@ -5,12 +5,35 @@
export let services: IconCard[];
export let heading = 'What we do';
export let intro =
'Choose the walk style that fits your dog best, then book a free Meet & Greet when you are ready.';
const requestedServiceStorageKey = 'goodwalk_requested_service';
function bookingHref() {
return '#newlead';
}
function primeBookingService(serviceTitle: string) {
try {
window.sessionStorage.setItem(requestedServiceStorageKey, serviceTitle);
} catch {
// Ignore storage failures and continue with the link target.
}
window.dispatchEvent(
new CustomEvent('goodwalk:service-selected', {
detail: { service: serviceTitle }
})
);
}
</script>
<section id="services" use:reveal={{ delay: 20 }} class="reveal-block">
<div class="services-inner">
<h2 class="section-heading">{heading}</h2>
<p class="services-intro">{intro}</p>
<div class="services-grid">
{#each services as service}
@@ -26,9 +49,16 @@
{/if}
{#if service.href}
<a href={service.href} class="btn btn-green">
Learn more<span class="visually-hidden"> about {service.title}</span>
</a>
<div class="service-card-actions">
<a href={bookingHref()} class="btn btn-green" on:click={() => primeBookingService(service.title)}>
<span>Book {service.title}</span>
<Icon name="fas fa-arrow-right" />
</a>
<a href={service.href} class="service-card-link">
View details &amp; pricing
</a>
</div>
{/if}
</div>
{/each}
@@ -80,4 +110,43 @@
transform: translateY(0);
}
}
.services-intro {
max-width: 700px;
margin: 18px auto 0;
text-align: center;
color: #4c5056;
font-size: 17px;
line-height: 1.65;
}
.service-card-actions {
display: grid;
gap: 12px;
margin-top: 18px;
}
.service-card-actions :global(.btn) {
width: 100%;
justify-content: center;
}
.service-card-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
color: var(--gw-green);
font-size: 14px;
font-weight: 700;
text-decoration: none;
}
@media (hover: hover) {
.service-card-link:hover {
text-decoration: underline;
text-underline-offset: 0.18em;
}
}
</style>
+8 -83
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import confetti from 'canvas-confetti';
import ModalShell from '$lib/components/ModalShell.svelte';
export let firstName: string;
export let petName: string;
export let email: string;
export let enquiryType: 'booking' | 'general' = 'booking';
export let onClose: () => void;
const gwGreenHex = '#213021';
$: isGeneralEnquiry = enquiryType === 'general';
@@ -20,7 +22,7 @@
angle: 60,
spread: 65,
origin: { x: 0, y: 0.75 },
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
colors: ['#FFD100', gwGreenHex, '#ffffff', '#7aaa7a', '#ffeaa0'],
gravity: 0.9,
scalar: 1.1,
});
@@ -29,7 +31,7 @@
angle: 120,
spread: 65,
origin: { x: 1, y: 0.75 },
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
colors: ['#FFD100', gwGreenHex, '#ffffff', '#7aaa7a', '#ffeaa0'],
gravity: 0.9,
scalar: 1.1,
});
@@ -43,20 +45,7 @@
});
</script>
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-label={isGeneralEnquiry ? 'Enquiry confirmed' : 'Booking confirmed'}
on:click|self={onClose}
on:keydown={(e) => e.key === 'Escape' && onClose()}
tabindex="-1"
>
<div class="modal-card">
<button class="modal-close" type="button" aria-label="Close" on:click={onClose}>
&#x2715;
</button>
<ModalShell {onClose} ariaLabel={isGeneralEnquiry ? 'Enquiry confirmed' : 'Booking confirmed'}>
<div class="modal-paw" aria-hidden="true">🐾</div>
<h2 class="modal-heading">
@@ -93,59 +82,9 @@
<button class="modal-btn" type="button" on:click={onClose}>
Sounds great!
</button>
</div>
</div>
</ModalShell>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(10, 20, 10, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: backdrop-in 0.25s ease;
}
.modal-card {
position: relative;
width: 100%;
max-width: 480px;
padding: 52px 48px 44px;
background: #fff;
border-radius: 24px;
box-shadow: 0 24px 80px rgba(10, 20, 10, 0.22);
text-align: center;
animation: card-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-close {
position: absolute;
top: 18px;
right: 20px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #f2f2f0;
color: #888;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease;
}
.modal-close:hover {
background: #e8e8e4;
color: #333;
}
.modal-paw {
font-size: 52px;
line-height: 1;
@@ -157,7 +96,7 @@
margin: 0 0 14px;
font-size: 26px;
font-weight: 700;
color: #213021;
color: var(--gw-green);
line-height: 1.2;
}
@@ -186,7 +125,7 @@
.modal-btn {
display: inline-block;
padding: 14px 36px;
background: #213021;
background: var(--gw-green);
color: #FFD100;
font-size: 15px;
font-weight: 600;
@@ -205,18 +144,4 @@
transform: translateY(0);
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes card-in {
from { opacity: 0; transform: scale(0.88) translateY(16px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes bounce-in {
from { opacity: 0; transform: scale(0.4); }
to { opacity: 1; transform: scale(1); }
}
</style>
+202 -55
View File
@@ -1,16 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import { getEnhancedImage } from '$lib/enhanced-images';
import { getSeededTestimonialIndex } from '$lib/testimonials';
import type { TestimonialContent } from '$lib/types';
export let testimonials: TestimonialContent[];
export let heading = 'Why people choose us!';
export let blurb =
"Happy owners, even happier dogs. Our Auckland dog walking clients love what the Tiny Gang brings to their dog's routine and you can see why. Follow along on Instagram for daily adventures, wagging tails and the odd zoomie";
export let eyebrow = '30+ five-star reviews';
export let heading = 'Proof your dog is in good hands';
export let blurb = 'Peace of mind for busy Auckland dog owners. Happier dogs, smoother routines, and a team owners trust with the important stuff.';
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
export let instagramLabel = 'goodwalk.nz';
export let seedKey = '';
type TestimonialSlide = TestimonialContent & { imageUrl: string };
@@ -20,28 +22,28 @@
detail: "Archie's mum",
quote:
'Love Aless! She is so amazing with my slightly hyper and anxious dog. She is great with communication if anything on either of our ends need to change. Archie love his walks, and I love the photos she posts of him.',
imageUrl: '/images/archie-auckland-dog-walking-review.png'
imageUrl: '/images/archie-auckland-dog-walking-review.jpg'
},
Estelle: {
reviewer: 'Estelle',
detail: "Monty's mum",
quote:
'GoodWalk was the best dog walking service for my little pooch ! Aless was very helpful - basically doubled as a second mum to Monty. She always provided feedback on his outings and assisted where possible with any additional training that she felt he could work on and made recommendations where necessary which i feel is what every dog mum wants and needs!',
imageUrl: '/images/monty-auckland-dog-walking-review.png'
imageUrl: '/images/monty-auckland-dog-walking-review.jpg'
},
Ross: {
reviewer: 'Ross',
detail: "Otis's Dad",
detail: "Otis's dad",
quote:
'Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, theres a reason she has 5 stars!',
imageUrl: '/images/otis-auckland-dog-walking-review.png'
imageUrl: '/images/otis-auckland-dog-walking-review.jpg'
},
Nina: {
reviewer: 'Nina',
detail: "Wallace's mum",
quote:
'Alessandra has been walking and spending time with my pup since she was 10 weeks old, coming over and doing puppy visits through to transitioning her to pack walks with her little doggo friends. I know Alassandra loves and cares for my dog as much as I do and my dog has a great time! Cant recommend enough',
imageUrl: '/images/wallace-auckland-dog-walking-review.png'
imageUrl: '/images/wallace-auckland-dog-walking-review.jpg'
}
};
@@ -50,6 +52,8 @@
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
let stageEl: HTMLDivElement | undefined;
let slideSignature = '';
$: slides = testimonials
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
@@ -59,6 +63,15 @@
activeIndex = 0;
}
$: {
const nextSignature = `${seedKey}:${slides.map((slide) => slide.reviewer).join('|')}`;
if (nextSignature !== slideSignature) {
slideSignature = nextSignature;
activeIndex = getSeededTestimonialIndex(slides, seedKey);
}
}
function dogNameFromDetail(detail: string) {
const match = detail.match(/^([^']+)/);
return match ? match[1].trim() : '';
@@ -77,6 +90,7 @@
}
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
syncMobileStage();
}
function showNext() {
@@ -85,6 +99,34 @@
}
activeIndex = (activeIndex + 1) % slides.length;
syncMobileStage();
}
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 767;
}
async function syncMobileStage(behavior: ScrollBehavior = 'smooth') {
if (!stageEl || !isMobileViewport()) {
return;
}
await tick();
stageEl.scrollTo({
left: stageEl.clientWidth * activeIndex,
behavior
});
}
function handleStageScroll() {
if (!stageEl || !isMobileViewport()) {
return;
}
const nextIndex = Math.round(stageEl.scrollLeft / Math.max(stageEl.clientWidth, 1));
if (nextIndex !== activeIndex) {
activeIndex = nextIndex;
}
}
onMount(() => {
@@ -108,6 +150,13 @@
observer.observe(carouselEl);
}
const handleResize = () => {
syncMobileStage('auto');
};
window.addEventListener('resize', handleResize);
syncMobileStage('auto');
const interval = window.setInterval(() => {
if (!paused && !prefersReducedMotion && inView && slides.length > 1) {
showNext();
@@ -116,6 +165,7 @@
return () => {
window.clearInterval(interval);
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', onMotionChange);
observer?.disconnect();
};
@@ -124,13 +174,10 @@
<section id="testimonials" use:reveal={{ delay: 40 }} class="reveal-block">
<div class="testimonials-inner">
<span class="testimonials-eyebrow">{eyebrow}</span>
<h2 class="section-heading">{heading}</h2>
<div class="testimonials-intro">
<p>{blurb}</p>
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
<Icon name="fab fa-instagram" />
<span>{instagramLabel}</span>
</a>
</div>
{#if slides.length}
@@ -153,7 +200,7 @@
<Icon name="fas fa-chevron-left" />
</button>
<div class="testimonial-stage">
<div bind:this={stageEl} class="testimonial-stage" on:scroll={handleStageScroll}>
<div class="testimonial-woof" aria-hidden="true">
<span class="testimonial-woof-text">WOOF</span>
<span class="testimonial-ray testimonial-ray-1"></span>
@@ -166,16 +213,24 @@
<div class="testimonial-photo-wrap">
<div class="testimonial-photo-frame">
{#if index === activeIndex}
{@const imageMeta = getImageMetadata(testimonial.imageUrl)}
<img
class="testimonial-photo"
src={testimonial.imageUrl}
alt={testimonialAlt(testimonial)}
width={imageMeta?.width}
height={imageMeta?.height}
loading="lazy"
decoding="async"
/>
{@const enhancedPhoto = getEnhancedImage(testimonial.imageUrl)}
{#if enhancedPhoto}
<enhanced:img
class="testimonial-photo"
src={enhancedPhoto}
alt={testimonialAlt(testimonial)}
loading="lazy"
decoding="async"
/>
{:else}
<img
class="testimonial-photo"
src={testimonial.imageUrl}
alt={testimonialAlt(testimonial)}
loading="lazy"
decoding="async"
/>
{/if}
{/if}
</div>
</div>
@@ -190,14 +245,40 @@
<div class="testimonial-divider"></div>
<div class="testimonial-mobile-controls" aria-label="Testimonial navigation">
<button
class="testimonial-arrow testimonial-arrow-inline"
type="button"
aria-label="Previous testimonial"
on:click={showPrevious}
>
<Icon name="fas fa-chevron-left" />
</button>
<button
class="testimonial-arrow testimonial-arrow-inline"
type="button"
aria-label="Next testimonial"
on:click={showNext}
>
<Icon name="fas fa-chevron-right" />
</button>
</div>
<a
class="testimonial-google"
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
>
<Icon name="fab fa-google" />
<span>All 5 star reviews on Google!</span>
<img
class="testimonial-google-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span>30+ five-star Google reviews</span>
</a>
</div>
</article>
@@ -214,10 +295,30 @@
</button>
</div>
{/if}
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
<Icon name="fab fa-instagram" />
<span>{instagramLabel}</span>
</a>
</div>
</section>
<style>
.testimonials-eyebrow {
display: block;
width: fit-content;
margin: 0 auto 10px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.testimonials-intro {
max-width: 760px;
margin: 18px auto 0;
@@ -232,14 +333,15 @@
}
.testimonials-instagram-link {
display: inline-flex;
display: flex;
width: fit-content;
align-items: center;
gap: 10px;
margin-top: 18px;
margin: 18px auto 0;
padding: 10px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
color: var(--gw-green);
font-weight: 700;
text-decoration: none;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
@@ -270,9 +372,16 @@
.testimonials-carousel {
position: relative;
margin-top: 48px;
padding: 0 38px;
}
@media (max-width: 768px) {
.testimonials-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
font-size: 11px;
}
.testimonials-intro {
margin-top: 14px;
}
@@ -283,7 +392,7 @@
}
.testimonials-instagram-link {
margin-top: 14px;
margin: 14px auto 0;
padding: 9px 14px;
font-size: 15px;
}
@@ -326,7 +435,7 @@
.testimonial-stage {
position: relative;
overflow: hidden;
border-radius: 24px;
border-radius: 28px;
background: #fff;
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
min-height: 620px;
@@ -438,19 +547,28 @@
border-radius: 999px;
background: #f8f8f8;
color: #0a304e;
font-family: var(--font-head);
font-size: 14px;
line-height: 1.3;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
}
.testimonial-google :global(.icon) {
font-size: 20px;
.testimonial-google-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.testimonial-google:hover {
background: #efe6d5;
}
.testimonial-mobile-controls {
display: none;
}
.testimonial-woof {
position: absolute;
top: 40px;
@@ -463,7 +581,7 @@
.testimonial-woof-text {
display: inline-block;
font-family: 'Fredoka One', var(--font-head), sans-serif;
font-family: 'Fredoka', 'Fredoka One', var(--font-head), sans-serif;
font-size: 32px;
line-height: 1;
letter-spacing: 0.02em;
@@ -522,11 +640,11 @@
}
.testimonial-arrow-left {
left: -38px;
left: 0;
}
.testimonial-arrow-right {
right: -38px;
right: 0;
}
@media (max-width: 1024px) {
@@ -555,18 +673,33 @@
@media (max-width: 767px) {
.testimonials-carousel {
margin-top: 32px;
padding: 0;
}
.testimonial-stage {
min-height: unset;
padding-bottom: 116px;
display: flex;
padding-bottom: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.testimonial-stage::-webkit-scrollbar {
display: none;
}
.testimonial-slide {
position: relative;
display: none;
display: grid;
flex: 0 0 100%;
grid-template-columns: 1fr;
opacity: 1;
pointer-events: auto;
transform: none;
scroll-snap-align: start;
}
.testimonial-slide-active {
@@ -605,8 +738,34 @@
margin-top: 28px;
}
.testimonial-mobile-controls {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: 100%;
gap: 12px;
margin-top: 20px;
}
.testimonial-arrow-inline {
position: static;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 18px;
transform: none;
box-shadow: none;
}
.testimonial-arrow-inline:active {
transform: scale(0.95);
}
.testimonial-google {
margin-top: 28px;
margin-top: 20px;
font-size: 16px;
gap: 10px;
padding: 10px 14px;
@@ -649,21 +808,9 @@
height: 8px;
}
.testimonial-arrow {
top: auto;
bottom: 24px;
width: 54px;
height: 54px;
font-size: 20px;
transform: none;
}
.testimonial-arrow-left {
left: 20px;
}
.testimonial-arrow-left,
.testimonial-arrow-right {
right: 20px;
display: none;
}
}
</style>
+28 -13
View File
@@ -5,10 +5,10 @@ import { homepageContent } from '$lib/content/homepage';
import type { TestimonialContent } from '$lib/types';
const expectedMappedSlides = [
{ reviewer: 'Kate', src: '/images/archie-auckland-dog-walking-review.png' },
{ reviewer: 'Estelle', src: '/images/monty-auckland-dog-walking-review.png' },
{ reviewer: 'Ross', src: '/images/otis-auckland-dog-walking-review.png' },
{ reviewer: 'Nina', src: '/images/wallace-auckland-dog-walking-review.png' }
{ reviewer: 'Kate' },
{ reviewer: 'Estelle' },
{ reviewer: 'Ross' },
{ reviewer: 'Nina' }
];
function getActiveSlide(container: HTMLElement) {
@@ -23,6 +23,14 @@ function getActiveImage(container: HTMLElement) {
return getActiveSlide(container).querySelector('img') as HTMLImageElement;
}
function getNextButton(container: HTMLElement) {
return container.querySelector('.testimonial-arrow-right') as HTMLButtonElement;
}
function getPreviousButton(container: HTMLElement) {
return container.querySelector('.testimonial-arrow-left') as HTMLButtonElement;
}
describe('TestimonialsSection', () => {
afterEach(() => {
vi.useRealTimers();
@@ -33,11 +41,11 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
const nextButton = getNextButton(container);
for (const [index, slide] of expectedMappedSlides.entries()) {
expect(getActiveReviewer(container)).toBe(slide.reviewer);
expect(getActiveImage(container).getAttribute('src')).toBe(slide.src);
expect(getActiveImage(container)).toBeTruthy();
if (index < expectedMappedSlides.length - 1) {
await fireEvent.click(nextButton);
@@ -52,7 +60,7 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
const nextButton = getNextButton(container);
expect(getActiveReviewer(container)).toBe('Kate');
@@ -68,16 +76,14 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const previousButton = screen.getByRole('button', { name: /previous testimonial/i });
const previousButton = getPreviousButton(container);
expect(getActiveReviewer(container)).toBe('Kate');
await fireEvent.click(previousButton);
expect(getActiveReviewer(container)).toBe('Nina');
expect(getActiveImage(container).getAttribute('src')).toBe(
'/images/wallace-auckland-dog-walking-review.png'
);
expect(getActiveImage(container)).toBeTruthy();
});
it('keeps custom testimonial images and filters out testimonials with no image', async () => {
@@ -100,7 +106,7 @@ describe('TestimonialsSection', () => {
testimonials: customTestimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
const nextButton = getNextButton(container);
expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5);
@@ -109,7 +115,16 @@ describe('TestimonialsSection', () => {
}
expect(getActiveReviewer(container)).toBe('Casey');
expect(getActiveImage(container).getAttribute('src')).toBe('/images/custom-casey-review.png');
expect(getActiveImage(container)).toBeTruthy();
expect(screen.queryByText('Jordan')).not.toBeInTheDocument();
});
it('can start on a different testimonial for a different page seed', () => {
const { container } = render(TestimonialsSection, {
testimonials: homepageContent.testimonials,
seedKey: '/dog-walking'
});
expect(getActiveReviewer(container)).not.toBe('Kate');
});
});
+372 -8
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import type { IconCard } from '$lib/types';
export let values: IconCard[];
let valuesScroller: HTMLDivElement | undefined;
let activeIndex = 0;
let mobileCardsObserver: IntersectionObserver | null = null;
$: orderedValues = values
.map((value, index) => ({ value, index }))
@@ -17,20 +21,380 @@
return a.index - b.index;
})
.map(({ value }) => value);
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 768;
}
function cardScrollLeft(card: HTMLElement) {
return Math.max(0, card.offsetLeft - 8);
}
async function scrollValues(direction: 1 | -1) {
if (!valuesScroller || !isMobileViewport()) {
return;
}
const nextIndex = Math.max(0, Math.min(activeIndex + direction, orderedValues.length - 1));
await scrollToValue(nextIndex, 'smooth');
}
async function scrollToValue(index: number, behavior: ScrollBehavior = 'smooth') {
if (!valuesScroller || !isMobileViewport()) {
return;
}
const cards = valuesScroller.querySelectorAll<HTMLElement>('.value-card');
const targetCard = cards[index];
if (!targetCard) {
return;
}
activeIndex = index;
await tick();
valuesScroller.scrollTo({
left: cardScrollLeft(targetCard),
behavior
});
}
function bindMobileCardObserver() {
mobileCardsObserver?.disconnect();
if (!valuesScroller || !isMobileViewport()) {
return;
}
const cards = valuesScroller.querySelectorAll<HTMLElement>('.value-card');
if (!cards.length) {
return;
}
mobileCardsObserver = new IntersectionObserver(
(entries) => {
const visibleEntry = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (!visibleEntry) {
return;
}
const nextIndex = cards.length ? [...cards].indexOf(visibleEntry.target as HTMLElement) : -1;
if (nextIndex >= 0) {
activeIndex = nextIndex;
}
},
{
root: valuesScroller,
threshold: [0.6, 0.75, 0.9]
}
);
cards.forEach((card) => mobileCardsObserver?.observe(card));
}
onMount(() => {
const handleResize = () => {
if (!valuesScroller) {
return;
}
if (isMobileViewport()) {
if (activeIndex === 0) {
valuesScroller.scrollTo({ left: 0, behavior: 'auto' });
}
bindMobileCardObserver();
void scrollToValue(activeIndex, 'auto');
} else {
mobileCardsObserver?.disconnect();
}
};
if (valuesScroller && isMobileViewport()) {
valuesScroller.scrollTo({ left: 0, behavior: 'auto' });
bindMobileCardObserver();
}
window.addEventListener('resize', handleResize);
return () => {
mobileCardsObserver?.disconnect();
window.removeEventListener('resize', handleResize);
};
});
</script>
<section id="values">
<div class="values-inner">
<h2 class="section-heading">Where dogs come first</h2>
<span class="values-eyebrow">Why owners stay</span>
<h2 class="section-heading">Calmer dogs. Clearer routines. Less worry.</h2>
<p class="values-intro">
Everything is designed to make life easier for busy Auckland dog owners and safer, happier for the dogs in our care.
</p>
<div class="values-grid">
{#each orderedValues as value}
<div class="value-card">
<Icon name={value.icon} className="value-card-icon" />
<h3>{value.title}</h3>
<p>{value.body}</p>
<div class="values-shell">
<div bind:this={valuesScroller} class="values-grid">
{#each orderedValues as value, index}
<div class:active={index === activeIndex} class="value-card">
<div class="value-icon-wrap">
<Icon name={value.icon} className="value-card-icon" />
</div>
<div class="value-text">
<h3>{value.title}</h3>
<p>{value.body}</p>
</div>
</div>
{/each}
</div>
<div class="values-mobile-controls" aria-label="Value cards navigation">
<button
type="button"
class="values-mobile-button"
aria-label="Previous value"
disabled={activeIndex === 0}
on:click={() => scrollValues(-1)}
>
<Icon name="fas fa-chevron-left" />
</button>
<div class="values-mobile-pager" aria-label="Current value">
{#each orderedValues as _, index}
<button
type="button"
class:active={index === activeIndex}
class="values-mobile-dot"
aria-label={`Go to value ${index + 1}`}
aria-pressed={index === activeIndex}
on:click={() => scrollToValue(index)}
/>
{/each}
</div>
{/each}
<button
type="button"
class="values-mobile-button"
aria-label="Next value"
disabled={activeIndex === orderedValues.length - 1}
on:click={() => scrollValues(1)}
>
<Icon name="fas fa-chevron-right" />
</button>
</div>
</div>
</div>
</section>
<style>
.values-eyebrow {
display: block;
width: fit-content;
margin: 0 auto 10px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--yellow);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.values-intro {
max-width: 760px;
margin: 18px auto 0;
text-align: center;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.65;
}
.values-mobile-controls {
display: none;
}
@media (max-width: 768px) {
.values-shell {
margin-top: 24px;
overflow: hidden;
}
.values-grid {
display: grid;
grid-auto-flow: column;
grid-auto-columns: calc(100% - 64px);
grid-template-columns: none;
align-items: stretch;
gap: 10px;
margin-top: 0;
border-top: none;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scroll-padding-left: 8px;
padding: 0 14px 8px 8px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pinch-zoom;
}
.values-grid::-webkit-scrollbar {
display: none;
}
.values-mobile-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 12px;
}
.values-mobile-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition:
background 0.18s ease,
opacity 0.18s ease,
transform 0.18s ease;
}
.values-mobile-button:disabled {
opacity: 0.35;
}
.values-mobile-pager {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.values-mobile-dot {
width: 9px;
height: 9px;
padding: 0;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
transition:
width 0.22s ease,
background 0.22s ease,
transform 0.22s ease;
}
.values-mobile-dot.active {
width: 28px;
background: var(--yellow);
}
.value-card {
display: flex;
min-height: clamp(230px, 42svh, 320px);
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 20px 18px 22px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.07));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 6px 16px rgba(0, 0, 0, 0.06);
scroll-snap-align: start;
scroll-snap-stop: always;
flex-direction: column;
justify-content: flex-start;
gap: 14px;
transition:
background 0.24s ease,
box-shadow 0.24s ease,
border-color 0.24s ease;
touch-action: pan-x;
user-select: none;
-webkit-user-select: none;
}
.value-card.active {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.09));
border-color: rgba(255, 255, 255, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 10px 22px rgba(0, 0, 0, 0.08);
}
.value-card:nth-child(odd) {
border-right: none;
}
.value-card:last-child {
margin-right: 2px;
}
.value-icon-wrap {
width: 56px;
height: 56px;
border-radius: 18px;
margin-top: 0;
background: rgba(255, 255, 255, 0.1);
}
.value-card .value-card-icon {
font-size: 23px;
}
.value-text {
max-width: 30ch;
min-width: 0;
margin-top: 0;
}
.value-text h3 {
margin-bottom: 8px;
font-size: 21px;
line-height: 1.08;
}
.value-card p {
font-size: 14px;
line-height: 1.55;
opacity: 0.9;
}
.values-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
font-size: 11px;
}
.values-intro {
margin-top: 14px;
font-size: 15px;
line-height: 1.55;
}
}
@media (hover: hover) {
.values-mobile-button:hover {
background: rgba(255, 255, 255, 0.18);
}
}
.values-mobile-button:active {
transform: scale(0.95);
}
</style>
+55 -14
View File
@@ -4,36 +4,77 @@ export const aboutPageContent: AboutPageContent = {
title: 'About Us',
sections: [
{
eyebrow: 'Our story',
title: 'Who we are',
body: [
"At GoodWalk, we're not your average dog walking service. We're a team of passionate dog lovers dedicated to providing top-notch care for your furry friends. Specialising in small dogs, we understand their unique needs firsthand, being small dog owners ourselves!",
"Our commitment to excellence has quickly made us a leader in Auckland Central's dog-walking scene. From pack walks to one-on-one sessions, we ensure the happiness and well-being of every dog in our care."
"Goodwalk is built around Alessandra — who started this because she couldn't find a walker she actually trusted, and hasn't stopped showing up the same way since. She walks every dog herself, posts updates to Instagram so you can see exactly what your dog is up to, and has built relationships with some dogs from as young as ten weeks old. Thirty-plus five-star Google reviews later, the feedback keeps saying the same thing: the dogs adore her, and their owners finally stop worrying.",
"We specialise in small and medium dogs because we understand them — not as a category, but as actual dogs with specific needs, specific quirks, and specific ways they fall apart in the wrong environment. The pace of a walk matters. The size of the group matters. The temperament of the other dogs matters. That's why we built a service around them, not just one that fits them in."
],
imageUrl: '/images/auckland-pack-walk-dog.jpg',
imageAlt: 'Dog on a Goodwalk pack walk'
imageUrl: '/images/auckland-pack-walk-small-dogs-group.jpg',
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park"
},
{
title: 'Our impact',
eyebrow: 'What we stand for',
title: 'How we do things',
body: [
"At GoodWalk, we believe in positive reinforcement training to help your dog thrive in the world. Safety, professionalism, well-being, fun, structure, and compassion are the cornerstones of our business ethos.",
"When you choose GoodWalk, you're choosing a partner who will treat your dog like family, because that's exactly what they are to us."
"Every walk you've seen across our pages — the Tiny Gang outings, the one-on-ones, the puppy visits — runs on the same principles. Calm handling, positive reinforcement, and a walker who already knows your dog. That's not a promise we make at signup. It's how every single walk actually goes.",
"We keep packs small because we mean it when we say your dog gets real attention. We cover pickup and drop-off because your day shouldn't have to work around us. And every walker holds pet first aid certification and public liability insurance — because the dogs in our care aren't just bookings, they're the whole reason we do this."
],
imageUrl: '/images/auckland-dog-group-outing.jpg',
imageAlt: 'Goodwalk dogs enjoying an outing together',
imageAlt: 'Goodwalk dogs enjoying a group outing in Auckland',
reverse: true,
accent: 'gradient'
},
{
title: 'Meet the team',
eyebrow: 'Meet the founder',
title: 'The heart of Goodwalk',
body: [
'Behind GoodWalk is Alessandra, an Italian who has a deep passion for dogs. With her love for animals and years of experience, Alessandra leads our team with dedication and expertise, ensuring that every dog receives the love and attention they deserve.',
"And let's not forget about Maya, our marketing manager! A Cavalier King Charles cross Shih Tzu, Maya is full of sass and personality, bringing a touch of charm and flair to everything we do."
"Alessandra started Goodwalk because she couldn't find a walker she actually trusted with Maya. So she became one. Italian-born and Auckland Central-based, she leads every walk herself — not because she has to, but because handing that off was never something she was willing to do. The dogs she walks have her full attention. Their owners have her number.",
"Maya is a Cavalier King Charles cross Shih Tzu, and the reason small dogs sit at the centre of everything Goodwalk does. She is opinionated, dramatic when it rains, and completely impossible to ignore on a walk. She is also the best argument we have for why small dogs deserve a service built specifically around them — not just accommodated by one."
],
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
imageAlt: 'Goodwalk staff member Aless'
imageUrl: '/images/founder-image-aless-goodwalk.jpg',
imageAlt: 'Alessandra, founder of Goodwalk Auckland',
accent: 'founder'
}
],
faqTitle: 'Questions about Goodwalk',
faqs: [
{
question: 'Who actually walks my dog?',
answer:
'Alessandra, the founder, personally leads every walk. We are not a platform or an agency — you will always know who is at the door.'
},
{
question: 'Why do you specialise in small dogs?',
answer:
'Small dogs have different energy levels, social dynamics, and handling needs compared to larger breeds. Alessandra is a small dog owner herself, and Goodwalk was built specifically around what those dogs need — not adapted from a one-size-fits-all model.'
},
{
question: 'How big are your packs?',
answer:
'We keep Tiny Gang packs to 48 dogs. Smaller packs mean better supervision, calmer outings, and dogs that actually come home settled rather than overstimulated.'
},
{
question: "What is a Meet & Greet?",
answer:
'A Meet & Greet is a free, no-obligation introduction where Alessandra meets you and your dog in person. It is a chance to ask questions, see how your dog responds, and decide if Goodwalk is the right fit — with no pressure either way.'
},
{
question: 'What suburbs do you cover?',
answer:
'We cover most of Auckland Central including Ponsonby, Grey Lynn, Mt Eden, Kingsland, Morningside, Sandringham, Mt Albert, Mt Roskill, Herne Bay, Freemans Bay, Pt Chevalier, Avondale, Eden Terrace, Balmoral, and more. If you are nearby and unsure, just ask.'
},
{
question: 'Are your walkers insured and first-aid trained?',
answer:
'Yes. All walkers hold public liability insurance and a current pet first aid certificate. Your dog is covered from pickup to drop-off.'
},
{
question: 'What does onboarding look like?',
answer:
'Every new dog goes through a screening process that includes at minimum two assessment walks. This lets us make sure the pack is the right fit for your dog, and your dog is the right fit for the pack.'
}
],
servicesTitle: 'Explore our services',
contact: {
title: "Let's get started!",
email: 'info@goodwalk.co.nz',
+68 -42
View File
@@ -3,82 +3,108 @@ import type { ServicePageContent } from '$lib/types';
export const dogWalkingContent: ServicePageContent = {
hero: {
eyebrow: '1:1 Walks',
title: 'Walks for larger breeds, too!',
title: 'A calmer walk for dogs who need more attention',
subtitle: 'Full attention, your dog\'s pace. Free pickup and drop-off across Auckland Central.',
paragraphs: [
'Looking for personal attention for your big pup? Our professional dog walking service is perfect for larger dogs who walk well on leash and have good recall. Safety is paramount, so we only accommodate dogs with no reactivity issues. At Goodwalk, we pride ourselves on building relationships with both you and your furry family member.',
"Give your dog his best life while joining our growing community of happy pet parents! For those seeking extra care, we offer specialised one-on-one walks tailored to your dog's individual needs and personality"
'Goodwalk 1:1 Walks are for dogs who do better with more individual attention, a quieter setup, and a walk tailored to their own pace, confidence, and routine.',
'They can be a great fit for larger dogs, dogs who are not suited to group walks, or owners who want a more personal approach with extra care and consistency.',
'If your dog needs space, structure, and a walker who can focus fully on them, our one-on-one walks are designed for exactly that.',
'We run 1:1 walks across Auckland Central — including Mt Eden, Ponsonby, Kingsland, Grey Lynn, Herne Bay, and surrounding suburbs — with free pickup and drop-off included.'
],
imageUrl:
'/images/auckland-large-dog-one-on-one-walk.jpg',
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk'
imageUrl: '/images/auckland-large-dog-one-on-one-walk.jpg',
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk',
chips: [
{ icon: 'fas fa-dog', label: 'One-on-one walk' },
{ icon: 'fas fa-tag', label: 'From $45 / walk' },
{ icon: 'fas fa-car', label: 'Free pickup & drop-off' }
],
cta: { label: 'Book a free Meet & Greet', href: '#newlead', variant: 'yellow' }
},
highlight: {
eyebrow: '▼・ᴥ・▼',
title: 'Personalised adventures for your dog!',
eyebrow: 'One dog. Full attention.',
title: 'Built for dogs who need a more individual kind of walk',
imageUrl: '/images/auckland-dogs-outdoor-pack.jpg',
imageAlt: 'Goodwalk dogs gathered together outdoors'
},
pricing: {
title: '1:1 Large Dog Breed Prices',
plans: [
imageAlt: 'Goodwalk dogs gathered together outdoors',
collageImages: [
{
title: '30 Minutes',
price: '$45',
period: 'Per Walk',
features: ['Free pickup/dropoff', '30 minute walk', 'Social media updates', 'Basic training']
imageUrl: '/images/one-on-one-dog-portrait-1.jpg',
imageAlt: 'Happy black dog on a one-on-one Goodwalk walk in Auckland'
},
{
title: '45 Minutes',
price: '$55',
period: 'Per Walk',
popular: true,
features: ['Free pickup/dropoff', '45 minute walk', 'Social media updates', 'Basic training']
imageUrl: '/images/one-on-one-dog-portrait-2.jpg',
imageAlt: 'Older black dog enjoying a calm one-on-one Goodwalk walk in Auckland'
},
{
title: '60 Minutes',
price: '$65',
period: 'Per Walk',
features: ['Free pickup/dropoff', '60 minute walk', 'Social media updates', 'Basic training']
imageUrl: '/images/one-on-one-dog-portrait-3.jpg',
imageAlt: 'Brown curly dog resting during a one-on-one Goodwalk walk in Auckland'
}
]
},
pricing: {
title: 'Choose the walk length that suits your dog',
intro:
'Our 1:1 walks are shaped around your dog, not a group schedule. Ideal for dogs who need extra attention, a steadier pace, or a more personalised walking routine.',
plans: [
{
title: '30 Minute 1:1 Walk',
price: '$45',
period: 'Per Walk',
features: ['Free pickup and drop-off', 'Shorter one-on-one walk', 'Personal attention throughout', 'Good fit for lower-energy dogs']
},
{
title: '45 Minute 1:1 Walk',
price: '$55',
period: 'Per Walk',
popular: true,
features: ['Free pickup and drop-off', 'Balanced walk length for most dogs', 'Time for calm handling and structure', 'Best fit for many routines']
},
{
title: '60 Minute 1:1 Walk',
price: '$65',
period: 'Per Walk',
features: ['Free pickup and drop-off', 'Longer individual walk', 'More time for movement and engagement', 'Best for dogs needing a fuller outing']
}
],
scarcityNote: 'A limited number of 1:1 slots are available each week.'
},
benefits: {
title: 'Benefits of our 1:1 walks',
title: 'Why some dogs do better on 1:1 walks',
items: [
{
title: 'Individualised Attention',
body: 'Large breeds receive personalised care and undivided attention from the walker, addressing their unique needs and preferences without competition from other dogs.'
title: 'They get the walkers full attention',
body: 'One-on-one walks give your dog focused handling and a calmer experience without competing with the needs of a group.'
},
{
title: 'Tailored Exercise',
body: 'Walkers can customise the pace, duration, and route of the walk to accommodate the energy levels and physical abilities of large breeds, providing an appropriate level of exercise tailored to their specific needs.'
title: 'The walk matches their pace',
body: 'We can tailor the route, speed, and duration to suit your dogs energy, confidence, and physical needs.'
},
{
title: 'Bonding and Socialisation',
body: 'During one-on-one walks, large breeds bond closely with their walker and socialise with people and animals encountered, promoting confidence and social skills'
title: 'They have more space to relax',
body: 'Dogs who are not suited to pack walks often feel more comfortable when they can move through the world without the pressure of a group.'
},
{
title: 'Enhanced safety',
body: "With one-on-one walks, there's reduced risk of potential conflicts or incidents that may arise in group settings, ensuring a safer walking experience for large breeds."
title: 'You get a more tailored routine',
body: 'A 1:1 setup gives us more flexibility to build a walking routine around what works best for your dog and your week.'
},
{
title: 'Training Opportunities',
body: 'One-on-one walks offer dedicated time for training and reinforcement of good behaviours, such as loose leash walking or obedience commands, helping large breeds develop and maintain positive habits.'
title: 'There is room for better habits',
body: 'One-on-one walks create more opportunity to reinforce calm walking, better focus, and the practical behaviours that make daily life easier.'
},
{
title: 'Stress Reduction',
body: 'Large breeds may feel more relaxed and comfortable during one-on-one walks, as they can explore and enjoy their surroundings without the potential stressors of a group dynamic, leading to a more positive walking experience overall.'
title: 'You can feel more confident leaving them with us',
body: 'For owners of dogs who need a bit more care, 1:1 walks offer reassurance that your dog is getting a more considered, individual approach.'
}
]
},
testimonialsHeading: 'What our clients say',
booking: {
title: "Let's meet!",
subtitle: 'Fill out your details below, and we can arrange a Meet & Greet for a one on one walk!',
title: 'See if 1:1 walks are the right fit',
subtitle: 'Fill out your details below and well arrange a free Meet & Greet to learn more about your dog.',
formAction: '/contact-us',
serviceOptions: [],
ownerStepLabel: 'Your details',
dogStepLabel: 'Your dog',
dogIntro: 'Tell us about your dog, your area, and anything important we should know before arranging a one on one Meet & Greet.'
dogIntro:
'Tell us about your dog, your area, and anything important we should know so we can see whether a 1:1 walk is the right fit.'
}
};
+80 -33
View File
@@ -21,7 +21,7 @@ export const homepageContent: HomePageContent = {
{ label: 'About Us', href: '/about' },
{ label: 'Contact Us', href: '/contact-us' }
],
cta: { label: 'Book a Meet & Greet', href: '/contact-us', variant: 'yellow' },
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' },
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
megaMenuServices: [
{ icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' },
@@ -31,34 +31,40 @@ export const homepageContent: HomePageContent = {
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
},
hero: {
title: 'Unleashing Fun in',
highlight: "Your Dog's Day!",
mobileTitle: "Unleashing Fun in\nYour Dog's Day!",
subtitle: 'Trusted, professional dog walking across Auckland Central — pack walks, 1:1 walks, and puppy visits.',
primaryCta: { label: 'Learn more', href: '#services', variant: 'yellow' },
secondaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'outline' },
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
title: 'Come home to a',
highlight: 'calm, happy dog',
mobileTitle: 'Come home to a\ncalm, happy dog',
subtitle:
'Dog walking for busy Auckland Central professionals who want a reliable, relationship-led team their dog knows by name.',
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
secondaryCta: {
label: 'See how it works',
href: '#how-it-works',
variant: 'outline'
},
imageUrl: '/images/maya-mascot.png',
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
},
intro: {
text: 'Trusted by Auckland dog parents.',
reviewCta: {
label: 'All 5 star reviews on Google!',
label: '30+ five-star Google reviews',
href: 'https://g.page/r/CUsvrWPhkYrAEB0/',
external: true
}
},
promise: {
title: 'Happy pets,',
subtitle: 'happy humans',
founderStory: {
title: 'Not just dog walking.',
subtitle: 'Built around trust.',
body: [
'We specialise in the unique needs of small-to-medium breeds — easing stress and anxiety while keeping tails wagging.',
'Professional dog walking across Auckland for small, medium and large breeds, with tailored pack walks for smaller dogs and one-on-one walks for larger breeds — giving every dog the personalised attention they deserve. Ready to join our'
'Most dog walking companies sell walks. Goodwalk was built for owners who want a calmer, more personal experience for their dog, especially small dogs who thrive on routine, familiarity, and gentle handling.',
'That means familiar walkers, safe group dynamics, reliable communication, and a team your dog genuinely builds a relationship with. We know we are not just collecting dogs for a walk. We are being trusted with part of your family and access to your home.',
'You know exactly who is caring for your dog. Your dog knows who is at the door. And you come home to a calmer, happier dog. Ready to'
],
emphasis: 'TINY GANG?',
cta: { label: 'See our services', href: '#services', variant: 'green' },
imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.webp',
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
emphasis: 'join the Tiny Gang?',
cta: { label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'green' },
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
imageAlt: 'Alessandra from Goodwalk with a dog in Auckland'
},
services: [
{
@@ -83,45 +89,76 @@ export const homepageContent: HomePageContent = {
href: '/puppy-visits'
}
],
howItWorks: {
title: 'How it works',
intro:
'A calm, simple start designed to give you confidence quickly and help your dog settle into the right routine.',
steps: [
{
phase: 'Meet',
benefit: 'No pressure, just clarity',
title: 'We get to know your dog properly',
body:
'Your free Meet & Greet gives you a chance to ask questions, talk through routine and temperament, and make sure the fit feels right before anything starts.',
icon: 'fas fa-handshake'
},
{
phase: 'Settle',
benefit: 'A smoother start for nervous dogs',
title: 'Your dog eases in without overwhelm',
body:
'We start with assessment walks so your dog can build confidence, settle with the walker, and find the right pace before moving into a regular routine.',
icon: 'fas fa-clipboard-check'
},
{
phase: 'Thrive',
benefit: 'The outcome you actually want',
title: 'You get a calmer, happier dog at home',
body:
'Once everything feels right, your dog joins their regular walks and comes home exercised, settled, and ready to relax while you get more peace of mind in your week.',
icon: 'fas fa-heart'
}
]
},
values: [
{
icon: 'fas fa-heart',
title: 'Kindness',
title: 'Calm, kind handling',
body:
'With gentle care and genuine affection, we make every walk a calm, happy experience. We use positive reinforcement to encourage good behaviour because kindness is at the heart of everything we do.'
'We use positive reinforcement, gentle handling, and patient routines so dogs build confidence instead of stress.'
},
{
icon: 'fas fa-camera',
title: 'Daily Updates',
title: 'Daily updates you will actually want',
body:
"Catch your pup in action with daily social updates - showcasing their walks, playtime, and mischief with the Tiny Gang. It's your window into their happiest moments."
"You get to see your dog out enjoying the day, which means less wondering and more peace of mind while you're at work."
},
{
icon: 'fas fa-users',
title: 'Small Pack Sizes',
order: 2,
body:
'With just 4-8 dogs per group, our walks are calm, controlled, and respectful of public spaces - ensuring every dog gets the attention and care they deserve.'
'With just 4-8 dogs per group, walks stay calm, structured, and manageable, with enough attention for every dog.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety',
title: 'Safety-first by default',
order: 1,
body:
'Our team is fully pet first aid certified and trained to handle any situation calmly and confidently. With proactive safety protocols and constant situational awareness, we create a secure environment for every walk.'
'Pet first aid, careful screening, and proactive handling are built into every walk, not added on as a nice extra.'
},
{
icon: 'fas fa-calendar-check',
title: 'Flexibility',
title: 'Built for real schedules',
body:
"We know life gets busy - so while we specialise in regular, permanent walks, we're always happy to adapt. Just give us a little notice, and we'll do our best to accommodate your changing schedule."
"We specialise in regular walks, but we know life changes. Give us notice and we'll do our best to help keep things running smoothly."
},
{
icon: 'fas fa-clock',
title: 'Reliability',
title: 'Reliable pickup, clear communication',
order: 3,
body:
"We guarantee punctuality and consistency, so you can count on us. With clear communication, you'll always be in the loop - and your dog's needs will always be our top priority."
"You should not have to chase your dog walker. We keep things consistent, communicate clearly, and make the practical side feel easy."
}
],
testimonials: [
@@ -143,7 +180,7 @@ export const homepageContent: HomePageContent = {
quote:
'Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, theres a reason she has 5 stars!',
reviewer: 'Ross',
detail: "Otis's Dad",
detail: "Otis's dad",
imageUrl: '/images/otis-auckland-dog-walking-review.jpg'
},
{
@@ -157,15 +194,15 @@ export const homepageContent: HomePageContent = {
booking: {
title: "Let's meet!",
subtitle:
"Almost there — just your contact details so we can reach out to arrange your free, no-obligation Meet & Greet.",
"A few contact details and well be in touch within 24 hours to arrange your free, no-obligation Meet & Greet.",
generalSubtitle:
"Almost there — just your contact details so we can reply properly to your message.",
"A few contact details and well reply properly within 24 hours.",
formAction: '/contact-us',
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
ownerStepLabel: 'Your details',
dogStepLabel: 'Your dog',
generalIntro:
'Got feedback, a complaint, or a business enquiry? Choose general enquiry and send us the details without filling in dog or service information.'
'Need to send feedback, make a complaint, or ask a business question? Choose general enquiry and send us the details without filling in dog or service information.'
},
info: {
title: 'Locations & Hours',
@@ -187,6 +224,11 @@ export const homepageContent: HomePageContent = {
question: 'How does payment work?',
answer: 'All walks are paid for a week in advance, via invoice.'
},
{
question: 'Do you provide a casual service?',
answer:
'Yes, we do offer casual rates, but they are priced higher. The best value for money is regular walks.'
},
{
question: 'What requirements does my dog need?',
answer:
@@ -197,6 +239,11 @@ export const homepageContent: HomePageContent = {
answer:
'All walkers are covered by public liability insurance, and all walkers hold a current First Aid training certificate.'
},
{
question: 'Do I need to leave keys with you?',
answer:
'Usually, yes, if no one will be home when we collect or return your dog. We can go over the best option for access during your Meet & Greet.'
},
{
question: 'What happens if the weather is bad?',
answer:
+450
View File
@@ -0,0 +1,450 @@
import type { LocationPageContent } from '$lib/types';
export const locationPages: LocationPageContent[] = [
{
suburb: 'Mt Eden',
slug: 'mt-eden',
intro:
'Mt Eden is one of Auckland Central\'s most walked neighbourhoods — and for good reason. The volcanic cone, leafy streets, and mix of open reserves and quiet paths make it an ideal place for small dogs who thrive on a proper daily outing. Goodwalk runs pack walks and 1:1 walks through Mt Eden as part of a regular weekly routine, with free pickup and drop-off included.',
parks: [
{
name: 'Maungawhau / Mt Eden Domain',
description:
'The volcanic cone at the heart of Mt Eden offers wide open paths, panoramic views across Auckland, and a mix of gentle and steeper terrain. Popular with local dog walkers and a staple route for the Tiny Gang.',
leashNote: 'Dogs must be on leash on the summit and in the Domain.'
},
{
name: 'Potters Park',
description:
'A well-used neighbourhood park on the border of Mt Eden and Sandringham with open grass areas and shade trees. Good for shorter walks and a regular favourite for local dogs.',
leashNote: 'On-leash area.'
},
{
name: 'Cornwall Park / One Tree Hill Domain',
description:
'Just south of Mt Eden, Cornwall Park is one of Auckland\'s most expansive green spaces with sweeping lawns, mature trees, and wide walking paths. The scale makes it excellent for a longer, more varied outing.',
leashNote: 'Dogs must be on leash and are not permitted in fenced farm animal areas.'
}
]
},
{
suburb: 'Kingsland',
slug: 'kingsland',
intro:
'Kingsland sits right in the heart of Auckland Central, with easy access to some of the area\'s best parks and green corridors. Its central position makes it one of our most efficient pickup stops, and dogs from Kingsland are a regular part of the Tiny Gang. Goodwalk covers Kingsland for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Fowlds Park',
description:
'A large open park in neighbouring Morningside, Fowlds Park offers generous grass areas, walking paths, and space to roam. It is one of the most popular spots for dog walking in the central suburbs and a regular route for Goodwalk outings.',
leashNote: 'On-leash area throughout most of the park.'
},
{
name: 'Western Springs Park',
description:
'Surrounding the historic Western Springs lake, this large park provides shaded paths, waterside walking, and a calm environment that suits dogs who prefer a quieter outing away from busy streets.',
leashNote: 'On-leash. Dogs are not permitted in the zoo area.'
},
{
name: 'Chamberlain Park',
description:
'A wide green space adjacent to the golf course with open walkways and flat terrain — easy going for smaller dogs and a good spot for a steady, unhurried walk.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Ponsonby',
slug: 'ponsonby',
intro:
'Ponsonby\'s tree-lined streets and proximity to several of Auckland\'s best parks make it a natural home for dog-loving households. Many of the small dogs in our Tiny Gang are based in Ponsonby, and we know the neighbourhood well. Goodwalk offers pack walks, 1:1 walks, and puppy visits across Ponsonby.',
parks: [
{
name: 'Western Park',
description:
'A terraced hillside reserve running alongside Ponsonby Road, Western Park offers shaded paths, native planting, and a quiet contrast to the busy street above. Well-suited to dogs who enjoy a more enclosed, leafy environment.',
leashNote: 'On-leash area.'
},
{
name: 'Victoria Park',
description:
'One of Auckland\'s most central parks, Victoria Park is a large open space with wide paths, mature trees, and plenty of room for small dogs to enjoy a proper walk without feeling crowded.',
leashNote: 'On-leash area.'
},
{
name: 'Herne Bay Foreshore',
description:
'A short walk from central Ponsonby, the Herne Bay waterfront and reserve offers coastal views, fresh sea air, and a relaxed route that small dogs tend to love.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Grey Lynn',
slug: 'grey-lynn',
intro:
'Grey Lynn is a dog-friendly suburb with one of Auckland Central\'s most popular parks right at its centre. It\'s a densely walkable area with good access to open green space, and a regular part of our Tiny Gang routes. Goodwalk serves Grey Lynn for all services — pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Grey Lynn Park',
description:
'One of the most popular dog walking destinations in Auckland Central, Grey Lynn Park has large open grass areas, wide paths, and an off-leash zone. It\'s a social, active park where small dogs thrive alongside the community.',
leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage for the designated area.'
},
{
name: 'Arch Hill Reserve',
description:
'A smaller hilltop reserve on the Grey Lynn / Arch Hill border with native planting, good views, and a quieter atmosphere than the main park. Good for dogs who prefer a less busy setting.',
leashNote: 'On-leash area.'
},
{
name: 'Cox\'s Bay Reserve',
description:
'A short walk from Grey Lynn, Cox\'s Bay Reserve sits on the Waitemata Harbour foreshore in Herne Bay. Flat, scenic, and popular — an excellent route extension for dogs who enjoy walking near the water.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Sandringham',
slug: 'sandringham',
intro:
'Sandringham is a well-connected central suburb with good access to several parks used by our Tiny Gang. Its quiet residential streets are easy to navigate for pickups, and its proximity to Mt Eden and Morningside means dogs based here have a range of walking routes available. Goodwalk covers Sandringham for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Potters Park',
description:
'On the Sandringham / Mt Eden border, Potters Park is a flat, open space with grassy areas and mature trees — a reliable neighbourhood park and a regular stop on our routes.',
leashNote: 'On-leash area.'
},
{
name: 'Fowlds Park',
description:
'Easily accessible from Sandringham, Fowlds Park is one of the central Auckland area\'s largest parks — open, spacious, and consistently popular with local dog walkers.',
leashNote: 'On-leash area.'
},
{
name: 'Woodside Reserve',
description:
'A quieter local reserve within Sandringham, Woodside is good for calmer walks and dogs who do better with fewer distractions — a solid neighbourhood option between the larger parks.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Mt Albert',
slug: 'mt-albert',
intro:
'Mt Albert combines a relaxed suburban feel with excellent access to parks and green corridors that make it one of the better areas for dog walking in Auckland Central. The Oakley Creek walkway alone is a standout route for small dogs. Goodwalk serves Mt Albert for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Oakley Creek / Te Auaunga Walkway',
description:
'A beautiful linear walkway following Oakley Creek through native bush restoration plantings and open stream-side paths. One of Auckland Central\'s most scenic dog walking routes — calm, well-maintained, and a genuine highlight for dogs and owners alike.',
leashNote: 'On-leash in most sections to protect local wildlife.'
},
{
name: 'Mt Albert Domain',
description:
'A well-maintained local domain with open lawns, sports fields, and walking paths. Reliable, well-used, and a regular part of our Mt Albert routes.',
leashNote: 'On-leash area.'
},
{
name: 'Phyllis Reserve',
description:
'A popular neighbourhood reserve with open grass and easy walking paths. Regularly used by local dog owners and a good stop for dogs who enjoy a steadier, more predictable walk.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Herne Bay',
slug: 'herne-bay',
intro:
'Herne Bay is one of Auckland\'s most picturesque suburbs, with waterfront reserves, harbour views, and a calm residential feel that suits small dogs perfectly. It\'s a natural fit for our Tiny Gang, and we regularly pick up from the area. Goodwalk covers Herne Bay for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Cox\'s Bay Reserve',
description:
'A much-loved waterfront reserve on the Waitemata Harbour with flat grassy areas, harbour views, and a relaxed atmosphere. One of Auckland\'s best spots for a morning walk with a small dog.',
leashNote: 'On-leash area.'
},
{
name: 'Herne Bay Foreshore',
description:
'The foreshore walkway along the Herne Bay waterfront provides easy, flat walking with sea breezes and good views across the harbour. Popular with dog owners at any time of day.',
leashNote: 'On-leash area.'
},
{
name: 'Western Park',
description:
'A short walk into Ponsonby, Western Park\'s terraced paths and native planting offer a quiet contrast to the open waterfront — a good second option for dogs who enjoy varied terrain.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Morningside',
slug: 'morningside',
intro:
'Morningside is home to some of the best park access in the central Auckland area — Fowlds Park and Western Springs are both on the doorstep. It\'s a regular part of our Tiny Gang routes, and pickup logistics are straightforward. Goodwalk serves Morningside for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Fowlds Park',
description:
'Morningside\'s standout park — large, open, and well-maintained with generous grass areas and plenty of room for a proper group outing. It\'s one of our most-used locations for Tiny Gang walks.',
leashNote: 'On-leash area throughout most of the park.'
},
{
name: 'Western Springs Park',
description:
'Surrounding a historic lake and well-established trees, Western Springs provides a calmer, more shaded alternative to Fowlds — ideal for dogs who prefer a quieter environment or warmer days.',
leashNote: 'On-leash. Dogs are not permitted in the zoo precinct.'
},
{
name: 'Chamberlain Park',
description:
'Adjacent to the golf course and easily reached from Morningside, Chamberlain Park offers flat, open walking in a less busy setting than the suburb\'s larger parks.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Freemans Bay',
slug: 'freemans-bay',
intro:
'Freemans Bay sits just below Ponsonby and within easy reach of Victoria Park and the waterfront. Its compact streets and central location make it one of our quickest pickup stops. Goodwalk serves Freemans Bay for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Victoria Park',
description:
'One of Auckland\'s most central and well-used parks, Victoria Park is right on Freemans Bay\'s doorstep — wide open lawns, mature trees, and a consistently good environment for small dogs on a group walk.',
leashNote: 'On-leash area.'
},
{
name: 'Westhaven Promenade',
description:
'The Westhaven foreshore walkway provides flat, scenic walking alongside the marina with harbour views and fresh sea air. A favourite for dogs who enjoy a coastal route.',
leashNote: 'On-leash area.'
},
{
name: 'Western Park',
description:
'Easily accessible from Freemans Bay, Western Park\'s shaded hillside paths offer a more enclosed and quieter alternative to the open parks nearby.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Pt Chevalier',
slug: 'pt-chevalier',
intro:
'Pt Chevalier has a relaxed, community-oriented feel and excellent park access — including beach reserves, wetland walkways, and open fields. It\'s a genuinely good suburb for dog walking and a regular part of our routes. Goodwalk serves Pt Chevalier for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Pt Chevalier Beach Reserve',
description:
'A popular local beach reserve with grassy areas, pohutukawa trees, and a relaxed foreshore setting. Dogs enjoy the sea air and the open space, and it\'s never too far from the water\'s edge.',
leashNote: 'On-leash area. Check Auckland Council signage for beach access rules by season.'
},
{
name: 'Meola Creek / Meola Reef',
description:
'A peaceful wetland walkway following Meola Creek out to the Meola Reef reserve on the foreshore. Excellent native bird habitat and a calm, scenic route that small dogs tend to enjoy.',
leashNote: 'On-leash to protect the wetland wildlife.'
},
{
name: 'Seddon Fields',
description:
'Large open sports fields with plenty of space for a good walk. Less structured than the beach reserve but useful for dogs who need more open room to stretch their legs.',
leashNote: 'On-leash area outside designated off-leash zones.'
}
]
},
{
suburb: 'Avondale',
slug: 'avondale',
intro:
'Avondale offers solid access to green space and the Oakley Creek walkway, one of the better dog walking routes in West Auckland. It sits on the western edge of our service area and is well-suited to dogs who enjoy more varied terrain. Goodwalk covers Avondale for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Oakley Creek / Te Auaunga Walkway',
description:
'The Oakley Creek walkway stretches through Avondale along a stream-side path lined with native plantings. One of the most enjoyable walking routes in the area — calm, green, and well away from traffic.',
leashNote: 'On-leash in most sections.'
},
{
name: 'Avondale Domain',
description:
'A local domain with open grass and easy paths — a reliable neighbourhood option for a straightforward, unhurried walk.',
leashNote: 'On-leash area.'
},
{
name: 'Hendon Park',
description:
'A quieter local reserve in Avondale with grassy open areas and a low-key atmosphere. Good for dogs who prefer a smaller, less busy setting.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Three Kings',
slug: 'three-kings',
intro:
'Three Kings is a quieter residential suburb with some genuinely interesting walking terrain — most notably the old quarry reserve that gives the suburb its character. It\'s well-positioned for pickup and sits within easy reach of Monte Cecilia Park. Goodwalk serves Three Kings for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Three Kings Reserve',
description:
'Formed from a former volcanic quarry, Three Kings Reserve is a dramatic and unusual park with steep rocky outcrops, native planting, and elevated views. An interesting change of scenery for dogs accustomed to flat suburban parks.',
leashNote: 'On-leash area.'
},
{
name: 'Monte Cecilia Park',
description:
'A large, well-landscaped park bordering Three Kings and Hillsborough with open lawns, mature trees, and walking paths. Popular with local dog walkers and a consistent favourite on our southern routes.',
leashNote: 'On-leash area.'
},
{
name: 'Winstone Park',
description:
'A local neighbourhood reserve with open grass and a calm environment — reliable for shorter walks and a regular stop for dogs in the Three Kings area.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Hillsborough',
slug: 'hillsborough',
intro:
'Hillsborough sits on the southern edge of our service area with access to Monte Cecilia Park and a network of quieter residential streets. It\'s a relaxed, lower-density suburb well-suited to dogs who do better on calmer, less congested walks. Goodwalk serves Hillsborough for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Monte Cecilia Park',
description:
'One of the southern central suburbs\' best parks — large open lawns, historic homestead grounds, and well-maintained walking paths through mature trees. A consistently good spot for a group outing.',
leashNote: 'On-leash area.'
},
{
name: 'Richardson Domain',
description:
'A large sports and recreation reserve with plenty of open space, wide paths, and a relaxed atmosphere. Good for dogs who need room to move without a lot of competing activity around them.',
leashNote: 'On-leash area in most sections.'
},
{
name: 'Hillsborough Reserve',
description:
'A smaller neighbourhood reserve within Hillsborough — useful for shorter walks and a reliable local option between the area\'s larger parks.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Eden Terrace',
slug: 'eden-terrace',
intro:
'Eden Terrace is a compact, centrally-located suburb with quick access to Myers Park and the Auckland Domain. Its urban density makes it an efficient pickup point, and dogs from Eden Terrace often join Tiny Gang outings to nearby Mt Eden and Grey Lynn parks. Goodwalk serves Eden Terrace for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Myers Park',
description:
'An urban park in the heart of the city with terraced gardens, shaded paths, and a pedestrian-friendly layout. A good option for a quick midday walk for dogs based in Eden Terrace.',
leashNote: 'On-leash area.'
},
{
name: 'Auckland Domain',
description:
'Auckland\'s oldest park, the Domain offers expansive lawns, tree-lined paths, and some of the city\'s best open green space. Just a short drive from Eden Terrace and a regular destination for our longer Tiny Gang outings.',
leashNote: 'On-leash area. Dogs are not permitted in the formal garden sections.'
},
{
name: 'Basque Park',
description:
'A small pocket park near the Newton Gully with quiet paths and native planting — useful as a local walking option for dogs in the immediate area.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Balmoral',
slug: 'balmoral',
intro:
'Balmoral is a well-established residential suburb with easy access to several parks used by our Tiny Gang. Its quiet streets and proximity to Mt Eden and Sandringham make it a natural part of our regular routes. Goodwalk serves Balmoral for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Balmoral Reserve',
description:
'A well-used neighbourhood reserve with open grass, walking paths, and a relaxed local atmosphere. A reliable everyday option for dogs based in Balmoral.',
leashNote: 'On-leash area.'
},
{
name: 'Potters Park',
description:
'On the Balmoral / Sandringham / Mt Eden boundary, Potters Park is a flat, open park and a regular stop on our central Auckland routes.',
leashNote: 'On-leash area.'
},
{
name: 'Cornwall Park',
description:
'Easily accessible from Balmoral, Cornwall Park\'s sweeping open lawns and wide paths make it one of Auckland\'s best walking parks — particularly suited to longer outings with a small group.',
leashNote: 'On-leash. Dogs are not permitted in fenced farm animal areas.'
}
]
},
{
suburb: 'Arch Hill',
slug: 'arch-hill',
intro:
'Arch Hill sits between Grey Lynn and Kingsland with good access to both Grey Lynn Park and the surrounding reserves. It\'s a compact suburb with a strong dog-owning community and a regular part of our pickup circuit. Goodwalk serves Arch Hill for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Arch Hill Reserve',
description:
'A hilltop reserve with native planting and elevated views — quieter than the main parks nearby and a good option for dogs who prefer a less busy environment.',
leashNote: 'On-leash area.'
},
{
name: 'Grey Lynn Park',
description:
'A short walk from Arch Hill, Grey Lynn Park is one of Auckland Central\'s most popular dog walking destinations with open lawns, wide paths, and a lively community feel.',
leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage.'
},
{
name: 'Fowlds Park',
description:
'Easily reached from Arch Hill via Kingsland, Fowlds Park provides generous open space and a reliable walking environment for dogs who need more room to move.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Mt Roskill',
slug: 'mt-roskill',
intro:
'Mt Roskill sits on the southern edge of our service area with access to Monte Cecilia Park and the Richardson Domain — two of the larger parks in the south-central Auckland belt. It\'s a well-connected suburb and a regular part of our extended routes. Goodwalk serves Mt Roskill for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Monte Cecilia Park',
description:
'One of the area\'s best parks — large, well-kept grounds with open lawns, historic gardens, and good walking paths under mature trees. A standout destination for group walks in the southern central suburbs.',
leashNote: 'On-leash area.'
},
{
name: 'Richardson Domain',
description:
'A large recreation reserve with wide open fields and walking paths. Excellent for dogs who need space and a good stretch without a lot of congestion.',
leashNote: 'On-leash area in most sections.'
},
{
name: 'Keith Hay Park',
description:
'A large sports and community park in Mt Roskill with open grass areas and a calm neighbourhood environment — a reliable local option for dogs in the immediate area.',
leashNote: 'On-leash area.'
}
]
}
];
export const locationsBySlug = Object.fromEntries(
locationPages.map((loc) => [loc.slug, loc])
);
+15 -10
View File
@@ -1,17 +1,22 @@
import type { PricingPageContent } from '$lib/types';
import { sharedServices } from '$lib/content/services';
import { dogWalkingContent } from './dog-walking';
import { packWalksContent } from './pack-walks';
import { puppyVisitsContent } from './puppy-visits';
const packWalksService = sharedServices.find((service) => service.title === 'Pack Walks');
const oneToOneService = sharedServices.find((service) => service.title === '1:1 Walks');
const puppyVisitsService = sharedServices.find((service) => service.title === 'Puppy Visits');
export const ourPricingContent: PricingPageContent = {
title: 'Our Pricing',
subtitle: 'Simple, transparent pricing — no lock-in contracts.',
subtitle: 'Choose the Goodwalk routine that fits your dog, your week, and the kind of support you need.',
sections: [
{
title: 'Pack Walks',
icon: 'fas fa-paw',
icon: packWalksService?.icon ?? 'fas fa-paw',
blurb:
'Small group adventures for calm, social dogs who thrive with structure, play, and regular weekly outings.',
'Our specialty for sociable small and medium dogs who thrive with calm structure, regular weekly outings, and the right dog company.',
detailCta: {
label: 'View Pack Walks',
href: '/pack-walks',
@@ -21,9 +26,9 @@ export const ourPricingContent: PricingPageContent = {
},
{
title: '1:1 Walks',
icon: 'fas fa-person-walking',
icon: oneToOneService?.icon ?? 'fas fa-person-walking',
blurb:
'One-on-one walks tailored to your dogs pace, confidence, and personality for a more focused outing.',
'A more individual option for dogs who need extra attention, more space, or a walk shaped around their own pace and confidence.',
detailCta: {
label: 'View 1:1 Walks',
href: '/dog-walking',
@@ -33,9 +38,9 @@ export const ourPricingContent: PricingPageContent = {
},
{
title: 'Puppy Visits',
icon: 'fas fa-dog',
icon: puppyVisitsService?.icon ?? 'fas fa-dog',
blurb:
'Short home visits for young pups who need company, enrichment, toilet breaks, and gentle routine support.',
'Home visits for young puppies who need company, toilet breaks, routine support, and a calmer start before they are ready for bigger adventures.',
detailCta: {
label: 'View Puppy Visits',
href: '/puppy-visits',
@@ -46,13 +51,13 @@ export const ourPricingContent: PricingPageContent = {
],
testimonialsHeading: 'What our clients say',
booking: {
title: 'Ready to join the Tiny Gang?',
title: 'Tell us about your dog',
subtitle: '',
formAction: '/contact-us',
serviceOptions: [],
ownerStepLabel: 'Your details',
dogStepLabel: 'Dog details',
dogStepLabel: 'Your dog',
dogIntro:
'Tell us about your dog, where you are based, and anything important we should know before we arrange a Meet & Greet.'
'Tell us about your dog, where you are based, and what kind of support you are looking for so we can help point you to the right Goodwalk service.'
}
};
+83 -42
View File
@@ -3,95 +3,136 @@ import type { ServicePageContent } from '$lib/types';
export const packWalksContent: ServicePageContent = {
hero: {
eyebrow: 'Pack Walks',
title: 'Join our Tiny Gang!',
title: 'Small-group pack walks for sociable small and medium dogs',
subtitle: 'Tiny Gang walks are social, active, and carefully matched for dogs who love the right company.',
paragraphs: [
'Fun, safe, and specially designed for little paws, these adventures help your dog build friendships and confidence in a calm, friendly group.',
'We only welcome sociable dogs, so every outing feels secure and stress-free. As small dog owners ourselves, we know just what it takes to help your pup feel relaxed, happy, and right at home.',
'Join the Tiny Gang today—because your dog deserves more than just a walk. They deserve a tail-wagging good time!'
'Goodwalk Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday.',
'Our Tiny Gang packs stay small, calm, and carefully matched, so sociable dogs can build confidence, enjoy safe group outings, and come home settled instead of overstimulated.',
'Tiny Gang is best suited to sociable small and medium dogs who enjoy being around other dogs. If your dog would be better with a quieter, more individual setup, our 1:1 walks may be a better fit.',
'We run pack walks across Auckland Central — including Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, and surrounding suburbs — with free pickup and drop-off included in every booking.'
],
imageUrl: '/images/auckland-small-dog-pack-walk.jpg',
imageAlt: 'Small dogs together on a Goodwalk Tiny Gang pack walk'
imageUrl: '/images/auckland-pack-walk-small-dogs-group.jpg',
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park",
chips: [
{ icon: 'fas fa-users', label: 'Small groups · 48 dogs' },
{ icon: 'fas fa-tag', label: 'From $49.50 / walk' },
{ icon: 'fas fa-car', label: 'Free pickup & drop-off' }
],
cta: { label: 'See if Tiny Gang fits your dog', href: '#newlead', variant: 'yellow' }
},
highlight: {
eyebrow: '▼・ᴥ・▼',
title: 'Goodwalk is the best choice for small and medium size dogs!',
imageUrl: '/images/tiny-gang-auckland-dog-pack.jpg',
imageAlt: 'Goodwalk Tiny Gang dogs gathered together in Auckland'
},
pricing: {
title: 'Tiny Gang Prices',
intro:
'Our pack walks are a permanent booking of at least one walk day a week. Our Tiny Gang pack outing typically lasts 2 hours or more, including a one-hour walk at one of Aucklands scenic dog parks or beaches. Additionally, pick-up and drop-off services are provided for your convenience. We assist in reinforcing basic training, including recall, car manners, and leash etiquette. Gift your dog the best life!',
plans: [
eyebrow: 'What Tiny Gang is',
title: 'A small-group walking routine for sociable dogs who love the right company',
imageUrl: '/images/small-medium-dogs-pack-walk.jpg',
imageAlt: 'Small and medium dogs together on a Goodwalk pack walk in Auckland',
points: [
{
title: '1 Walk',
price: '$58',
period: 'Per Walk',
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
title: 'Small, social groups',
body:
'Tiny Gang walks run in carefully matched groups of 4-8 dogs, with real play, movement, and social time without the chaos of oversized packs.'
},
{
title: '2-3 Walks',
title: 'Best for the right dogs',
body:
'These walks suit sociable small and medium dogs who enjoy company and tend to do well in a shared, active environment. If your dog needs more space, 1:1 walks may be a better fit.'
},
{
title: 'Exercise with a weekly rhythm',
body:
'Most owners use Tiny Gang as a regular weekday routine, with pickup and drop-off included across Auckland Central to make the whole thing easier to stick to.'
}
]
},
pricing: {
title: 'Choose the weekly routine that suits your dog',
intro:
'Choose the routine that gives your dog the right amount of exercise, social time, and consistency each week.',
plans: [
{
title: '1 Walk Per Week',
price: '$58',
period: 'Per Walk',
features: ['One regular walk each week', 'Free pickup and drop-off', 'Calm small-group outing', 'Best for dogs starting out']
},
{
title: '2-3 Walks Per Week',
price: '$55',
period: 'Per Walk',
popular: true,
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
features: ['Two to three regular walks each week', 'Free pickup and drop-off', 'Consistent exercise and social time', 'Best fit for busy owners']
},
{
title: '4-5 Walks',
title: '4-5 Walks Per Week',
price: '$49.50',
period: 'Per Walk',
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
features: ['Four to five regular walks each week', 'Free pickup and drop-off', 'Maximum consistency and structure', 'Best for high-energy social dogs']
},
{
title: 'Casual Walk',
title: 'Casual Pack Walk',
price: '$65',
period: 'Per Walk',
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
features: ['Casual availability only', 'Free pickup and drop-off', 'For dogs already suited to pack walks', 'Higher rate than weekly routines']
}
],
extras: [
{ label: 'Extra Dog', note: 'From same household', price: '$35' },
{ label: 'Muddy Wash', price: '$35' },
{ label: '5 Hour Day Out', note: 'Not suitable for all dogs', price: '$90' }
]
],
scarcityNote: 'We keep packs small (4-8 dogs) — popular days fill up fast.'
},
benefits: {
title: 'Tiny Gang membership benefits',
title: 'Why the right dogs thrive in Tiny Gang',
intro:
'Small, compatible groups give dogs the exercise, confidence, and routine they need without the chaos of oversized pack walks.',
items: [
{
title: 'Socialisation with other dogs',
body: 'Tiny Gang pack walks help small and medium-sized dogs mingle and learn social skills from each other, boosting their confidence and positive behaviour.'
title: 'Calmer evenings at home',
body: 'Small, structured outings help dogs burn energy without overstimulation, so they come home settled, content, and ready to rest.',
badge: 'Structured weekly walks',
icon: 'fas fa-house'
},
{
title: 'Tailored pace',
body: 'Our handlers can adjust the pace and intensity of the walk to suit the energy levels and abilities of small and medium-sized dogs, ensuring a pleasant and enjoyable experience for all participants.'
title: 'Confidence with the right dogs',
body: 'Carefully matched groups help sociable dogs enjoy company at the right pace, without the pressure of bigger mixed packs.',
badge: 'Carefully matched groups',
icon: 'fas fa-user-group'
},
{
title: 'Comfort',
body: 'Smaller groups create a more relaxed and comfortable atmosphere for dogs, allowing them to explore and enjoy the walk without feeling overwhelmed by larger dogs.'
title: 'No overwhelming pack dynamics',
body: 'Tiny Gang is designed for small and medium dogs, with group size, pace, and play style matched to help them feel safe.',
badge: 'Small & medium dogs',
icon: 'fas fa-shield-dog'
},
{
title: 'Increased bonding',
body: 'Tiny Gang pack walks foster stronger bonds between dogs and their walker, as well as between the dogs themselves, enhancing trust and companionship among the group.'
title: 'A routine owners can rely on',
body: 'Regular weekly slots give busy owners dependable exercise support and give dogs the comfort of a familiar rhythm.',
badge: 'Reliable weekly slots',
icon: 'fas fa-calendar-check'
},
{
title: 'Individualised attention',
body: 'Small pack sizes allow for more personalised care and attention from the walker, addressing the unique needs and preferences of small and medium-sized breeds.'
title: 'Individual attention still matters',
body: 'Smaller groups mean our walkers can notice confidence, handling, behaviour, and the little details that make a difference.',
badge: '48 dogs per walk',
icon: 'fas fa-eye'
},
{
title: 'Safety',
body: "With a smaller group composed of dogs of similar sizes, there's reduced risk of accidental injury or intimidation, ensuring a safer walking environment."
title: 'Safety is built into the group',
body: 'Calm, compatible packs reduce intimidation and create a safer walking environment than a one-size-fits-all approach.',
badge: 'Calm, compatible packs',
icon: 'fas fa-shield-heart'
}
]
},
testimonialsHeading: 'What our clients say',
booking: {
title: 'Join the Tiny Gang!',
title: 'See if your dog fits our Tiny Gang',
subtitle: '',
formAction: '/contact-us',
serviceOptions: [],
ownerStepLabel: 'Your details',
dogStepLabel: 'Dog details',
dogIntro: 'Tell us about your dog and where you are based so we can plan the right Tiny Gang Meet & Greet.'
dogIntro:
'Tell us about your small or medium dog, where you are based, and anything important we should know so we can see if Tiny Gang is the right fit.'
}
};
+51 -23
View File
@@ -3,65 +3,93 @@ import type { ServicePageContent } from '$lib/types';
export const puppyVisitsContent: ServicePageContent = {
hero: {
eyebrow: 'Puppy Visits',
title: 'Introducing Puppy Visits: Building strong foundations for our pack walks!',
title: 'Give your puppy a calmer start while you are out',
subtitle: 'Toilet breaks, play, feeding, and calm one-on-one attention — at home, while you\'re out.',
paragraphs: [
"We love puppies! Our puppy home visits are perfect for young pups not quite ready to join the pack and busy owners with hectic schedules. We lay the groundwork for future pack walks, including fun games, potty breaks, and even feeding if required. Let us help your furry friend thrive while you're away!"
'Goodwalk Puppy Visits are designed for busy owners who want their puppy cared for properly during the day, with toilet breaks, play, feeding, and calm one-on-one attention at home.',
'They are also the first stage of the Goodwalk journey. For puppies who may later join our Pack Walks, these visits help build familiarity, confidence, and the early routines that make that transition much smoother.',
'Instead of just getting through the day, your puppy gets a more thoughtful start, and you get more peace of mind while you are away.',
'We offer puppy visits across Auckland Central including Mt Eden, Ponsonby, Grey Lynn, Kingsland, Sandringham, Herne Bay, and surrounding suburbs.'
],
imageUrl: '/images/auckland-puppy-home-visit.jpg',
imageAlt: 'Puppy Visits page splash image'
imageAlt: 'Puppy receiving a calm Goodwalk home visit in Auckland',
chips: [
{ icon: 'fas fa-house', label: 'In-home visit' },
{ icon: 'fas fa-tag', label: 'From $39 / visit' },
{ icon: 'fas fa-map-marker-alt', label: 'Auckland Central' }
],
cta: { label: 'Book a free Meet & Greet', href: '#newlead', variant: 'yellow' }
},
highlight: {
eyebrow: 'Start well. Grow well.',
title: 'A home visit now can help set your puppy up for calmer routines and future Pack Walks later on',
imageUrl: '/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg',
imageAlt: 'Young Cavalier King Charles Spaniel puppy resting at home before future Goodwalk Pack Walk training in Auckland'
},
pricing: {
title: 'Puppy Visits',
title: 'Choose the visit length that suits your puppy',
intro:
'Puppy Visits are built around your puppys age, routine, and energy levels, with practical support now and foundations for later social walking if they are a good fit for our Tiny Gang.',
plans: [
{
title: '20 Minutes',
title: '20 Minute Visit',
price: '$39',
period: 'Per Visit',
features: ['Bathroom break', 'Pet feed', 'Basic training', 'Enrichment games']
features: ['Toilet break and check-in', 'Feeding if needed', 'Gentle one-on-one attention', 'Good for shorter midday support']
},
{
title: '45 Minutes',
title: '45 Minute Visit',
price: '$49',
period: 'Per Visit',
features: ['Bathroom break', 'Pet feed', 'Basic training', 'Enrichment games']
features: ['Toilet break and feeding if needed', 'Play and enrichment time', 'Early routine-building support', 'Best fit for many puppies']
},
{
title: '1 Hour',
title: '60 Minute Visit',
price: '$55',
period: 'Per Visit',
features: ['Bathroom break', 'Pet feed', 'Basic training', 'Enrichment games']
features: ['Longer home visit', 'More play, settling, and engagement', 'Extra support for younger puppies', 'Best for pups needing more time']
}
]
],
scarcityNote: 'Puppy Visit spaces are limited so we can keep care consistent.'
},
benefits: {
title: 'Puppy Visits benefits',
title: 'Why Puppy Visits matter early',
items: [
{
title: 'Enrichment',
body: 'From stimulating games to sensory toys, we keep those curious minds engaged and little tails wagging.'
title: 'Fewer long stretches alone',
body: 'Regular visits break up the day, help with toilet timing, and give your puppy company, care, and comfort while you are out.'
},
{
title: 'Setting up the basics for pack walks',
body: "Lay the groundwork for your pup's adult life. We'll guide you through setting the right tone, offering basic training tips and tricks along the way."
title: 'Better foundations for future Pack Walks',
body: 'For puppies who may later join our Tiny Gang, early visits help build confidence, familiarity, and the routines that support a smoother next step.'
},
{
title: 'Reduce anxiety',
body: "With time your pup will know when to expect a visit, reducing the chances of accidents while you're away. With regular visits, your pup will feel loved and secure, minimising any time spent at home alone."
title: 'A calmer puppy at home',
body: 'Play, enrichment, and routine help use up some puppy energy in the right way, which can mean a more settled puppy through the rest of the day.'
},
{
title: 'Expert advice',
body: "As experienced dog pawrents, we've been through it all with many adorable puppies. Consider us your go-to for any questions or concerns as your furry friend grows up."
title: 'Support for busy owners too',
body: 'You get practical help during a demanding stage, plus guidance from a team that understands how much consistency matters when puppies are learning fast.'
},
{
title: 'Early habits start taking shape',
body: 'Visits give us time to reinforce the basics around handling, routine, and calm engagement before those small habits become bigger problems.'
},
{
title: 'A more personal start with Goodwalk',
body: 'Puppy Visits help your puppy get to know us early, which builds trust and makes any future transition into other Goodwalk services feel more natural.'
}
]
},
testimonialsHeading: 'What our clients say',
booking: {
title: 'Ready to join the Tiny Gang?',
title: 'See if Puppy Visits are the right start',
subtitle: '',
formAction: '/contact-us',
serviceOptions: [],
ownerStepLabel: 'Your details',
dogStepLabel: 'Dog details',
dogIntro: 'Tell us about your puppy, your area, and any special needs so we can plan the right visit.'
dogStepLabel: 'Puppy details',
dogIntro:
'Tell us about your puppy, your area, routine, and any special needs so we can plan the right visit and see what support fits best.'
}
};
+39
View File
@@ -0,0 +1,39 @@
export interface SharedServiceDefinition {
title: 'Pack Walks' | '1:1 Walks' | 'Puppy Visits';
href: string;
icon: string;
megaMenuDescription: string;
cardBody: string;
locationDescription: string;
priceFrom: string;
}
export const sharedServices: SharedServiceDefinition[] = [
{
title: 'Pack Walks',
href: '/pack-walks',
icon: 'fas fa-paw',
megaMenuDescription: 'Tiny Gang outdoor adventures',
cardBody: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.',
locationDescription: 'Small group outings for sociable dogs. From $49.50.',
priceFrom: 'From $49.50 / walk'
},
{
title: '1:1 Walks',
href: '/dog-walking',
icon: 'fas fa-person-walking',
megaMenuDescription: 'Personalised solo walks',
cardBody: "One-on-one walks tailored to your dog's individual pace, personality, and needs.",
locationDescription: 'One dog, full attention, tailored pace.',
priceFrom: 'From $45 / walk'
},
{
title: 'Puppy Visits',
href: '/puppy-visits',
icon: 'fas fa-dog',
megaMenuDescription: 'Home visits for young pups',
cardBody: 'In-home visits to check in on your puppy, play, and keep them company.',
locationDescription: 'In-home care for puppies during the day.',
priceFrom: 'From $39 / visit'
}
];
+17 -14
View File
@@ -1,48 +1,51 @@
export const staticPages = {
'pack-walks': {
title: 'Pack Walks | Join Our Tiny Gang',
title: 'Pack Walks for Small Dogs | Mt Eden, Kingsland & Auckland Central | Goodwalk',
description:
'Join our Tiny Gang pack walks. We take our dogs to beautiful parks and beaches around the Auckland region.',
'Tiny Gang pack walks for small and medium dogs across Mt Eden, Kingsland, Ponsonby, Grey Lynn and Auckland Central. Small groups, calm outings, free pickup and drop-off.',
canonicalPath: '/pack-walks'
},
'dog-walking': {
title: '1 on 1 Walks | Professional Dog Walking | Auckland Wide',
title: '1:1 Dog Walks Auckland | Mt Eden, Ponsonby & Kingsland | Goodwalk',
description:
'Our 1:1 (one on one) are perfect for dogs with great recall and leash manners, our walks guarantee a stress-free experience!',
'One-on-one dog walks across Auckland Central — Mt Eden, Kingsland, Ponsonby, Grey Lynn and more. For dogs who need more space, attention, and a calmer routine.',
canonicalPath: '/dog-walking'
},
'puppy-visits': {
title: 'Puppy Visits | Auckland In-Home Puppy Care | Goodwalk',
title: 'Puppy Visits Auckland Central | Mt Eden, Ponsonby & Grey Lynn | Goodwalk',
description:
'In-home puppy visits across Auckland Central — toilet breaks, feeding, play and gentle early training for pups not yet ready for pack walks.',
'In-home puppy visits across Mt Eden, Ponsonby, Grey Lynn, Kingsland and Auckland Central. Toilet breaks, feeding, play, and calm one-on-one care while you are out.',
canonicalPath: '/puppy-visits'
},
'our-pricing': {
title: 'Our Pricing',
title: 'Dog Walking Prices Auckland | Pack Walks & 1:1 Walks | Goodwalk',
description:
'Learn more about the pricing for Goodwalk. Prices for our Tiny Gang pack walks and 1 on 1 solo walks.',
'Transparent pricing for Goodwalk pack walks, 1:1 dog walks, and puppy visits across Auckland Central. From $49.50 per walk. Free Meet & Greet included.',
canonicalPath: '/our-pricing'
},
about: {
title: 'About Us | Dog Walkers',
title: 'About Goodwalk | Dog Walkers in Mt Eden, Kingsland & Auckland Central',
description:
'Learn more about Kingsland based dog walking company Goodwalk. We offer our Tiny Gang pack walks throughout the Auckland region. Solo (1:1 walks), homestays and more.',
'Meet Alessandra, founder of Goodwalk — Auckland Central\'s small dog walking specialist. Serving Mt Eden, Kingsland, Ponsonby, Grey Lynn and surrounding suburbs with 30+ five-star reviews.',
canonicalPath: '/about'
},
'contact-us': {
title: 'Contact Us',
description: 'Book a Meet & Greet or send a general enquiry to Goodwalk Auckland dog walking services.',
title: 'Book a Dog Walker in Auckland | Contact Goodwalk',
description:
'Book a free Meet & Greet or send an enquiry to Goodwalk. Auckland Central dog walking specialists serving Mt Eden, Kingsland, Ponsonby, Grey Lynn and surrounding suburbs.',
canonicalPath: '/contact-us'
},
'terms-and-conditions': {
title: 'Terms & Conditions',
description: 'Terms and conditions for Goodwalk Auckland dog walking services.',
canonicalPath: '/terms-and-conditions'
canonicalPath: '/terms-and-conditions',
noindex: true
},
'privacy-policy': {
title: 'Privacy Policy',
description: 'Privacy policy for Goodwalk Auckland dog walking services.',
canonicalPath: '/privacy-policy'
canonicalPath: '/privacy-policy',
noindex: true
}
} as const;
+17
View File
@@ -0,0 +1,17 @@
import type { Picture } from '@sveltejs/enhanced-img';
// In dev, imagetools can be painfully slow for large local assets.
// Fall back to the already-served static files and keep enhanced variants for production builds.
const modules: Record<string, { default: Picture }> = import.meta.env.DEV
? {}
: (import.meta.glob('./images/**/*.{jpg,jpeg,png,webp,avif,gif}', {
eager: true,
query: { enhanced: true }
}) as Record<string, { default: Picture }>);
export function getEnhancedImage(src: string | undefined | null): Picture | null {
if (!src) return null;
// '/images/foo.png' -> './images/foo.png' (relative to src/lib/)
const key = '.' + src;
return modules[key]?.default ?? null;
}
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('feature flags', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('defaults the mobile CTA button to disabled', async () => {
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
expect(isMobileCtaButtonEnabled()).toBe(false);
});
it('enables the mobile CTA button when the public env flag is truthy', async () => {
vi.stubEnv('PUBLIC_ENABLE_MOBILE_CTA_BUTTON', 'enabled');
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
expect(isMobileCtaButtonEnabled()).toBe(true);
});
it('treats explicit false values as disabled', async () => {
vi.stubEnv('PUBLIC_ENABLE_MOBILE_CTA_BUTTON', 'off');
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
expect(isMobileCtaButtonEnabled()).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
export function parseBooleanFlag(value: string | undefined, defaultValue = false) {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) {
return false;
}
return defaultValue;
}
export function isMobileCtaButtonEnabled() {
return parseBooleanFlag(import.meta.env.PUBLIC_ENABLE_MOBILE_CTA_BUTTON, false);
}
+11 -5
View File
@@ -8,18 +8,24 @@ const imageMetadata: Record<string, ImageMetadata> = {
'/images/goodwalk-auckland-dog-walking-logo-mobile.png': { width: 206, height: 41 },
'/images/auckland-dog-walking-happy-dog-hero.png': { width: 500, height: 500 },
'/images/auckland-dog-walking-happy-dogs-happy-humans.webp': { width: 1222, height: 1312 },
'/images/archie-auckland-dog-walking-review.png': { width: 1122, height: 1402 },
'/images/monty-auckland-dog-walking-review.png': { width: 1254, height: 1254 },
'/images/otis-auckland-dog-walking-review.png': { width: 1254, height: 1254 },
'/images/wallace-auckland-dog-walking-review.png': { width: 1254, height: 1254 },
'/images/archie-auckland-dog-walking-review.jpg': { width: 1122, height: 1402 },
'/images/monty-auckland-dog-walking-review.jpg': { width: 1254, height: 1254 },
'/images/otis-auckland-dog-walking-review.jpg': { width: 1254, height: 1254 },
'/images/wallace-auckland-dog-walking-review.jpg': { width: 1254, height: 1254 },
'/images/auckland-small-dog-pack-walk.jpg': { width: 640, height: 480 },
'/images/auckland-pack-walk-small-dogs-group.jpg': { width: 1469, height: 1071 },
'/images/small-medium-dogs-pack-walk.jpg': { width: 1240, height: 1269 },
'/images/one-on-one-dog-portrait-1.jpg': { width: 1054, height: 1492 },
'/images/one-on-one-dog-portrait-2.jpg': { width: 1091, height: 1441 },
'/images/one-on-one-dog-portrait-3.jpg': { width: 1124, height: 1399 },
'/images/tiny-gang-auckland-dog-pack.jpg': { width: 1024, height: 297 },
'/images/auckland-large-dog-one-on-one-walk.jpg': { width: 1024, height: 970 },
'/images/auckland-dogs-outdoor-pack.jpg': { width: 1024, height: 297 },
'/images/auckland-puppy-home-visit.jpg': { width: 640, height: 427 },
'/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg': { width: 3327, height: 2217 },
'/images/auckland-pack-walk-dog.jpg': { width: 480, height: 640 },
'/images/auckland-dog-group-outing.jpg': { width: 640, height: 480 },
'/images/goodwalk-dog-walker-alessandra.png': { width: 640, height: 640 }
'/images/founder-image-aless-goodwalk.jpg': { width: 1076, height: 1461 }
};
export function getImageMetadata(src: string | undefined | null): ImageMetadata | null {
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 262">
<path fill="#4285F4" d="M255.68 133.53c0-8.89-.8-17.43-2.29-25.63H130.8v48.48h70.06c-3.02 16.31-12.2 30.12-25.99 39.35v32.65h41.95c24.54-22.6 38.86-55.92 38.86-94.85Z"/>
<path fill="#34A853" d="M130.8 261.1c35.1 0 64.53-11.63 86.04-31.55l-41.95-32.65c-11.63 7.79-26.53 12.38-44.09 12.38-33.88 0-62.58-22.88-72.83-53.62H14.6v33.67c21.39 42.42 65.29 71.77 116.2 71.77Z"/>
<path fill="#FBBC05" d="M57.97 155.65c-2.61-7.79-4.09-16.11-4.09-24.65s1.48-16.86 4.09-24.65V72.68H14.6C5.28 91.24 0 110.62 0 131s5.28 39.76 14.6 58.32l43.37-33.67Z"/>
<path fill="#EA4335" d="M130.8 52.72c19.08 0 36.23 6.57 49.72 19.48l37.29-37.29C195.28 13.35 165.87 0 130.8 0 79.89 0 35.99 29.35 14.6 72.68l43.37 33.67c10.25-30.74 38.95-53.63 72.83-53.63Z"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Some files were not shown because too many files have changed in this diff Show More