Compare commits
9 Commits
7edd4c7f9d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| baafafabdb | |||
| de8b60b9c3 | |||
| 6c943b14bd | |||
| ac6179e776 | |||
| 955a563d14 | |||
| a90dfb7c66 | |||
| 0d86f450ec | |||
| 4d70993817 | |||
| 32ccd49d78 |
@@ -3,7 +3,8 @@
|
||||
"allow": [
|
||||
"Bash(kill %1)",
|
||||
"Bash(pkill -f \"vite dev\")",
|
||||
"Bash(npm run *)"
|
||||
"Bash(npm run *)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,7 @@ It is created from [deploy.env.template](deploy.env.template). Current template
|
||||
```env
|
||||
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:
|
||||
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
# Goodwalk Design Language (2026 Refined Edition)
|
||||
|
||||
This document is the source of truth for Goodwalk’s 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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 600–900 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 ~150–300ms TTFB from geography. NGINX has Gzip at level 6 but no Brotli (15–25% 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 (140–160 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 57–75
|
||||
|
||||
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 (~40–55% 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 2–3 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.
|
||||
|
||||
---
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
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
|
||||
@@ -33,7 +34,7 @@ services:
|
||||
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}
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mattcohen0@gmail.com": {
|
||||
"fullName": "Matt Test",
|
||||
"phone": "02124347477",
|
||||
"dogName": "Geoffrey"
|
||||
},
|
||||
"natalie@desseinparke.com": {
|
||||
"fullName":"Natalie Parke",
|
||||
"phone": "021616200",
|
||||
"dogName": "Ziggy"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 + '/')
|
||||
);
|
||||
@@ -7,31 +7,6 @@
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://cdnjs.cloudflare.com"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<!-- Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-K7TLSFJVP1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-K7TLSFJVP1');
|
||||
</script>
|
||||
<style>
|
||||
.no-js-overlay {
|
||||
position: fixed;
|
||||
@@ -52,7 +27,7 @@
|
||||
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.2);
|
||||
text-align: left;
|
||||
font-family: 'Readex Pro', system-ui, sans-serif;
|
||||
color: #213021;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.no-js-kicker {
|
||||
@@ -65,7 +40,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #213021;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.no-js-card h1 {
|
||||
@@ -74,7 +49,7 @@
|
||||
font-size: clamp(28px, 4vw, 38px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #213021;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.no-js-card p {
|
||||
@@ -100,5 +75,13 @@
|
||||
</div>
|
||||
</noscript>
|
||||
<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 || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-K7TLSFJVP1');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
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;
|
||||
@@ -10,31 +11,28 @@
|
||||
|
||||
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 & 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} />
|
||||
@@ -45,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(34px, 4vw, 56px);
|
||||
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 {
|
||||
@@ -91,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;
|
||||
}
|
||||
@@ -102,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;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,38 @@
|
||||
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 dog’s 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 puppy’s age, routine, toilet needs, feeding schedule, and anything important we should know before visiting.'
|
||||
}
|
||||
};
|
||||
|
||||
let step = 1;
|
||||
$: headingParts = splitBookingTitle(booking.title);
|
||||
@@ -76,7 +107,9 @@
|
||||
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') {
|
||||
@@ -90,6 +123,16 @@
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
|
||||
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
|
||||
$: bookingEyebrow = isGeneralEnquiry ? 'Friendly contact' : primarySelectedService || 'Free Meet & Greet';
|
||||
$: bookingIntro = isGeneralEnquiry
|
||||
? 'Send us the details and we’ll point you in the right direction.'
|
||||
: 'Tell us a little about your dog first. We’ll 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(() => {
|
||||
@@ -98,6 +141,19 @@
|
||||
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) {
|
||||
@@ -169,11 +225,51 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -342,10 +438,22 @@
|
||||
{/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>
|
||||
</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 & Greet
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="booking-stepper" aria-label="Booking form steps">
|
||||
<button
|
||||
@@ -438,113 +546,138 @@
|
||||
{/if}
|
||||
|
||||
{#if !isGeneralEnquiry}
|
||||
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
|
||||
<label for="petName">
|
||||
<Icon name="fas fa-dog" /> 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" /> 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-stack" class:booking-field-stack-invalid={errors.location}>
|
||||
<label for="location">
|
||||
<Icon name="fas fa-location-dot" /> 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" /> {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" /> 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-wide"
|
||||
class:booking-field-card-invalid={errors.location}
|
||||
class="booking-field-card booking-field-card-full"
|
||||
class:booking-field-card-invalid={errors.message}
|
||||
>
|
||||
<label for="location">
|
||||
<Icon name="fas fa-location-dot" /> Location <span class="booking-required">*</span>
|
||||
<label for="message">
|
||||
<Icon name="fas fa-comment" /> {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" /> {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" /> 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">Response from us within 24 hours</p>
|
||||
<p class="booking-next-note">No payment, no pressure, just the right starting point for your dog.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<input type="hidden" name="fullName" value={fullName} />
|
||||
@@ -652,7 +785,13 @@
|
||||
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 & Greet <Icon name="fas fa-arrow-right" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -24,7 +24,7 @@ async function fillDogStep() {
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Kingsland' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Pack Walks fit/i), {
|
||||
target: { value: 'Loves small group walks.' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 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!'
|
||||
? "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}>
|
||||
✕
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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,47 +89,13 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-action footer-panel footer-panel-accent">
|
||||
<p class="footer-col-label">Get Started</p>
|
||||
<h3 class="footer-action-title">Ready when you are</h3>
|
||||
<p class="footer-action-copy">Questions, pricing, or your first Meet & Greet. Start here and we’ll reply within 24 hours.</p>
|
||||
<a href="/contact-us" class="footer-book-btn">
|
||||
Contact Us
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
<p class="footer-book-note">Friendly, no-pressure first step</p>
|
||||
<a
|
||||
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="footer-reviews"
|
||||
>
|
||||
<img
|
||||
class="footer-google-logo"
|
||||
src="/images/google-g-logo.svg"
|
||||
alt=""
|
||||
width="16"
|
||||
height="17"
|
||||
/>
|
||||
<span>30+ five-star 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>
|
||||
|
||||
@@ -122,5 +105,6 @@
|
||||
<a href="/terms-and-conditions">Terms & 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>
|
||||
@@ -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 & Medium Dog Specialists</span>
|
||||
<span class="nav-ribbon-divider"></span>
|
||||
<span class="nav-ribbon-item"><Icon name="fas fa-handshake" />Free Meet & Greet</span>
|
||||
<span class="nav-ribbon-divider"></span>
|
||||
<span class="nav-ribbon-item"><Icon name="fas fa-van-shuttle" />Free Pickup & 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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { getImageMetadata } from '$lib/image-metadata';
|
||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||
import type { CallToAction, HeroContent } from '$lib/types';
|
||||
|
||||
export let hero: HeroContent;
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
$: 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();
|
||||
@@ -25,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>
|
||||
@@ -39,11 +75,24 @@
|
||||
<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}
|
||||
@@ -71,21 +120,24 @@
|
||||
{/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>
|
||||
|
||||
@@ -7,44 +7,287 @@
|
||||
</script>
|
||||
|
||||
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
|
||||
<div class="how-it-works-inner">
|
||||
<div class="how-it-works-header">
|
||||
<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="how-it-works-intro">{content.intro}</p>
|
||||
<p class="hiw-intro">{content.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="how-it-works-flow" aria-label="How it works">
|
||||
<div class="hiw-steps">
|
||||
{#each content.steps as step, index}
|
||||
<article class="how-it-works-step">
|
||||
<div class="how-it-works-badge" aria-hidden="true">
|
||||
<span class="how-it-works-count">0{index + 1}</span>
|
||||
<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>
|
||||
{#if step.icon}
|
||||
<div class="how-it-works-icon-bubble">
|
||||
<Icon name={step.icon} className="how-it-works-icon" />
|
||||
</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}
|
||||
<h3>{step.title}</h3>
|
||||
<p>{step.body}</p>
|
||||
</article>
|
||||
|
||||
{#if index < content.steps.length - 1}
|
||||
<div class="how-it-works-connector" aria-hidden="true">
|
||||
<span class="how-it-works-connector-line"></span>
|
||||
<div class="how-it-works-connector-bubble">
|
||||
<Icon name="fas fa-paw" className="how-it-works-connector-icon" />
|
||||
</div>
|
||||
<span class="how-it-works-connector-line"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hiw-cta">
|
||||
<a href="#newlead" class="btn btn-green btn-mobile-center">Book your free Meet & 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);
|
||||
@@ -58,202 +301,4 @@
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
#how-it-works {
|
||||
background: #fff;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.how-it-works-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.how-it-works-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.how-it-works-intro {
|
||||
max-width: 640px;
|
||||
margin: 14px auto 0;
|
||||
color: #4c5056;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.how-it-works-flow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.how-it-works-step {
|
||||
position: relative;
|
||||
padding: 26px 24px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at top center, rgba(255, 209, 0, 0.18), transparent 36%),
|
||||
linear-gradient(180deg, #fffaf0 0%, #f8f4ea 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
|
||||
0 14px 28px rgba(17, 20, 24, 0.04);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.how-it-works-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
|
||||
}
|
||||
|
||||
.how-it-works-count {
|
||||
color: var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.how-it-works-icon-bubble {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 16px 28px rgba(33, 48, 33, 0.16);
|
||||
}
|
||||
|
||||
:global(.how-it-works-icon.icon) {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.how-it-works-step h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.how-it-works-step p {
|
||||
margin: 0;
|
||||
color: #4c5056;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.how-it-works-connector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.how-it-works-connector-line {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.22);
|
||||
}
|
||||
|
||||
.how-it-works-connector-bubble {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #254129 0%, #213021 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 14px 24px rgba(33, 48, 33, 0.14);
|
||||
}
|
||||
|
||||
:global(.how-it-works-connector-icon.icon) {
|
||||
color: var(--green);
|
||||
color: #ffd54a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#how-it-works {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.how-it-works-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.how-it-works-intro {
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.how-it-works-flow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.how-it-works-step {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
'icon title badge'
|
||||
'body body body';
|
||||
column-gap: 14px;
|
||||
row-gap: 10px;
|
||||
padding: 20px 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.how-it-works-badge {
|
||||
grid-area: badge;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin-bottom: 0;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.how-it-works-icon-bubble {
|
||||
grid-area: icon;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0;
|
||||
align-self: start;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
0 12px 22px rgba(33, 48, 33, 0.14);
|
||||
}
|
||||
|
||||
.how-it-works-step h3 {
|
||||
grid-area: title;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.how-it-works-step p {
|
||||
grid-area: body;
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.how-it-works-connector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<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())
|
||||
.filter(Boolean);
|
||||
.map((suburb) => suburb.trim().replace(/\.$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((suburb) => ({ name: suburb, slug: slugBySuburb.get(suburb) ?? null }));
|
||||
</script>
|
||||
|
||||
<section id="info">
|
||||
@@ -18,8 +23,12 @@
|
||||
<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 suburb}
|
||||
<span class="info-suburb-chip">{suburb}</span>
|
||||
{#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>
|
||||
|
||||
@@ -39,7 +48,7 @@
|
||||
|
||||
<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>
|
||||
@@ -78,10 +87,17 @@
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
|
||||
0 10px 24px rgba(17, 20, 24, 0.04);
|
||||
color: #213021;
|
||||
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 {
|
||||
@@ -103,7 +119,7 @@
|
||||
|
||||
.info-nearby-kicker {
|
||||
display: inline-block;
|
||||
color: var(--green);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -119,7 +135,7 @@
|
||||
min-height: 48px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
background: var(--gw-green);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
export let instagram: HomePageContent['instagram'];
|
||||
|
||||
const dogCutoutSrc = '/images/smiling-dogs-instagram-cta.png';
|
||||
</script>
|
||||
|
||||
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -89,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;
|
||||
}
|
||||
@@ -149,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%);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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>
|
||||
@@ -3,6 +3,7 @@
|
||||
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.
|
||||
@@ -20,8 +21,14 @@
|
||||
* to book while they're already on the form).
|
||||
*/
|
||||
|
||||
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
|
||||
|
||||
$: pathname = $page.url.pathname;
|
||||
$: hidden = pathname === '/contact-us' || pathname === '/booking';
|
||||
$: 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;
|
||||
@@ -41,7 +48,7 @@
|
||||
}
|
||||
|
||||
async function setupObservers() {
|
||||
if (typeof window === 'undefined') {
|
||||
if (!mobileCtaButtonEnabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,6 +91,10 @@
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
if (!mobileCtaButtonEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
visible = false;
|
||||
triggerPassed = false;
|
||||
bookingInView = false;
|
||||
@@ -91,6 +102,10 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!mobileCtaButtonEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setupObservers();
|
||||
|
||||
return () => {
|
||||
@@ -99,7 +114,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !hidden}
|
||||
{#if mobileCtaButtonEnabled && !hidden}
|
||||
<div
|
||||
class="mobile-book-bar"
|
||||
class:mobile-book-bar-visible={visible}
|
||||
|
||||
@@ -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}>
|
||||
✕
|
||||
</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>
|
||||
@@ -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>·</span>
|
||||
<span>© {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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,41 +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}
|
||||
|
||||
<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>
|
||||
</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, 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">
|
||||
@@ -147,28 +142,12 @@
|
||||
</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 & Greet</a>
|
||||
</article>
|
||||
{#each decoratePlans(section.plans) as plan}
|
||||
<PricingPlanCard {plan} variant="pricing" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a class="btn btn-yellow pricing-section-mobile-cta" href="#newlead">
|
||||
<a class="btn btn-yellow btn-mobile-center pricing-section-mobile-cta" href="#newlead">
|
||||
Book a Meet & Greet
|
||||
</a>
|
||||
|
||||
@@ -181,7 +160,7 @@
|
||||
<p>
|
||||
Book a free Meet & Greet and we’ll help you choose the right walk or visit for your dog.
|
||||
</p>
|
||||
<a class="btn btn-outline btn-outline-green pricing-mobile-consult-cta" href="#newlead">
|
||||
<a class="btn btn-outline btn-outline-green btn-mobile-center pricing-mobile-consult-cta" href="#newlead">
|
||||
Talk it through with us
|
||||
</a>
|
||||
</aside>
|
||||
@@ -190,7 +169,11 @@
|
||||
</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}
|
||||
@@ -219,34 +202,6 @@
|
||||
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;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pricing-page-sub {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.pricing-trust {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -257,8 +212,11 @@
|
||||
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: 600;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
@@ -284,10 +242,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pricing-trust-label {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
:global(.pricing-trust .pricing-trust-arrow) {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
@@ -298,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;
|
||||
}
|
||||
|
||||
@@ -320,7 +276,7 @@
|
||||
height: 56px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--green);
|
||||
background: var(--gw-green);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
}
|
||||
@@ -350,87 +306,6 @@
|
||||
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;
|
||||
@@ -469,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;
|
||||
@@ -555,31 +430,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.pricing-plan-card:hover {
|
||||
transform: translateY(-6px) 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 {
|
||||
@@ -588,18 +438,6 @@
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -634,22 +472,6 @@
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pricing-plan-popular {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.pricing-plan-card {
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.pricing-plan-price {
|
||||
font-size: 46px;
|
||||
}
|
||||
|
||||
.pricing-plan-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pricing-section-mobile-cta {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
@@ -672,7 +494,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--green);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,10 +49,16 @@
|
||||
{/if}
|
||||
|
||||
{#if service.href}
|
||||
<a href={service.href} class="btn btn-green">
|
||||
<span>See {service.title} pricing</span>
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</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 & pricing
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -81,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>
|
||||
|
||||
@@ -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}>
|
||||
✕
|
||||
</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>
|
||||
|
||||
@@ -1,15 +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 = '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.';
|
||||
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 };
|
||||
|
||||
@@ -19,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, there’s 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'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,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)
|
||||
@@ -58,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() : '';
|
||||
@@ -76,6 +90,7 @@
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
|
||||
syncMobileStage();
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
@@ -84,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(() => {
|
||||
@@ -107,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();
|
||||
@@ -115,6 +165,7 @@
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
motionQuery.removeEventListener('change', onMotionChange);
|
||||
observer?.disconnect();
|
||||
};
|
||||
@@ -123,6 +174,7 @@
|
||||
|
||||
<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>
|
||||
@@ -148,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>
|
||||
@@ -161,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>
|
||||
@@ -244,6 +304,21 @@
|
||||
</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;
|
||||
@@ -266,7 +341,7 @@
|
||||
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);
|
||||
@@ -297,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;
|
||||
}
|
||||
@@ -465,8 +547,11 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -496,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;
|
||||
@@ -555,11 +640,11 @@
|
||||
}
|
||||
|
||||
.testimonial-arrow-left {
|
||||
left: -38px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.testimonial-arrow-right {
|
||||
right: -38px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -588,18 +673,33 @@
|
||||
@media (max-width: 767px) {
|
||||
.testimonials-carousel {
|
||||
margin-top: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
min-height: unset;
|
||||
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 {
|
||||
@@ -641,17 +741,27 @@
|
||||
.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: 48px;
|
||||
height: 48px;
|
||||
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: 0 10px 22px rgba(20, 24, 20, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.testimonial-arrow-inline:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.testimonial-google {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,36 +4,77 @@ export const aboutPageContent: AboutPageContent = {
|
||||
title: 'About Us',
|
||||
sections: [
|
||||
{
|
||||
eyebrow: 'Our story',
|
||||
title: 'Who we are',
|
||||
body: [
|
||||
'GoodWalk is led by Alessandra, who was born in Italy and is now a New Zealand citizen. Before dog walking full time, she worked in corporate retail as a store manager for a major supermarket chain and also ran cafes in different parts of the world. That background shows up in the way GoodWalk is run: organised, hands-on, people-focused, and calm under pressure.',
|
||||
'That focus shapes everything we do across Auckland Central: small-group Tiny Gang adventures for the right dogs, one-on-one walks when a dog needs more space, and puppy visits that help younger dogs build confidence before they are ready for the pack.'
|
||||
"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-dog-group-outing.jpg',
|
||||
imageAlt: 'Group of GoodWalk dogs on an outing together'
|
||||
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 philosophy',
|
||||
eyebrow: 'What we stand for',
|
||||
title: 'How we do things',
|
||||
body: [
|
||||
'We believe good dog walking is part handling, part communication, and part good judgement. We use positive reinforcement, clear routines, and carefully matched groups so dogs can learn, socialise, and enjoy the outing without being overwhelmed.',
|
||||
'For us, a great walk is not just exercise. It is confidence-building, enrichment, practice around other dogs and people, and a reliable routine that helps your dog come home happier and more settled.'
|
||||
"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 Aless and Maya',
|
||||
eyebrow: 'Meet the founder',
|
||||
title: 'The heart of Goodwalk',
|
||||
body: [
|
||||
'Behind GoodWalk is Alessandra, whose approach is hands-on, observant, and relationship-led. She gets to know each dog properly: their pace, their confidence level, their social skills, and what helps them feel safe and successful out on a walk.',
|
||||
"Maya, the resident Cavalier King Charles cross Shih Tzu, keeps the brand honest. She is part mascot, part quality control, and a daily reminder that small dogs deserve walks designed around their size, temperament, and personality."
|
||||
"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 4–8 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',
|
||||
|
||||
@@ -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 walker’s 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 dog’s 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 we’ll 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.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,13 +31,18 @@ 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 Auckland Central dog walking — small packs, solo adventures, and puppy visits from a team that knows your dog by name",
|
||||
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: 'Explore our services →', href: '#services', variant: 'outline' },
|
||||
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
|
||||
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: {
|
||||
@@ -48,17 +53,18 @@ export const homepageContent: HomePageContent = {
|
||||
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?',
|
||||
emphasis: 'join the Tiny Gang?',
|
||||
cta: { label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'green' },
|
||||
imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.webp',
|
||||
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
|
||||
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
|
||||
imageAlt: 'Alessandra from Goodwalk with a dog in Auckland'
|
||||
},
|
||||
services: [
|
||||
{
|
||||
@@ -85,21 +91,31 @@ export const homepageContent: HomePageContent = {
|
||||
],
|
||||
howItWorks: {
|
||||
title: 'How it works',
|
||||
//intro: 'A simple onboarding flow designed to make sure the fit is right for both you and your dog.',
|
||||
intro:
|
||||
'A calm, simple start designed to give you confidence quickly and help your dog settle into the right routine.',
|
||||
steps: [
|
||||
{
|
||||
title: 'Meet & Greet',
|
||||
body: 'We meet you and your dog first, talk through routine, temperament, and what support you need.',
|
||||
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'
|
||||
},
|
||||
{
|
||||
title: 'Two assessment walks',
|
||||
body: 'We ease your dog in with two assessment walks so we can check confidence, handling, and group fit.',
|
||||
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'
|
||||
},
|
||||
{
|
||||
title: 'Happy dogs, happy humans',
|
||||
body: 'Once approved, your dog joins regular walks and comes home tired, settled, and ready for a nap.',
|
||||
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'
|
||||
}
|
||||
]
|
||||
@@ -107,75 +123,45 @@ export const homepageContent: HomePageContent = {
|
||||
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: [
|
||||
{
|
||||
quote:
|
||||
'Fully professionally run company with the most caring and kind Aless at the lead. I trust my most beloved dog with her completely. She is selective where she goes and is all about fun and adventure and care. Love love love GoodWalk.',
|
||||
reviewer: 'Jo',
|
||||
detail: "Dog mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Aless has been amazing with my pup. She’s taken the time to bond and he LOVES her. I can go to work with absolutely no worries and my favourite part of my day are the updates and seeing how happy he is. Highly recommend this service!',
|
||||
reviewer: 'Brigid',
|
||||
detail: "Dog mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'My dog has an amazing time each week on his adventure walk with Aless and comes home pooped! Aless is a lovely and super friendly person and she runs a fantastic dog walking business which I highly recommend.',
|
||||
reviewer: 'Kate',
|
||||
detail: "Dog mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Alessandra is amazing. Not only she looked after my dog but since I left him with her, he has improved on his behaviour. If I could rate 6 stars I would. I couldn\'t imagine a better person to leave my lovely Rusty with. Thank you Alessandra.',
|
||||
reviewer: 'Sara',
|
||||
detail: "Rusty's mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Aless is top notch, you and your dog would be lucky to have her. Aless took the time to get to know Buddy’s personality to make sure he was a good fit for the pack and went above to work with him on recall and obedience training. Reliable, trustworthy and all around awesome.',
|
||||
reviewer: 'Lori',
|
||||
detail: "Buddy'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.',
|
||||
@@ -194,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, there’s a reason she has 5 stars!',
|
||||
reviewer: 'Ross',
|
||||
detail: "Otis's Dad",
|
||||
detail: "Otis's dad",
|
||||
imageUrl: '/images/otis-auckland-dog-walking-review.jpg'
|
||||
},
|
||||
{
|
||||
@@ -208,15 +194,15 @@ export const homepageContent: HomePageContent = {
|
||||
booking: {
|
||||
title: "Let's meet!",
|
||||
subtitle:
|
||||
"Almost there — just your contact details. We'll reply within 24 hours to arrange your free, no-obligation Meet & Greet.",
|
||||
"A few contact details and we’ll be in touch within 24 hours to arrange your free, no-obligation Meet & Greet.",
|
||||
generalSubtitle:
|
||||
"Almost there — just your contact details. We'll reply within 24 hours.",
|
||||
"A few contact details and we’ll 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',
|
||||
@@ -238,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:
|
||||
@@ -248,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:
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
@@ -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 dog’s 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.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,50 +3,75 @@ 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 · 4–8 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:
|
||||
'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!',
|
||||
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: [
|
||||
@@ -57,42 +82,57 @@ export const packWalksContent: ServicePageContent = {
|
||||
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: '4–8 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.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 puppy’s 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.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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 |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 382 KiB |