11 Commits

Author SHA1 Message Date
admin 4d70993817 Merge branch 'main' of http://10.0.0.213:3001/admin/gw-svelte 2026-05-07 07:57:56 +12:00
admin 32ccd49d78 Remove CTA button from mobile 2026-05-07 07:57:52 +12:00
admin 7edd4c7f9d AboutUs rewrite 2026-05-06 23:55:31 +12:00
admin ad9df7578a 4.2.3 - CTA footer, How it works 2026-05-06 17:42:43 +12:00
admin 6d021e05ea Testimonials fixes 2026-05-06 16:47:15 +12:00
admin 2f4001b8af 4.2.2 - tracking across email, fixes to dark mode. 2026-05-06 15:50:01 +12:00
admin a7ce4c74b5 4.2.1 final fixes 2026-05-06 11:36:19 +12:00
admin b8b9d12a82 4.2.1 polish 2026-05-06 08:27:24 +12:00
admin 55217f59bd Merge branch 'ui-consistency'
Visual consistency pass: unified H1 token, shared .eyebrow utility,
card border-radius standardised to 28px, card hover transforms
unified to translateY(-6px) scale(1.012).
2026-05-06 07:25:09 +12:00
admin 251ec5737f Footer change 2026-05-05 22:48:18 +12:00
admin 71cdc809c6 Commit 2026-05-05 22:47:14 +12:00
47 changed files with 3663 additions and 431 deletions
+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.sh text eol=lf
*.ps1 text eol=crlf
+73 -1
View File
@@ -37,6 +37,9 @@ containers untouched.
`deploy.ps1`. Keep using the root script directly.
- [scripts/deploy-remote.sh](scripts/deploy-remote.sh)
- Server-side helper that updates only the `goodwalk-svelte` compose project.
- [scripts/deploy-from-git.sh](scripts/deploy-from-git.sh)
- Standalone server-side entrypoint that pulls from Git, then runs the same
compose/nginx deployment steps on the server.
- [docker-compose.prod.yml](docker-compose.prod.yml)
- Production compose file for the new Svelte app, mail API, and Postgres.
- `scripts/export-homepage-content.mjs`
@@ -70,8 +73,9 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env
APP_VERSION=4.0.2
APP_VERSION=4.2.3
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -97,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
@@ -129,6 +138,69 @@ To rebuild and restart only one service, for example the mail API:
powershell -ExecutionPolicy Bypass -File .\deploy.ps1 -Force -Service mail-api
```
## Remote Git deploy
If you want the production server to pull straight from Gitea instead of
receiving an uploaded tarball from Windows, use
[scripts/deploy-from-git.sh](scripts/deploy-from-git.sh) on the server.
Recommended credential setup for a private HTTPS repo:
```bash
umask 077
cat > ~/.netrc <<'EOF'
machine g.sublogue.com
login YOUR_GITEA_USERNAME
password YOUR_READ_ONLY_TOKEN
EOF
chmod 600 ~/.netrc
```
Install the script on the server and make it executable:
```bash
install -m 0755 scripts/deploy-from-git.sh /usr/local/bin/goodwalk-deploy
```
The remote host must have `git` and `docker`. A host-level `node` install is
optional; if it is missing, the script will export homepage content using a
temporary `node:22-alpine` container instead.
Run a full deploy from the repo:
```bash
/usr/local/bin/goodwalk-deploy \
--repo-url https://g.sublogue.com/admin/gw-svelte.git \
--branch main \
--deploy-path /docker/goodwalk-svelte \
--compose-file docker-compose.prod.yml \
--project-name goodwalk-svelte \
--nginx-source nginx/goodwalk.co.nz.svelte.conf.example \
--nginx-target /docker/nginx/conf.d/goodwalk.co.nz.conf \
--nginx-compose-file /docker/nginx/docker-compose.yml \
--nginx-project-name nginx \
--maintenance-host-dir /docker/nginx/maintenance \
--maintenance-flag /docker/nginx/conf.d/maintenance.flag
```
Deploy a specific commit or tag:
```bash
/usr/local/bin/goodwalk-deploy \
--repo-url https://g.sublogue.com/admin/gw-svelte.git \
--branch main \
--ref <commit-or-tag> \
--deploy-path /docker/goodwalk-svelte \
--compose-file docker-compose.prod.yml \
--project-name goodwalk-svelte \
--nginx-source nginx/goodwalk.co.nz.svelte.conf.example \
--nginx-target /docker/nginx/conf.d/goodwalk.co.nz.conf \
--nginx-compose-file /docker/nginx/docker-compose.yml \
--nginx-project-name nginx \
--maintenance-host-dir /docker/nginx/maintenance \
--maintenance-flag /docker/nginx/conf.d/maintenance.flag
```
## Homepage content sync
Local development can feel fresher than production because production reads the
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.3
FROM node:22-alpine AS builder
ARG APP_VERSION
+332
View File
@@ -0,0 +1,332 @@
# Mobile Polish — Conversion & Comfort Audit Tracker
## New Rescan Items — Mobile Conversion Opportunities
Fresh opportunities from a second mobile-first pass over the main site.
These are intentionally only the new items, kept separate from the
existing audit below.
### High — conversion strategy and flow
- [ ] **Hero CTA hierarchy still prioritises browsing over booking**
- Files: `src/lib/content/homepage.ts:37-39`, `src/lib/components/HeroSection.svelte`
- Current: the yellow primary CTA is `Explore our services →`, while
`Book a Meet & Greet` is secondary.
- Why: on mobile, high-intent users should be able to choose the next
step immediately. Making the exploratory path more visually dominant
adds friction before the user reaches the lead form.
- Opportunity: test flipping the hierarchy on mobile so booking
becomes the primary CTA and service exploration becomes secondary.
- [ ] **Homepage social proof appears too late in the scroll**
- File: `src/routes/+page.svelte:143-147`
- Current order: `Services -> Values -> Testimonials -> Booking`.
- Why: testimonials are one of the strongest conversion levers, but
on mobile they arrive after several large sections. Users are asked
to keep scrolling before seeing the strongest emotional proof.
- Opportunity: move testimonials above values on the homepage, or
surface one featured review snippet earlier in the page.
- [ ] **Hero still relies on the next section for trust**
- Files: `src/lib/content/homepage.ts:43-49`, `src/lib/components/HeroSection.svelte`,
`src/lib/components/IntroStrip.svelte`
- Current: the hero presents the headline and CTAs, but the review
proof sits in the intro strip below.
- Why: on mobile, the hero needs to answer both "what is this?" and
"can I trust them?" before the user scrolls away. Separating those
two jobs weakens the first decision moment.
- Opportunity: add a compact review/trust chip directly under the
hero subtitle or near the hero CTAs on mobile.
- [ ] **Booking flow asks for dog details before it captures the lead**
- File: `src/lib/components/BookingSection.svelte:298-441`
- Current: step 1 asks for dog name, location, message, and services;
contact details are only requested in step 2.
- Why: this is a higher-friction sequence on mobile. Users often feel
more comfortable giving owner details first, then expanding into pet
specifics once they have mentally committed.
- Opportunity: test reversing the step order so step 1 captures name,
email, and phone first, then dog details second.
### Medium — mobile persuasion and CTA timing
- [ ] **Sticky mobile CTA appears on a fixed pixel threshold rather than page context**
- File: `src/lib/components/MobileBookBar.svelte:26-37`
- Current: visibility is driven by `SHOW_AFTER_PX = 480` and
`HIDE_BELOW_PX = 120`.
- Why: a fixed threshold will feel early on some phones and late on
others. It also ignores whether the hero or booking section is
actually in view.
- Opportunity: switch to an `IntersectionObserver` tied to the hero or
booking section so the bar appears based on user context rather than
raw scroll position.
- [ ] **Testimonials section pushes users off-site before finishing the proof story**
- File: `src/lib/components/TestimonialsSection.svelte:128-134`
- Current: the Instagram CTA appears near the top of the testimonials
section, before the user has fully consumed the review content.
- Why: on mobile, sending users to Instagram this early interrupts the
conversion journey and competes with the booking path.
- Opportunity: demote the Instagram CTA below the carousel, or replace
it with a tighter trust-oriented proof CTA higher up.
- [ ] **Mobile pricing pages lose the consultative "not sure?" nudge**
- File: `src/lib/components/PricingPage.svelte:12-19`
- Current: the meet-and-greet reminder prompt is gated behind
`min-width: 769px`, so desktop gets a tailored nudge and mobile
does not.
- Why: mobile users are more likely to feel overwhelmed by stacked
pricing cards, not less. Removing the consultative reassurance on
the smallest screens is directionally backwards for conversion.
- Opportunity: add an inline mobile prompt after the first pricing
section that says, in effect, "Not sure which option fits? Book a
free Meet & Greet and well help you choose."
### Medium — stacked-page CTA noise
- [ ] **Stacked pricing/service cards repeat the same CTA too many times**
- Files: `src/lib/components/PricingPage.svelte:141-159`,
`src/lib/components/ServiceLandingPage.svelte:94-112`
- Current: when cards collapse to one column on mobile, each card
keeps a full "Book a Meet & Greet" button.
- Why: the repetition turns persuasive choice architecture into visual
noise. Instead of helping the user decide, the page starts feeling
like a stack of repeated asks.
- Opportunity: treat this as a shared mobile pattern across pricing
and service pages. Keep one strong CTA per section, let the popular
card carry the primary action, and demote the rest.
Findings from a focused mobile-experience review (≤768px, with extra
attention to 375px small-phones). Desktop is considered done. Each item
records the where, why, and the concrete change.
> Important context for prioritisation: a dog-walking business is a
> jobs-to-be-done service that users research on the couch. Mobile
> conversion lower-bound is "they Meet & Greet". So the dial-movers
> are: thumb-reach for the booking CTA, legibility, friction-free
> form, and trust signals visible on the first scroll.
---
## High — direct conversion impact
- [x] **Hero buttons stack awkwardly at ~375px**
- File: `src/lib/styles/responsive.css` (new ≤480px block)
- Implementation: At `@media (max-width: 480px)` the hero buttons
flip to `flex-direction: column; gap: 12px;` and each button
becomes full-width with padding `16px 24px`. The 768px-specific
`padding-right: 18px` on the buttons row and the
`margin-right: 12px` on `:last-child` are both reset so the two
CTAs read as a clean stacked stack.
- Why: At 375px the side-by-side primary + outline CTAs were wrapping
unevenly and the primary's prominence collapsed.
- [x] **Mobile header phone button is low-contrast and small**
- File: `src/lib/styles/responsive.css:88-100`
- Implementation: Background bumped from `rgba(33, 48, 33, 0.06)`
`0.10`; padding 9px 12px → 11px 14px (and at ≤480px, 10px 12px);
`font-weight: 600`; `min-height: 44px` to meet the touch-target
minimum; icon size also bumped from 13px → 14px.
- Why: The tap-to-call on the mobile header is one of the highest
intent actions on the site. It now reads as a button rather than a
barely-visible label.
- [x] **Booking form inputs trigger iOS zoom-on-focus**
- File: `src/lib/styles/responsive.css:453-462`
- Implementation: Input `font-size: 15px``16px` at ≤768px. Comment
added explaining the iOS-Safari 16px threshold so a future edit
doesn't accidentally drop it again.
- Why: Below 16px Safari auto-zooms on focus; the page jolts every
time a field is tapped. Critical at the booking conversion step.
- [ ] **Testimonial carousel arrows hard to reach at 375px**
- File: `src/lib/components/TestimonialsSection.svelte` (mobile rules
lines ~640-660)
- Current: arrows pinned to `bottom: 24px` inside a stage with
`padding-bottom: 116px`. Visually at the bottom of a tall card —
requires deliberate stretching.
- Why: The carousel feels passive. Users don't realise they can advance
it; testimonials sell — losing that engagement matters.
- Fix: Lift arrows on small screens:
```css
@media (max-width: 480px) {
.testimonial-arrow { bottom: 80px; }
}
```
- [x] **No persistent "Book Meet & Greet" CTA on mobile after hero scrolls past**
- Files: `src/lib/components/MobileBookBar.svelte` (new),
`src/routes/+layout.svelte` (mount), `src/lib/styles/responsive.css`
(`body { padding-bottom: 64px }` at ≤768px).
- Implementation v2 — *Airbnb-style soft-container, scroll-triggered*:
- **Soft container**: a translucent white tray (`rgba(255, 255,
255, 0.96)` with backdrop-filter blur) sits flush at the bottom
with a thin top hairline and a soft shadow. The brand-yellow CTA
pill lives *inside* the tray, not as the tray itself — so the
action stays unmistakable but the surrounding chrome is calm.
- **Scroll-triggered**: the bar slides up + fades in only after the
user has scrolled ~480px (≈ one mobile viewport, hero out of
view). Slides back out below ~120px to avoid flicker near the
top. `prefers-reduced-motion` respected — the slide is dropped,
only the opacity fade remains.
- **Hidden on `/contact-us` and `/booking`** (already in the form).
- **Resets on navigation**: `afterNavigate` resets `visible = false`
so each new page starts hidden until the user has earned it again.
- **Accessibility**: `aria-hidden` toggles with visibility; CTA
`tabindex` flips to `-1` while hidden so keyboard users don't
stumble onto an off-screen control; `pointer-events: none` while
hidden so the user can't accidentally tap it during the fade.
- **Safe-area aware**: `env(safe-area-inset-bottom)` so iPhones
with the home-bar gesture area get correct spacing.
- Body bottom-padding of 64px on mobile keeps the footer from sitting
behind the bar when it's visible at the bottom of long pages.
- Why: Sticky mobile CTAs are a validated conversion pattern (Airbnb,
Booking.com, etc.), but the v1 full-yellow bar was tonally wrong
for Goodwalk — it competed with the calm brand voice and dominated
the screen permanently. v2 keeps the conversion benefit (one-tap
booking, always one swipe away after engagement) without yelling.
## Medium — legibility & polish
- [x] **Hero title can wrap awkwardly at 375px**
- File: `src/lib/styles/responsive.css` (≤480px block)
- Implementation: At ≤480px the desktop H1 drops to 32px /
line-height 1.12 and the dedicated `.hero-heading-mobile` element
drops to 30px / 1.12 (it was 33.5px). The two-line "Unleashing Fun
in / Your Dog's Day!" now sits comfortably with breathing room
above the subtitle.
- Why: Previous 38px / 33.5px sizings were a hair too big for 375px;
line-2 felt cramped against the subtitle.
- [x] **Body text 15px feels small on mobile (and on ultra-wide desktop)**
- File: `src/lib/styles/responsive.css` (≤768px body rule)
- Implementation: `body { font-size: 16px; }` at ≤768px, with a
comment noting the iOS-Safari 16px zoom threshold so this rule
isn't accidentally undone.
- Desktop (≥769px) still inherits 15px from `base.css` for now — the
ultra-wide bump is tracked separately at the bottom of this file
so it can be reasoned about independently (clamp vs. breakpoint).
- Why: 16px is the modern legibility standard on mobile, dovetails
with the iOS zoom-on-focus rule for inputs, and reduces read
fatigue on long pages (about, FAQ answers, legal).
- [ ] **FAQ summary tap target too small**
- File: `src/lib/styles/sections.css` (`.faq summary` rules)
- Current: just text height (~22-26px) — below the 44px minimum.
- Fix:
```css
.faq summary {
padding: 12px 0;
min-height: 44px;
display: flex; align-items: center;
}
```
- [ ] **Booking service-option chips wrap awkwardly at 375px**
- File: `src/lib/styles/responsive.css:468-470`
- Fix at ≤480px: 2-up grid with tighter gap:
```css
.booking-service-options { gap: 10px 12px; }
.booking-toggle-option { flex: 1 1 calc(50% - 6px); }
```
- [ ] **Footer social icons spaced too tight for thumbs**
- File: `src/lib/styles/sections.css` (`.social-links { gap: 14px }`)
- Current 14px gap with 40px icons → centres are 54px apart. Apple
HIG wants 8px+ between targets after the 44px minimum.
- Fix:
```css
@media (max-width: 768px) { .social-links { gap: 18px; } }
```
- [ ] **Pricing cards stack to 1-col with multiple "Book" buttons in a row**
- File: `src/lib/components/PricingPage.svelte` (and ServiceLandingPage
plan grids)
- Why: After stacking, the user sees Plan → Book → Plan → Book → Plan
→ Book in vertical sequence. The repetition reads as noise rather
than choice; the "popular" anchor disappears.
- Fix (opinionated): on mobile, only the popular plan keeps its CTA
button. Other plans show a smaller "Choose this plan" link instead,
or no per-card CTA at all (a single CTA appears under the grid):
```css
@media (max-width: 768px) {
.pricing-plan-card:not(.pricing-plan-popular) .pricing-plan-cta {
display: none;
}
}
```
Then keep the existing under-grid `.service-plan-reassurance` pill
and add a single "Book a Meet & Greet" button below it.
- [ ] **Mobile nav header is too tall — eats above-the-fold real estate**
- File: `src/lib/styles/responsive.css:74`
- Current: `nav { padding: 20px 24px; }` + 25px logo = ~65px header.
On iPhone 13 (844px), this leaves ~380px for hero before scroll —
less than what the Goodwalk dog-image needs to feel like a hero.
- Fix at ≤480px:
```css
nav { padding: 14px 20px; }
.logo img { height: 22px; }
```
## Low — incremental polish
- [ ] **Hero top padding generous at 375px**
- File: `src/lib/styles/responsive.css:179`
- Reduce hero `padding-top` from 50px to 32px at ≤480px.
- [ ] **Intro trust badge feels edge-to-edge at 375px**
- File: `src/lib/styles/responsive.css:296-301`
- Add `padding: 18px 16px; margin: 0 12px;` at ≤480px.
- [ ] **Testimonial quote mark too large at 375px**
- File: `src/lib/components/TestimonialsSection.svelte:~595`
- Current: 44px. Reduce to 36px at ≤480px.
- [ ] **Booking form labels could shrink slightly at 375px**
- File: `src/lib/styles/responsive.css:450`
- Optional: 16px → 15px at ≤480px to give field width back to the
input value (where it actually matters for legibility).
- [ ] **No scroll-to-top affordance on long pages (booking, pricing)**
- Currently absent. Low priority but helpful when users have scrolled
past the booking form and want to re-read service details. Could be
folded into the same sticky-book-bar work above (one bar, both jobs).
## Open from elsewhere
- [ ] **Ultra-wide desktop body font feels small** *(noted by user)*
- Currently `body { font-size: 15px }` ([base.css:15](src/lib/styles/base.css#L15)).
On ≥1800px screens with the `--max-w` already widening (per the
1800px breakpoint), 15px in long-form sections (about, FAQ answers,
legal) becomes uncomfortable.
- Suggested fix: bump to `clamp(15px, 0.95vw, 17px)` on `body`, OR
introduce a `@media (min-width: 1600px) { body { font-size: 17px; } }`.
Either keeps the desktop ≤1599px experience identical and only
expands type when there's genuinely more reading width.
## Deliberately not actioning
- **Drop reveal animations on mobile to "save bandwidth".** They're
IntersectionObserver-driven, cost nothing perceivable, and add brand
polish. Removing them would make the mobile site feel cheaper for no
measurable performance gain.
- **Replace the testimonial carousel with a stacked list on mobile.**
Tempting (carousels famously hide content), but the carousel is
central to the brand's "see real dogs" pitch. Better to fix the arrow
reachability and let the autoplay do the work.
---
## Suggested order of attack
If you want one batch that moves the dial: **High items 1, 2, 3, 5**
together is roughly an hour of work — they hit the hero, the header
tap-to-call, the booking form's biggest mobile bug (zoom-on-focus), and
add the sticky CTA. That's the package I'd ship first. Item 4 (carousel
arrows) is a one-line fix once you're already in `responsive.css`.
The Medium list is best as a second pass — body-text bump,
header-padding reduction, FAQ tap-target, and pricing-card-CTA dedupe
all compound into a noticeably more "intentional on mobile" feel
without any structural change.
+143
View File
@@ -0,0 +1,143 @@
# UX Polish — Conversion Audit Tracker
Findings from the senior-marketing-lens audit, with completion status. Each
item has a one-line rationale and the file/line where the change lives (or
will live).
> Only commit to "We'll reply within 24 hours" if Aless can actually hold
> to it. If response time is more like 1-2 business days, soften to
> "within one business day".
---
## High — direct conversion impact
- [x] **Hero primary CTA: "Learn more" → "Explore our services →"**
- File: `src/lib/content/homepage.ts:38`
- Why: "Learn more" is the lowest-intent CTA that exists.
- [x] **Promise CTA: "See our services" → "Book a free Meet & Greet"**
- File: `src/lib/content/homepage.ts:59`
- Also: target changed from `#services` to `/contact-us` so the CTA goes
to the booking page instead of bouncing back up to a service list.
- Why: After the value prop + happy-dog photo, sending visitors to the
services list is a step backwards. Push them to book.
- [x] **Booking subtitle now states response time**
- File: `src/lib/content/homepage.ts:159-162`
- Old: *"...so we can reach out to arrange your free, no-obligation Meet & Greet."*
- New: *"...We'll reply within 24 hours to arrange your free, no-obligation Meet & Greet."*
- General-enquiry variant updated to match.
- Why: Open-ended "we'll reach out" creates anxiety at submit time.
- [x] 1 **Pricing page — Google rating trust signal above plan grid**
- File: `src/lib/components/PricingPage.svelte`
- Implementation: Pill-styled trust badge inside the green hero,
directly under the subtitle — five yellow stars + "30+ five-star
Google reviews" label + arrow, links out to Google. Styled to read
against the green hero (semi-transparent white pill) rather than
reusing the cream IntroStrip, which would have clashed.
- Why: Visitors land on pricing mid-decision; trust signal now appears
before the plan grid.
- [x] 2 **Service plan CTAs — add free / no-obligation reassurance**
- File: `src/lib/components/ServiceLandingPage.svelte`
- Implementation: A subtle green pill *"Every booking starts with a
free, no-obligation Meet & Greet."* (yellow shield-heart icon) sits
centred directly under the plan grid on every service page, above the
Extras block. Reuses the brand-tinted-pill aesthetic so it feels
native, not tacked on.
- Why: The "Book a Meet & Greet" buttons under each plan didn't carry
risk-reversal phrasing in their immediate context. Now they do.
## Medium — trust + polish
- [x] 3 **Quantify the Google rating wherever it appears**
- Files: `src/lib/content/homepage.ts:46`,
`src/lib/components/Footer.svelte:89`,
`src/lib/components/TestimonialsSection.svelte:200`,
`src/lib/components/PricingPage.svelte` (new pricing-trust pill).
- Implementation: "All 5 star reviews on Google!" → "30+ five-star
Google reviews" everywhere. Aless confirmed 30+ as the count.
- Why: A specific number is dramatically more credible than "all".
- [x] 4 **Lean into the "limited spots" angle**
- File: `src/lib/content/pack-walks.ts` (added `scarcityNote` to the
`pricing` block); `src/lib/types.ts` (added optional `scarcityNote?:
string` to ServicePageContent.pricing); rendered in
`src/lib/components/ServiceLandingPage.svelte` directly under the
plan grid as a yellow-tinted pill with a clock icon.
- Copy: *"We keep packs small (4-8 dogs) — popular days fill up fast."*
- Only set on Pack Walks (the 4-8 number is specific to that service);
the field is optional so 1:1 Walks and Puppy Visits get nothing.
- Why: Real, honest scarcity. The 4-8 cap is already a fact; saying it
out loud nudges decision-making.
- [ ] **About page — quantify Aless's expertise**
- File: `src/lib/content/about.ts:29-30`
- Why: "years of experience" is the weakest possible claim. Replace with
concrete numbers Aless can stand behind: years operating, dogs in
rotation, first-aid certification.
- [x] 5 **Pack Walks pricing intro — lead with the differentiator**
- File: `src/lib/content/pack-walks.ts:23-24`
- Implementation: Old intro led with "Our pack walks are a permanent
booking of at least one walk day a week..." (commitment ask first).
New intro leads with the benefits: *"Small packs of 4-8 dogs, 2-hour
outings at Auckland's scenic dog parks and beaches, with free pick-up
and drop-off included. We reinforce recall, car manners, and leash
etiquette while your dog plays. Booked as a permanent weekly slot —
gift your dog the best life!"*
- Why: Buyers scan for benefits before commitments. Lead-with-policy
framing creates resistance; lead-with-benefit framing builds desire.
- [ ] **FAQs — reframe from policy to reassurance**
- File: `src/lib/content/homepage.ts:180-205`
- Why: Answers are correct but read like terms & conditions. Lead with
the *why* (the benefit/reassurance), then the *what*.
## Low — incremental polish
- [x] **Home services-card CTAs: "Learn more" → outcome-oriented**
- File: `src/lib/components/ServicesSection.svelte:29`
- Implementation: Visible label is now derived from the service title —
*"See Pack Walks pricing →"*, *"See 1:1 Walks pricing →"*, *"See
Puppy Visits pricing →"*. The previously-added screen-reader-only
"about <Service>" span was removed since the visible label now carries
that context for everyone, not just assistive tech users.
- Why: "Learn more" was the lowest-intent CTA on the page; the new
label states the destination and the next step.
- [x] **Testimonials intro blurb — sharper jobs-to-be-done framing**
- File: `src/lib/components/TestimonialsSection.svelte:10-11`
- Old: *"Happy owners, even happier dogs. Our Auckland dog walking
clients love what the Tiny Gang brings to their dog's routine — and
you can see why. Follow along on Instagram for daily adventures..."*
- New: *"Busy parents get peace of mind. Dogs come home tired and
happy. See why 30+ Auckland families trust the Tiny Gang — follow
along on Instagram for daily adventures, wagging tails and the odd
zoomie."*
- Why: Leads with the two outcomes buyers actually care about (peace of
mind for them, exercise for the dog), keeps the brand voice + 30+
review proof point, then makes the Instagram nudge feel like a
follow-on rather than the lead.
- [x] **Surface "Reliability / on-time" earlier**
- File: `src/lib/content/homepage.ts:37` (hero subtitle)
- Old: *"Trusted, professional dog walking across Auckland Central..."*
- New: *"Trusted, on-time dog walking across Auckland Central..."*
- Why: Reliability/punctuality is the #1 anxiety for busy parents
booking a service that visits their home. Pulling "on-time" into the
hero subtitle (one-word swap, no length cost) puts the reassurance
above the fold.
## Deliberately not actioning
- **Pack Walks H1 rewrite to "Small-dog pack walks designed for calm,
confident groups."** *"Join our Tiny Gang!"* is doing brand work — it's
memorable and reinforces a phrase used everywhere else. Rewriting kills
the most distinctive asset for marginal headline clarity.
- **Booking submit button: "Send" → "Book my Meet & Greet".** The form
also handles general enquiries, so a "book my…" label would feel wrong
on a complaint email. Better fix would be to switch the label by
`enquiryType` — keep "Send my booking" / "Send my enquiry" contextually.
+2 -1
View File
@@ -1,4 +1,4 @@
APP_VERSION=4.0.2
APP_VERSION=4.2.3
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
@@ -13,6 +13,7 @@ CLIENT_BCC=mattcohen0@gmail.com
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
REPLY_TO=info@goodwalk.co.nz
ENABLE_GENERAL_ENQUIRIES=false
PUBLIC_ENABLE_MOBILE_CTA_BUTTON=false
FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200
+5 -4
View File
@@ -3,14 +3,15 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
container_name: goodwalk_svelte_app
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: 3000
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -25,10 +26,10 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
container_name: goodwalk_svelte_mail_api
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
+5 -4
View File
@@ -3,13 +3,14 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: ${APP_PORT:-3000}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
PUBLIC_ENABLE_MOBILE_CTA_BUTTON: ${PUBLIC_ENABLE_MOBILE_CTA_BUTTON:-false}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
@@ -19,9 +20,9 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.3}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.3
FROM python:3.12-slim
ARG APP_VERSION
+210 -12
View File
@@ -141,6 +141,7 @@ logger.info(
)
app = FastAPI(title="GoodWalk Mail API")
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else ""
app.add_middleware(
CORSMiddleware,
@@ -184,6 +185,12 @@ class BookingSubmission(BaseModel):
services: list[str] = []
website: str = ""
formStartedAt: int | None = None
visitStartedAt: int | None = None
pageEnteredAt: int | None = None
firstInteractionAt: int | None = None
sendClickedAt: int | None = None
stepChanges: int = 0
journey: list[str] = []
referrer: str = ""
page: str = ""
@@ -382,6 +389,13 @@ def _normalize_submission(data: BookingSubmission) -> None:
data.referrer = _trimmed(data.referrer)
data.page = _trimmed(data.page)
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
data.journey = [_trimmed(step) for step in data.journey if _trimmed(step)][:12]
data.stepChanges = max(0, data.stepChanges)
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
value = getattr(data, field_name)
if value is None or value <= 0:
setattr(data, field_name, None)
if _is_general_enquiry(data):
data.petName = ""
@@ -430,6 +444,33 @@ def _meta_row(label: str, value: str) -> str:
</tr>"""
def _format_duration_ms(duration_ms: int | None) -> str:
if duration_ms is None or duration_ms < 0:
return ""
total_seconds = int(round(duration_ms / 1000))
minutes, seconds = divmod(total_seconds, 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours}h {minutes}m"
if minutes > 0:
return f"{minutes}m {seconds}s"
return f"{seconds}s"
def _duration_between(start_ms: int | None, end_ms: int | None) -> str:
if start_ms is None or end_ms is None or end_ms < start_ms:
return ""
return _format_duration_ms(end_ms - start_ms)
def _journey_text(journey: list[str]) -> str:
if not journey:
return ""
return " -> ".join(journey)
# ── Email templates ──────────────────────────────────────────────────────────
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
@@ -620,6 +661,12 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
page_row = _meta_row("Page", data.page) if data.page else ""
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
step_changes_row = _meta_row("Step changes", str(data.stepChanges)) if data.stepChanges else ""
journey_row = _meta_row("Journey", _journey_text(data.journey))
detail_heading = "Enquiry details" if is_general else "Dog &amp; services"
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
@@ -642,29 +689,104 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light only">
<meta name="supported-color-schemes" content="light">
<title>{email_title}</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<style>
:root {{
color-scheme: light only;
supported-color-schemes: light;
}}
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
body,
table,
td,
div,
p,
span,
a {{
forced-color-adjust: none !important;
-webkit-text-size-adjust: 100%;
}}
.gw-owner-body {{
background: #f2f2f0 !important;
color: #213021 !important;
}}
.gw-owner-shell {{
background: #ffffff !important;
}}
.gw-owner-dark-panel {{
background: #213021 !important;
}}
.gw-owner-email-chip {{
display: inline-block;
background: #ffffff !important;
color: #213021 !important;
border-radius: 10px;
padding: 12px 14px;
border: 1px solid #d9dfd9;
text-decoration: none !important;
}}
.gw-owner-email-chip,
.gw-owner-email-chip a,
a.gw-owner-email-chip {{
color: #213021 !important;
}}
@media (prefers-color-scheme: dark) {{
html,
body,
.gw-owner-body {{
background: #f2f2f0 !important;
color: #213021 !important;
}}
.gw-owner-shell,
.gw-owner-shell td {{
background: #ffffff !important;
color: #213021 !important;
}}
.gw-owner-dark-panel,
.gw-owner-dark-panel td {{
background: #213021 !important;
}}
.gw-owner-email-chip,
.gw-owner-email-chip a,
a.gw-owner-email-chip {{
background: #ffffff !important;
color: #213021 !important;
}}
}}
</style>
</head>
<body class="gw-owner-body" style="margin:0;padding:0;background:#f2f2f0;color:#213021;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0"
style="background:#f2f2f0;padding:40px 16px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
<table class="gw-owner-shell" width="600" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
box-shadow:0 4px 24px rgba(0,0,0,0.08);background:#ffffff;">
{_logo_header(badge_html=badge)}
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:40px 48px 36px;">
<td bgcolor="#ffffff" style="background:#ffffff;padding:40px 48px 36px;color:#213021;">
<!-- Quick contact -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
<table class="gw-owner-dark-panel" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#213021"
style="background:#213021;border-radius:12px;margin-bottom:28px;">
<tr>
<td style="padding:22px 24px;">
<td bgcolor="#213021" style="padding:22px 24px;background:#213021;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
text-transform:uppercase;margin-bottom:10px;">
@@ -674,10 +796,15 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
Email {first_name} directly:
</div>
<div style="font-family:Menlo,Consolas,'SFMono-Regular',monospace;
font-size:20px;font-weight:700;color:#ffffff;line-height:1.4;
word-break:break-all;margin-bottom:12px;">
{data.email}
<div style="margin-bottom:12px;">
<a href="mailto:{data.email}" class="gw-owner-email-chip"
style="display:inline-block;background:#ffffff;color:#213021 !important;
font-family:Menlo,Consolas,'SFMono-Regular',monospace;
font-size:20px;font-weight:700;line-height:1.4;word-break:break-all;
border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;
text-decoration:none;">
{data.email}
</a>
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#b7cbb7;line-height:1.6;">
@@ -779,8 +906,14 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_meta_row("IP address", ip)}
{_meta_row("Browser", browser)}
{visit_time_row}
{page_time_row}
{active_time_row}
{form_time_row}
{step_changes_row}
{referrer_row}
{page_row}
{journey_row}
</table>
</td></tr>
</table>
@@ -853,8 +986,73 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict:
raise last_exc
async def _send_startup_test_email() -> None:
if not STARTUP_TEST_RECIPIENT:
logger.info("Startup test email skipped: OWNER_BCC is not set to a real address")
return
request_id = "startup-test"
payload = {
"from": FROM_EMAIL,
"to": [STARTUP_TEST_RECIPIENT],
"reply_to": REPLY_TO,
"subject": f"GoodWalk Mail API startup check ({APP_VERSION})",
"html": f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light only">
<meta name="supported-color-schemes" content="light">
<title>GoodWalk Mail API startup check</title>
</head>
<body style="margin:0;padding:24px;background:#f2f2f0;color:#213021;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0" style="background:#f2f2f0;">
<tr>
<td align="center">
<table width="560" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
style="max-width:560px;width:100%;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
{_logo_header(subtitle="Mail API startup check")}
<tr>
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 40px;">
<h1 style="margin:0 0 12px;font-size:24px;line-height:1.2;color:#213021;">Startup test email</h1>
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#4a4f4a;">
The GoodWalk mail service started successfully and sent this boot check to the Gmail monitoring address only.
</p>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f7f4;border-radius:12px;">
<tr>
<td style="padding:20px 24px;">
<div style="font-size:13px;line-height:1.7;color:#213021;">
<strong>Version:</strong> {APP_VERSION}<br>
<strong>Started:</strong> {datetime.now().strftime("%d %b %Y %I:%M %p").lstrip("0")}<br>
<strong>Recipient:</strong> {STARTUP_TEST_RECIPIENT}
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>""",
}
await _send_email(payload, label="startup_test_email", request_id=request_id)
# ── Routes ───────────────────────────────────────────────────────────────────
@app.on_event("startup")
async def _startup_mail_check() -> None:
try:
await _send_startup_test_email()
except Exception:
logger.exception("Startup test email failed")
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodwalk-svelte-port",
"version": "4.0.2",
"version": "4.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodwalk-svelte-port",
"version": "4.0.2",
"version": "4.2.3",
"dependencies": {
"canvas-confetti": "^1.9.4",
"pg": "^8.13.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodwalk-svelte-port",
"version": "4.2.0",
"version": "4.2.3",
"private": true,
"type": "module",
"scripts": {
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_URL=""
BRANCH="main"
REF=""
DEPLOY_PATH=""
COMPOSE_FILE=""
PROJECT_NAME=""
SERVICE_NAME=""
NGINX_SOURCE=""
NGINX_TARGET=""
NGINX_COMPOSE_FILE=""
NGINX_PROJECT_NAME=""
MAINTENANCE_HOST_DIR=""
MAINTENANCE_FLAG_PATH=""
VERIFY_URL="https://www.goodwalk.co.nz/api/health"
SKIP_SITE_CHECK=0
usage() {
cat <<'EOF'
Usage:
deploy-from-git.sh --repo-url <url> --branch <name> --deploy-path <path> --compose-file <name> --project-name <name>
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> \
[--service <name>] [--nginx-source <path>] [--nginx-target <path>] \
[--nginx-compose-file <path>] [--nginx-project-name <name>] \
[--maintenance-host-dir <path>] [--maintenance-flag <path>] \
[--verify-url <url>] [--skip-site-check]
This script clones or fetches the application repo on the server, exports the
homepage content payload, updates only the main Goodwalk compose project, and
optionally updates the shared nginx stack plus maintenance mode handling.
Authentication for private HTTPS repos is expected to come from ~/.netrc,
git-credential, or another Git-supported credential mechanism already present
on the server.
EOF
}
fail() {
echo "[deploy-git] ERROR: $*" >&2
exit 1
}
assert_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server"
}
run_homepage_export() {
local export_script="$1"
local output_path="$2"
if command -v node >/dev/null 2>&1; then
node --experimental-strip-types "$export_script" "$output_path"
return
fi
echo "[deploy-git] Host node not found; exporting homepage content via temporary node:22-alpine container"
docker run --rm \
-v "$CHECKOUT_DIR:/app" \
-w /app \
node:22-alpine \
node --experimental-strip-types "${export_script#/app/}" "${output_path#/app/}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-url)
REPO_URL="${2:-}"
shift 2
;;
--branch)
BRANCH="${2:-}"
shift 2
;;
--ref)
REF="${2:-}"
shift 2
;;
--deploy-path)
DEPLOY_PATH="${2:-}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:-}"
shift 2
;;
--project-name)
PROJECT_NAME="${2:-}"
shift 2
;;
--service)
SERVICE_NAME="${2:-}"
shift 2
;;
--nginx-source)
NGINX_SOURCE="${2:-}"
shift 2
;;
--nginx-target)
NGINX_TARGET="${2:-}"
shift 2
;;
--nginx-compose-file)
NGINX_COMPOSE_FILE="${2:-}"
shift 2
;;
--nginx-project-name)
NGINX_PROJECT_NAME="${2:-}"
shift 2
;;
--maintenance-host-dir)
MAINTENANCE_HOST_DIR="${2:-}"
shift 2
;;
--maintenance-flag)
MAINTENANCE_FLAG_PATH="${2:-}"
shift 2
;;
--verify-url)
VERIFY_URL="${2:-}"
shift 2
;;
--skip-site-check)
SKIP_SITE_CHECK=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$REPO_URL" ]] || fail "--repo-url is required"
[[ -n "$BRANCH" ]] || fail "--branch is required"
[[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required"
[[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required"
[[ -n "$PROJECT_NAME" ]] || fail "--project-name is required"
if [[ -n "$SERVICE_NAME" ]]; then
SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)"
fi
[[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /"
nginx_args=("$NGINX_SOURCE" "$NGINX_TARGET" "$NGINX_COMPOSE_FILE" "$NGINX_PROJECT_NAME")
nginx_args_present=0
for value in "${nginx_args[@]}"; do
if [[ -n "$value" ]]; then
nginx_args_present=1
break
fi
done
if (( nginx_args_present )); then
[[ -n "$NGINX_SOURCE" ]] || fail "--nginx-source is required when nginx deployment is enabled"
[[ -n "$NGINX_TARGET" ]] || fail "--nginx-target is required when nginx deployment is enabled"
[[ -n "$NGINX_COMPOSE_FILE" ]] || fail "--nginx-compose-file is required when nginx deployment is enabled"
[[ -n "$NGINX_PROJECT_NAME" ]] || fail "--nginx-project-name is required when nginx deployment is enabled"
[[ -n "$MAINTENANCE_HOST_DIR" ]] || fail "--maintenance-host-dir is required when nginx deployment is enabled"
[[ -n "$MAINTENANCE_FLAG_PATH" ]] || fail "--maintenance-flag is required when nginx deployment is enabled"
fi
assert_command git
assert_command docker
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
fail "Docker Compose is not installed on the server"
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-git-deploy.XXXXXX")"
CHECKOUT_DIR="$WORK_DIR/repo"
PAYLOAD_DIR="$WORK_DIR/payload"
MAINTENANCE_ACTIVE=0
clear_maintenance_flag() {
if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then
echo "[deploy-git] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH"
rm -f "$MAINTENANCE_FLAG_PATH" || true
MAINTENANCE_ACTIVE=0
fi
}
cleanup() {
clear_maintenance_flag
rm -rf "$WORK_DIR"
}
copy_checkout_to_payload() {
mkdir -p "$PAYLOAD_DIR"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude '.git' \
--exclude '.env' \
--exclude '.env.*' \
"$CHECKOUT_DIR"/ "$PAYLOAD_DIR"/
return
fi
while IFS= read -r -d '' item; do
relative_path="${item#"$CHECKOUT_DIR"/}"
case "$relative_path" in
.git|.git/*|.env|.env.*)
continue
;;
esac
destination="$PAYLOAD_DIR/$relative_path"
if [[ -d "$item" ]]; then
mkdir -p "$destination"
continue
fi
mkdir -p "$(dirname "$destination")"
cp -f "$item" "$destination"
done < <(find "$CHECKOUT_DIR" -mindepth 1 -print0)
}
copy_payload_to_deploy() {
mkdir -p "$DEPLOY_PATH"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude '.env' \
--exclude '.env.*' \
"$PAYLOAD_DIR"/ "$DEPLOY_PATH"/
return
fi
while IFS= read -r -d '' item; do
relative_path="${item#"$PAYLOAD_DIR"/}"
if [[ "$relative_path" == ".env" || "$relative_path" == .env.* ]]; then
continue
fi
destination="$DEPLOY_PATH/$relative_path"
if [[ -d "$item" ]]; then
mkdir -p "$destination"
continue
fi
mkdir -p "$(dirname "$destination")"
cp -f "$item" "$destination"
done < <(find "$PAYLOAD_DIR" -mindepth 1 -print0)
}
merge_env_file() {
local template="$1"
local live="$2"
[[ -f "$template" ]] || { echo "[deploy-git] No env template at $template, skipping merge"; return 0; }
[[ -f "$live" ]] || { echo "[deploy-git] No live .env at $live, skipping merge"; return 0; }
local added diffs backup
added="$(mktemp)"
diffs="$(mktemp)"
backup="${live}.bak.$(date -u +%Y%m%dT%H%M%SZ)"
awk -v live="$live" -v added_log="$added" -v diff_log="$diffs" '
function trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s }
BEGIN {
while ((getline line < live) > 0) {
if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue
eq = index(line, "=")
if (eq == 0) continue
k = trim(substr(line, 1, eq-1))
v = substr(line, eq+1)
live_keys[k] = v
live_seen[k] = 1
}
close(live)
}
/^[ \t]*#/ || /^[ \t]*$/ { next }
{
eq = index($0, "=")
if (eq == 0) next
k = trim(substr($0, 1, eq-1))
v = substr($0, eq+1)
if (!(k in live_seen)) {
print k "=" v >> added_log
} else if (live_keys[k] != v) {
print k " (template=" v " | live=" live_keys[k] ")" >> diff_log
}
}
' "$template"
if [[ -s "$added" ]]; then
cp "$live" "$backup"
echo "[deploy-git] Adding env keys present in template but missing from $live:"
sed 's/^/ + /' "$added"
{
printf '\n# Appended by deploy-from-git.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat "$added"
} >> "$live"
echo "[deploy-git] Backup of previous .env written to $backup"
else
echo "[deploy-git] .env is up to date with template (no missing keys)"
fi
if [[ -s "$diffs" ]]; then
echo "[deploy-git] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:"
sed 's/^/ ! /' "$diffs"
echo "[deploy-git] If a live value is stale, edit $live and re-deploy."
fi
rm -f "$added" "$diffs"
}
check_site() {
if (( SKIP_SITE_CHECK )) || [[ -z "$VERIFY_URL" ]]; then
return 0
fi
echo "[deploy-git] Checking production site: $VERIFY_URL"
if command -v curl >/dev/null 2>&1; then
local http_code
if http_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 -L "$VERIFY_URL" 2>/dev/null)"; then
echo "[deploy-git] Site responded with HTTP $http_code"
else
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
fi
return 0
fi
if command -v wget >/dev/null 2>&1; then
if wget --spider --server-response --timeout=30 "$VERIFY_URL" >/tmp/goodwalk-site-check.$$ 2>&1; then
awk '/^ HTTP\// { code=$2 } END { if (code != "") printf "[deploy-git] Site responded with HTTP %s\n", code }' /tmp/goodwalk-site-check.$$
else
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
fi
rm -f /tmp/goodwalk-site-check.$$
return 0
fi
echo "[deploy-git] WARNING: curl/wget not available; skipping site check" >&2
}
trap cleanup EXIT
echo "[deploy-git] Deploying main Goodwalk stack from Git"
echo "[deploy-git] Repo URL: $REPO_URL"
echo "[deploy-git] Branch: $BRANCH"
if [[ -n "$REF" ]]; then
echo "[deploy-git] Requested ref: $REF"
fi
echo "[deploy-git] Target deployment path: $DEPLOY_PATH"
echo "[deploy-git] Compose file: $COMPOSE_FILE"
echo "[deploy-git] Docker project: $PROJECT_NAME"
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-git] Target service: $SERVICE_NAME"
fi
if (( nginx_args_present )); then
echo "[deploy-git] Nginx config source: $NGINX_SOURCE"
echo "[deploy-git] Nginx config target: $NGINX_TARGET"
echo "[deploy-git] Nginx compose file: $NGINX_COMPOSE_FILE"
echo "[deploy-git] Nginx project: $NGINX_PROJECT_NAME"
echo "[deploy-git] Maintenance host dir: $MAINTENANCE_HOST_DIR"
echo "[deploy-git] Maintenance flag path: $MAINTENANCE_FLAG_PATH"
fi
echo "[deploy-git] Cloning repository into: $CHECKOUT_DIR"
git clone "$REPO_URL" "$CHECKOUT_DIR"
git -C "$CHECKOUT_DIR" fetch --tags --prune origin
if [[ -n "$REF" ]]; then
git -C "$CHECKOUT_DIR" checkout --detach "$REF"
else
git -C "$CHECKOUT_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
fi
DEPLOYED_REVISION="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)"
echo "[deploy-git] Using repo revision: $DEPLOYED_REVISION"
EXPORT_SCRIPT="$CHECKOUT_DIR/scripts/export-homepage-content.mjs"
[[ -f "$EXPORT_SCRIPT" ]] || fail "Homepage export script not found: $EXPORT_SCRIPT"
echo "[deploy-git] Exporting current homepage content for PostgreSQL sync"
run_homepage_export "/app/scripts/export-homepage-content.mjs" "/app/deploy-data/homepage-content.json"
echo "[deploy-git] Preparing deployment payload"
copy_checkout_to_payload
[[ -f "$PAYLOAD_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from repo checkout: $COMPOSE_FILE"
if [[ -f "$DEPLOY_PATH/.env" ]]; then
echo "[deploy-git] Preserving existing $DEPLOY_PATH/.env"
fi
echo "[deploy-git] Copying application files into $DEPLOY_PATH"
copy_payload_to_deploy
[[ -f "$DEPLOY_PATH/$COMPOSE_FILE" ]] || fail "Compose file missing after copy: $DEPLOY_PATH/$COMPOSE_FILE"
if [[ ! -f "$DEPLOY_PATH/.env" ]]; then
if [[ -f "$DEPLOY_PATH/deploy.env.template" ]]; then
echo "[deploy-git] No remote .env found. Creating $DEPLOY_PATH/.env from deploy.env.template"
cp "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
else
fail "Remote .env is missing and deploy.env.template was not present"
fi
fi
merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
cd "$DEPLOY_PATH"
echo "[deploy-git] Validating compose configuration"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null
if [[ -n "$SERVICE_NAME" ]]; then
AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)"
if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then
fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')"
fi
fi
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"
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"
[[ -f "$MAINTENANCE_LOGO_SRC" ]] || fail "Maintenance logo missing from deployment payload: $MAINTENANCE_LOGO_SRC"
NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)"
[[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying."
if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \
| grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then
fail "nginx container is missing the maintenance bind mount.
Expected: ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
One-time setup on the droplet:
mkdir -p ${MAINTENANCE_HOST_DIR}/m
# add this volume to ${NGINX_COMPOSE_FILE}:
# - ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
${COMPOSE_CMD[*]} -p ${NGINX_PROJECT_NAME} -f ${NGINX_COMPOSE_FILE} up -d"
fi
FLAG_DIR="$(dirname "$MAINTENANCE_FLAG_PATH")"
[[ -d "$FLAG_DIR" ]] || fail "Maintenance flag directory does not exist on host: $FLAG_DIR"
echo "[deploy-git] Writing maintenance assets to host bind dir: $MAINTENANCE_HOST_DIR"
mkdir -p "$MAINTENANCE_HOST_DIR/m"
install -m 0644 "$MAINTENANCE_HTML_SRC" "$MAINTENANCE_HOST_DIR/maintenance.html"
install -m 0644 "$MAINTENANCE_LOGO_SRC" "$MAINTENANCE_HOST_DIR/m/logo.png"
echo "[deploy-git] Updating shared nginx config (pre-rebuild) so maintenance routing is active"
mkdir -p "$(dirname "$NGINX_TARGET")"
cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET"
echo "[deploy-git] Validating nginx configuration"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
echo "[deploy-git] Reloading shared nginx so the new config (incl. maintenance routing) is live"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
echo "[deploy-git] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH"
: > "$MAINTENANCE_FLAG_PATH"
MAINTENANCE_ACTIVE=1
fi
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-git] Stopping only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
echo "[deploy-git] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME"
else
echo "[deploy-git] Stopping only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true
echo "[deploy-git] Rebuilding and starting only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans
fi
echo "[deploy-git] Current Goodwalk container status"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then
echo "[deploy-git] Syncing homepage content into PostgreSQL"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
fi
clear_maintenance_flag
check_site
echo "[deploy-git] Remote deployment finished"
echo "[deploy-git] Deployed revision: $DEPLOYED_REVISION"
+64 -30
View File
@@ -9,51 +9,21 @@
<link rel="apple-touch-icon" href="/images/goodwalk-favicon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
/>
<link
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
rel="stylesheet"
media="print"
onload="this.media='all'"
/>
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
rel="stylesheet"
/>
</noscript>
<link
rel="preconnect"
href="https://cdnjs.cloudflare.com"
crossorigin="anonymous"
/>
<link
rel="preload"
as="style"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
media="print"
onload="this.media='all'"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<noscript>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</noscript>
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-K7TLSFJVP1"></script>
<script>
@@ -62,9 +32,73 @@
gtag('js', new Date());
gtag('config', 'G-K7TLSFJVP1');
</script>
<style>
.no-js-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 20, 24, 0.58);
}
.no-js-card {
width: min(100%, 560px);
padding: 32px 28px;
border-radius: 24px;
background: #fff;
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.2);
text-align: left;
font-family: 'Readex Pro', system-ui, sans-serif;
color: #213021;
}
.no-js-kicker {
display: inline-block;
margin-bottom: 14px;
padding: 6px 10px;
border-radius: 999px;
background: #f3ecd9;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #213021;
}
.no-js-card h1 {
margin: 0 0 12px;
font-family: 'Unbounded', system-ui, sans-serif;
font-size: clamp(28px, 4vw, 38px);
line-height: 1.05;
letter-spacing: -0.04em;
color: #213021;
}
.no-js-card p {
margin: 0;
font-size: 16px;
line-height: 1.65;
color: #4c5056;
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<noscript>
<div class="no-js-overlay" role="alertdialog" aria-labelledby="no-js-title" aria-describedby="no-js-copy">
<div class="no-js-card">
<span class="no-js-kicker">JavaScript Required</span>
<h1 id="no-js-title">Please enable JavaScript to use this site.</h1>
<p id="no-js-copy">
Goodwalk relies on JavaScript for key parts of the site, including the booking and contact forms.
Please turn JavaScript back on in your browser settings and reload the page.
</p>
</div>
</div>
</noscript>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+735 -100
View File
@@ -1,63 +1,254 @@
<script lang="ts">
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import ServicesSection from '$lib/components/ServicesSection.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
export let content: SiteSharedContent;
export let pageContent: AboutPageContent;
const leadSection = pageContent.sections[0];
const storySections = pageContent.sections.slice(1);
const storyPillars = ['Small and medium dogs', 'Auckland Central', 'Positive reinforcement'];
const careStripItems = [
{ label: 'Vaccinated dogs', icon: 'fas fa-shield-heart' },
{ label: 'Well-mannered groups', icon: 'fas fa-award' },
{ label: 'Comfortable with people', icon: 'fas fa-handshake-angle' },
{ label: 'Social with other dogs', icon: 'fas fa-paw' },
{ label: 'Structured adventures', icon: 'fas fa-tree' },
{ label: 'Small-pack focus', icon: 'fas fa-users' }
];
const trustItems = [
{
icon: 'fas fa-shield-heart',
title: 'First aid trained',
body: 'All walkers hold a current First Aid training certificate.'
},
{
icon: 'fas fa-user-shield',
title: 'Insured and police-checked',
body: 'We are covered by public liability insurance, and each dog walker is police checked.'
},
{
icon: 'fas fa-camera',
title: 'Clear updates',
body: 'Clients get social media updates, photos, and practical feedback after outings.'
},
{
icon: 'fas fa-clipboard-check',
title: 'Thoughtful onboarding',
body: 'Every new dog starts with a Meet & Greet and assessment walks before joining the routine.'
}
];
const workflowItems = [
{
icon: 'fas fa-users',
title: 'Small groups, not chaos',
body: 'Tiny Gang walks are kept to 4-8 dogs, designed specifically for small and medium breeds.'
},
{
icon: 'fas fa-person-walking',
title: 'Solo support when needed',
body: 'One-on-one walks are available for dogs that need more space, more focus, or a different pace.'
},
{
icon: 'fas fa-car-side',
title: 'Pick-up, drop-off, and adventure',
body: 'We collect dogs, head to Auckland parks and beaches, and build outings around movement, sniffing, and manners.'
},
{
icon: 'fas fa-shield-heart',
title: 'Weather and emergency plans',
body: 'We walk in most conditions when it is safe, and if there is an emergency we contact you and follow your vet protocol.'
}
];
const proofItems = content.testimonials.slice(0, 6);
</script>
<main class="about-page">
<section class="about-hero">
<section class="about-page-hero">
<div class="about-inner">
<h1>{pageContent.title}</h1>
</div>
</section>
{#each pageContent.sections as section}
<section
use:reveal
class:about-section-gradient={section.accent === 'gradient'}
class="about-section reveal-block"
>
<div class:about-section-reverse={section.reverse} class="about-inner about-section-grid">
<div class="about-copy">
<h2>{section.title}</h2>
{#each section.body as paragraph}
<p>{paragraph}</p>
{/each}
<section class="about-hero">
<div class="about-inner">
<div class="about-hero-grid">
<div class="about-hero-copy">
<span class="about-kicker">About GoodWalk</span>
<h2>{leadSection.title}</h2>
<p class="about-hero-intro">{leadSection.body[0]}</p>
<div class="about-pillars" aria-label="What shapes GoodWalk">
{#each storyPillars as pillar}
<span>{pillar}</span>
{/each}
</div>
</div>
<div class="about-media">
<div class="about-hero-media">
<img
src={section.imageUrl}
alt={section.imageAlt}
width={getImageMetadata(section.imageUrl)?.width}
height={getImageMetadata(section.imageUrl)?.height}
loading="lazy"
src={leadSection.imageUrl}
alt={leadSection.imageAlt}
width={getImageMetadata(leadSection.imageUrl)?.width}
height={getImageMetadata(leadSection.imageUrl)?.height}
fetchpriority="high"
decoding="async"
/>
</div>
</div>
</div>
</section>
<section class="about-ribbon">
<div class="about-inner">
<div class="about-ribbon-track" aria-label="GoodWalk pack standards">
{#each careStripItems as item, index}
<span class={`about-ribbon-chip about-ribbon-chip-${(index % 3) + 1}`}>
<Icon name={item.icon} />
{item.label}
</span>
{/each}
</div>
</div>
</section>
<section use:reveal class="about-intro-band reveal-block">
<div class="about-inner">
<div class="about-intro-grid">
<div class="about-intro-main">
<span class="about-kicker">Our story</span>
<p>{leadSection.body[1]}</p>
</div>
<aside class="about-intro-aside">
<span class="about-kicker">What clients are choosing</span>
<ul>
<li>Small-group pack walks built for the right dogs</li>
<li>One-on-one walks for dogs needing more space or focus</li>
<li>Puppy visits that build confidence before pack life</li>
</ul>
</aside>
</div>
</div>
</section>
{#each storySections as section, index}
<section use:reveal class={`about-editorial reveal-block ${index % 2 === 0 ? 'about-editorial-tint' : ''}`}>
<div class="about-inner">
<div class:about-editorial-reverse={section.reverse} class="about-editorial-grid">
<div class="about-editorial-copy">
<span class="about-kicker">0{index + 2}</span>
<h2>{section.title}</h2>
{#each section.body as paragraph}
<p>{paragraph}</p>
{/each}
</div>
<div class="about-editorial-media">
<img
src={section.imageUrl}
alt={section.imageAlt}
width={getImageMetadata(section.imageUrl)?.width}
height={getImageMetadata(section.imageUrl)?.height}
loading="lazy"
decoding="async"
/>
</div>
</div>
</div>
</section>
{/each}
<section use:reveal class="about-credentials reveal-block">
<div class="about-inner">
<div class="about-credentials-head">
<span class="about-kicker about-kicker-light">Experience and safety</span>
<h2>Practical trust signals, not filler.</h2>
</div>
<div class="about-credentials-list">
{#each trustItems as item}
<article class="about-credential-row">
<div class="about-credential-mark" aria-hidden="true">
<Icon name={item.icon} />
</div>
<div>
<h3>{item.title}</h3>
<p>{item.body}</p>
</div>
</article>
{/each}
</div>
</div>
</section>
<section use:reveal class="about-workflow reveal-block">
<div class="about-inner">
<div class="about-workflow-head">
<span class="about-kicker">How we work</span>
<h2>Clear standards, calm handling, and a routine dogs can settle into.</h2>
</div>
<div class="about-workflow-lines">
{#each workflowItems as item, index}
<article class="about-workflow-row">
<div class="about-workflow-index">0{index + 1}</div>
<div class="about-workflow-icon" aria-hidden="true">
<Icon name={item.icon} />
</div>
<h3>{item.title}</h3>
<p>{item.body}</p>
</article>
{/each}
</div>
</div>
</section>
{#if proofItems.length > 0}
<section use:reveal class="about-proof reveal-block">
<div class="about-inner">
<div class="about-proof-head">
<span class="about-kicker">Proof</span>
<h2>Dogs who drag their people to the gate tell the story better than we can.</h2>
</div>
<div class="about-proof-grid">
{#each proofItems as item, index}
<article class={`about-proof-card about-proof-card-${(index % 3) + 1}`}>
<p class="about-proof-quote">{item.quote}</p>
<p class="about-proof-meta">{item.reviewer} · {item.detail}</p>
</article>
{/each}
</div>
</div>
</section>
{/if}
<ServicesSection services={content.services} heading={pageContent.servicesTitle} />
<section use:reveal={{ delay: 70 }} class="about-contact reveal-block">
<div class="about-inner">
<div class="about-contact-card">
<h2>{pageContent.contact.title}</h2>
<div class="about-contact-grid">
<div class="about-contact-band">
<div class="about-contact-copy">
<span class="about-kicker">Get in touch</span>
<h2>{pageContent.contact.title}</h2>
<p>Tell us a little about your dog and well help you figure out the right fit.</p>
</div>
<div class="about-contact-actions">
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
{pageContent.contact.email}
<span>Email</span>
<strong>{pageContent.contact.email}</strong>
</a>
<a class="btn btn-yellow" href={pageContent.contact.cta.href}>
{pageContent.contact.cta.label}
</a>
<a class="about-contact-link" href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}>
{pageContent.contact.phone}
<span>Phone</span>
<strong>{pageContent.contact.phone}</strong>
</a>
</div>
</div>
@@ -67,7 +258,9 @@
<style>
.about-page {
background: var(--off-white);
background:
radial-gradient(circle at top left, rgba(255, 209, 0, 0.14), transparent 24%),
linear-gradient(180deg, #fffdfa 0%, #fbfbfb 28%, #f7f4ed 100%);
}
.about-inner {
@@ -76,13 +269,17 @@
padding: 0 50px;
}
.about-hero {
padding: 72px 0 40px;
.about-page-hero {
padding: 72px 0 26px;
}
.about-hero h1,
.about-copy h2,
.about-contact-card h2 {
.about-page-hero h1,
.about-hero h2,
.about-editorial-copy h2,
.about-credentials h2,
.about-workflow h2,
.about-proof h2,
.about-contact h2 {
margin: 0;
font-family: var(--font-head);
font-size: clamp(34px, 4vw, 56px);
@@ -91,62 +288,360 @@
color: #000;
}
.about-hero h1 {
.about-page-hero h1 {
text-align: center;
}
.about-section {
padding: 0 0 88px;
.about-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(33, 48, 33, 0.64);
}
.about-section-gradient {
margin: 0 24px 88px;
padding: 40px 0;
border-radius: 28px;
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
.about-kicker-light {
color: rgba(255, 255, 255, 0.64);
}
.about-section-grid {
.about-hero {
padding: 0 0 20px;
}
.about-hero-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 0.95fr) minmax(380px, 1.05fr);
gap: 44px;
align-items: center;
}
.about-section-reverse {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
.about-hero-copy h2 {
margin-top: 12px;
max-width: 11ch;
}
.about-section-reverse .about-copy {
.about-hero-intro {
margin: 22px 0 0;
max-width: 35rem;
color: #34363a;
font-size: clamp(18px, 1.8vw, 20px);
line-height: 1.85;
}
.about-pillars {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.about-pillars span {
padding: 10px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
font-size: 14px;
font-weight: 700;
}
.about-hero-media img {
display: block;
width: 100%;
aspect-ratio: 16 / 11;
border-radius: 34px;
object-fit: cover;
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.12);
}
.about-ribbon {
padding: 6px 0 28px;
}
.about-ribbon-track {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 0;
}
.about-ribbon-chip {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 10px 16px;
border-radius: 999px;
color: var(--green);
font-size: 14px;
font-weight: 700;
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.08);
}
.about-ribbon-chip-1 {
background: #f3efe7;
}
.about-ribbon-chip-2 {
background: #fff7d5;
}
.about-ribbon-chip-3 {
background: #e7efe0;
}
.about-intro-band {
padding: 0 0 52px;
}
.about-intro-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
gap: 34px;
align-items: start;
padding-top: 22px;
border-top: 1px solid rgba(33, 48, 33, 0.12);
}
.about-intro-main p {
margin: 12px 0 0;
font-size: clamp(22px, 2.4vw, 31px);
line-height: 1.45;
letter-spacing: -0.03em;
color: #1f2328;
}
.about-intro-aside {
padding-left: 26px;
border-left: 3px solid rgba(33, 48, 33, 0.14);
}
.about-intro-aside ul {
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.about-intro-aside li {
color: #40444a;
font-size: 15px;
line-height: 1.7;
}
.about-intro-aside li + li {
margin-top: 10px;
}
.about-editorial,
.about-workflow,
.about-proof {
padding: 0 0 72px;
}
.about-editorial-tint {
background: linear-gradient(180deg, rgba(245, 239, 230, 0.66) 0%, rgba(245, 239, 230, 0) 100%);
}
.about-editorial-grid {
display: grid;
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
gap: 52px;
align-items: center;
}
.about-editorial-reverse .about-editorial-copy {
order: 2;
}
.about-section-reverse .about-media {
.about-editorial-reverse .about-editorial-media {
order: 1;
}
.about-copy h2 {
font-size: clamp(28px, 3vw, 40px);
.about-editorial-copy h2 {
margin-top: 12px;
font-size: clamp(28px, 3vw, 42px);
}
.about-copy p {
.about-editorial-copy p {
margin: 18px 0 0;
color: #34363a;
font-size: 17px;
line-height: 1.8;
}
.about-editorial-media img {
display: block;
width: 100%;
border-radius: 32px;
object-fit: cover;
box-shadow: 0 18px 44px rgba(17, 20, 24, 0.12);
}
.about-credentials {
padding: 68px 0 72px;
background: var(--green);
color: #fff;
}
.about-credentials-head {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 28px;
align-items: end;
margin-bottom: 28px;
}
.about-credentials h2 {
color: #fff;
font-size: clamp(30px, 3.2vw, 46px);
}
.about-credentials-list {
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
.about-credential-row {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 18px;
align-items: start;
padding: 24px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.about-credential-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
color: var(--yellow);
font-size: 18px;
}
.about-credential-row h3 {
margin: 0;
font-family: var(--font-head);
font-size: 20px;
line-height: 1.2;
color: #fff;
}
.about-credential-row p {
margin: 10px 0 0;
max-width: 52rem;
color: rgba(255, 255, 255, 0.8);
font-size: 15px;
line-height: 1.7;
}
.about-workflow-head,
.about-proof-head {
margin-bottom: 28px;
}
.about-workflow-head h2,
.about-proof-head h2 {
margin-top: 12px;
max-width: 16ch;
font-size: clamp(28px, 3vw, 42px);
}
.about-workflow-lines {
border-top: 1px solid rgba(33, 48, 33, 0.12);
}
.about-workflow-row {
display: grid;
grid-template-columns: 70px 54px minmax(220px, 0.45fr) minmax(0, 1fr);
gap: 18px;
align-items: start;
padding: 24px 0;
border-bottom: 1px solid rgba(33, 48, 33, 0.12);
}
.about-workflow-index {
font-family: var(--font-head);
font-size: 18px;
line-height: 1;
color: rgba(33, 48, 33, 0.38);
}
.about-workflow-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
border-radius: 18px;
background: #fff;
color: var(--green);
font-size: 18px;
box-shadow: 0 12px 28px rgba(17, 20, 24, 0.08);
}
.about-workflow-row h3 {
margin: 0;
font-family: var(--font-head);
font-size: 20px;
line-height: 1.25;
color: #000;
}
.about-workflow-row p {
margin: 0;
color: #40444a;
font-size: 15px;
line-height: 1.7;
}
.about-proof-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
align-items: start;
}
.about-proof-card {
padding: 24px 22px;
border-radius: 26px;
background: rgba(255, 255, 255, 0.84);
box-shadow:
inset 0 0 0 1px rgba(33, 48, 33, 0.06),
0 14px 34px rgba(17, 20, 24, 0.07);
}
.about-proof-card-1 {
transform: rotate(-1.2deg);
background: #fffdfa;
}
.about-proof-card-2 {
transform: translateY(18px);
background: #f7f4ed;
}
.about-proof-card-3 {
transform: rotate(1deg);
background: #f4f7f1;
}
.about-proof-quote {
margin: 0;
color: #2e3033;
font-size: 15px;
line-height: 1.75;
}
.about-media img {
display: block;
width: 100%;
max-width: 460px;
aspect-ratio: 4 / 3;
height: auto;
margin-left: auto;
margin-right: auto;
border-radius: 28px;
object-fit: cover;
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
.about-proof-meta {
margin: 16px 0 0;
color: var(--green);
font-size: 14px;
font-weight: 700;
}
:global(.reveal-ready.reveal-block) {
@@ -167,47 +662,89 @@
padding: 0 0 88px;
}
.about-contact-card {
border-radius: 28px;
background: #fff;
padding: 42px 48px;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
text-align: center;
.about-contact-band {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 28px;
align-items: center;
padding: 38px 0 0;
border-top: 1px solid rgba(33, 48, 33, 0.12);
}
.about-contact-card h2 {
.about-contact-copy h2 {
margin-top: 12px;
font-size: clamp(28px, 3vw, 42px);
}
.about-contact-grid {
.about-contact-copy p {
margin: 14px 0 0;
color: #4a4e54;
font-size: 16px;
line-height: 1.75;
}
.about-contact-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 18px;
align-items: center;
margin-top: 28px;
}
.about-contact-link {
display: grid;
gap: 6px;
padding: 18px 20px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.72);
color: #34363a;
font-size: 20px;
font-weight: 600;
text-decoration: none;
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.06);
}
.about-contact-link span {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(33, 48, 33, 0.52);
}
.about-contact-link strong {
font-size: 20px;
line-height: 1.3;
}
.about-contact-actions .btn {
min-height: 62px;
padding-inline: 32px;
}
@media (max-width: 1024px) {
.about-section-grid,
.about-section-reverse {
.about-hero-grid,
.about-intro-grid,
.about-editorial-grid,
.about-credentials-head,
.about-contact-band,
.about-contact-actions {
grid-template-columns: 1fr;
}
.about-section-reverse .about-copy,
.about-section-reverse .about-media {
.about-workflow-row {
grid-template-columns: 70px 54px minmax(0, 1fr);
}
.about-workflow-row p {
grid-column: 2 / -1;
}
.about-proof-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.about-editorial-reverse .about-editorial-copy,
.about-editorial-reverse .about-editorial-media {
order: initial;
}
.about-contact-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
@@ -215,44 +752,142 @@
padding: 0 24px;
}
.about-hero {
padding: 56px 0 24px;
.about-page-hero {
padding: 56px 0 20px;
}
.about-section,
.about-contact {
padding-bottom: 64px;
.about-page-hero h1,
.about-hero h2 {
font-size: 34px;
}
.about-section-gradient {
margin: 0 12px 64px;
padding: 28px 0;
border-radius: 28px;
.about-ribbon {
padding-bottom: 18px;
}
.about-section-grid {
.about-ribbon .about-inner {
padding-right: 0;
}
.about-ribbon-track {
flex-wrap: nowrap;
gap: 10px;
overflow-x: auto;
padding: 0 24px 10px 0;
scroll-snap-type: x proximity;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.about-ribbon-track::-webkit-scrollbar {
display: none;
}
.about-ribbon-chip {
flex: 0 0 auto;
scroll-snap-align: start;
font-size: 13px;
}
.about-hero,
.about-intro-band,
.about-editorial,
.about-workflow,
.about-proof {
padding-bottom: 56px;
}
.about-hero-grid,
.about-intro-grid,
.about-editorial-grid {
gap: 24px;
}
.about-copy h2,
.about-contact-card h2 {
font-size: 30px;
}
.about-copy p {
.about-hero-intro,
.about-editorial-copy p,
.about-contact-copy p {
font-size: 16px;
line-height: 1.7;
}
.about-contact-card {
padding: 30px 24px;
.about-intro-main p {
font-size: 22px;
}
.about-contact-grid {
margin-top: 22px;
.about-intro-aside {
padding-left: 18px;
border-left-width: 2px;
}
.about-contact-link {
.about-credentials {
padding: 52px 0 56px;
}
.about-workflow .about-inner,
.about-proof .about-inner {
padding-right: 0;
}
.about-workflow-row {
grid-template-columns: 1fr;
gap: 14px;
min-width: 280px;
padding: 22px 20px;
border: none;
border-radius: 26px;
background: rgba(255, 255, 255, 0.84);
box-shadow:
inset 0 0 0 1px rgba(33, 48, 33, 0.06),
0 12px 28px rgba(17, 20, 24, 0.06);
scroll-snap-align: start;
}
.about-workflow-row p {
grid-column: auto;
}
.about-workflow-lines {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(280px, 82vw);
gap: 14px;
overflow-x: auto;
padding: 2px 24px 10px 0;
border-top: none;
scroll-snap-type: x proximity;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.about-workflow-lines::-webkit-scrollbar {
display: none;
}
.about-proof-grid {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(286px, 84vw);
gap: 14px;
overflow-x: auto;
padding: 2px 24px 10px 0;
scroll-snap-type: x proximity;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.about-proof-grid::-webkit-scrollbar {
display: none;
}
.about-proof-card,
.about-proof-card-1,
.about-proof-card-2,
.about-proof-card-3 {
transform: none;
scroll-snap-align: start;
}
.about-contact-link strong {
font-size: 18px;
}
}
+4 -1
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import Icon from '$lib/components/Icon.svelte';
import type { BookingContent } from '$lib/types';
import InfoSection from '$lib/components/InfoSection.svelte';
import type { BookingContent, InfoContent } from '$lib/types';
export let booking: BookingContent;
export let info: InfoContent;
export let allowGeneralEnquiry = false;
const email = 'info@goodwalk.co.nz';
@@ -35,6 +37,7 @@
</section>
<BookingSection {booking} {allowGeneralEnquiry} />
<InfoSection {info} />
</main>
<style>
+165 -60
View File
@@ -9,6 +9,9 @@
export let booking: BookingContent;
export let allowGeneralEnquiry = false;
type EnquiryType = 'booking' | 'general';
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const maxJourneyEntries = 8;
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
@@ -23,6 +26,12 @@
let selectedServices: string[] = [];
let website = '';
let formStartedAt = 0;
let visitStartedAt = 0;
let pageEnteredAt = 0;
let firstInteractionAt = 0;
let sendClickedAt = 0;
let stepChanges = 0;
let journey: string[] = [];
let fullNameInput: HTMLInputElement;
let emailInput: HTMLInputElement;
@@ -74,17 +83,21 @@
enquiryType = 'booking';
}
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
$: ownerSubtitle = isGeneralEnquiry
$: ownerIntro = isGeneralEnquiry
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
: booking.subtitle;
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
$: firstStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: firstStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: successPetName = petName.trim() || 'your dog';
onMount(() => {
formStartedAt = Date.now();
const now = Date.now();
formStartedAt = now;
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
});
function splitBookingTitle(title: string) {
@@ -107,7 +120,58 @@
}
}
function readOrCreateVisitStartedAt(fallback: number) {
try {
const raw = window.sessionStorage.getItem(visitStartedStorageKey);
const parsed = raw ? Number(raw) : NaN;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
window.sessionStorage.setItem(visitStartedStorageKey, String(fallback));
} catch {
return fallback;
}
return fallback;
}
function updateJourneySnapshot(pathname: string, search: string) {
const nextEntry = `${pathname}${search}`;
try {
const raw = window.sessionStorage.getItem(journeyStorageKey);
const previous = raw ? (JSON.parse(raw) as string[]) : [];
const cleaned = previous.filter((value) => typeof value === 'string' && value.trim());
const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry];
const nextJourney = deduped.slice(-maxJourneyEntries);
window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney));
return nextJourney;
} catch {
return [nextEntry];
}
}
function noteInteraction() {
if (!firstInteractionAt) {
firstInteractionAt = Date.now();
}
}
function setStep(nextStep: number, trackTransition = false) {
if (step !== nextStep && trackTransition) {
stepChanges += 1;
}
step = nextStep;
errors = {};
}
function toggleService(service: string, checked: boolean) {
noteInteraction();
if (checked) {
selectedServices = [...selectedServices, service];
return;
@@ -117,6 +181,7 @@
}
function setEnquiryType(nextType: EnquiryType) {
noteInteraction();
enquiryType = nextType;
if (nextType === 'general') {
petName = '';
@@ -126,7 +191,37 @@
errors = {};
}
function validateFirstStep(): boolean {
function validateOwnerStep(): boolean {
const next: Record<string, string> = {};
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
errors = next;
if (next.fullName) {
fullNameInput?.focus();
return false;
}
if (next.email) {
emailInput?.focus();
return false;
}
if (next.phone) {
phoneInput?.focus();
return false;
}
return true;
}
function validateDetailsStep(): boolean {
const next: Record<string, string> = {};
if (isGeneralEnquiry) {
@@ -156,9 +251,9 @@
}
function goToOwnerStep() {
if (!validateFirstStep()) return;
errors = {};
step = 2;
noteInteraction();
if (!validateDetailsStep()) return;
setStep(2, true);
}
async function handleSubmit(event: SubmitEvent) {
@@ -169,22 +264,13 @@
return;
}
const next: Record<string, string> = {};
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
if (Object.keys(next).length > 0) {
errors = next;
if (next.fullName) fullNameInput?.focus();
else if (next.email) emailInput?.focus();
else if (next.phone) phoneInput?.focus();
if (!validateOwnerStep()) {
return;
}
errors = {};
noteInteraction();
sendClickedAt = Date.now();
submitting = true;
submitErrorDetail = '';
showErrorModal = false;
@@ -194,26 +280,33 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enquiryType,
fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
referrer: document.referrer,
page: window.location.href,
})
enquiryType,
fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
referrer: document.referrer,
page: window.location.href
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const detail = typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
const detail =
typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
throw new Error(detail);
}
@@ -229,7 +322,6 @@
<section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
<div class="form-inner">
{#if submitted}
<SuccessModal
firstName={fullName.split(' ')[0]}
@@ -251,7 +343,8 @@
<div class="booking-header">
<h2 class="booking-title">
<span class="booking-title-plain">{headingParts.plain}</span>{' '}<span class="booking-title-highlight">{headingParts.highlight}</span>
<span class="booking-title-plain">{headingParts.plain}</span>{' '}
<span class="booking-title-highlight">{headingParts.highlight}</span>
</h2>
<div class="booking-stepper" aria-label="Booking form steps">
@@ -259,10 +352,10 @@
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => (step = 1)}
on:click={() => setStep(1, step !== 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{firstStepLabel}</span>
<span class="booking-step-label">{detailsStepLabel}</span>
</button>
<span class="booking-step-divider" aria-hidden="true"></span>
<button
@@ -282,6 +375,9 @@
id="bookingForm"
novalidate
on:submit={handleSubmit}
on:focusin={noteInteraction}
on:input={noteInteraction}
on:change={noteInteraction}
>
<div class="booking-honeypot" aria-hidden="true">
<label for="website">Website</label>
@@ -296,12 +392,16 @@
</div>
{#if step === 1}
<input type="hidden" name="enquiryType" value={enquiryType} />
<div class="booking-panel">
{#if firstStepIntro}
<div class="booking-panel-banner">{firstStepIntro}</div>
{#if detailsStepIntro}
<div class="booking-panel-banner">{detailsStepIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(firstStepIntro)} class="booking-card-grid booking-card-grid-dog">
<div
class:booking-card-grid-with-banner={Boolean(detailsStepIntro)}
class="booking-card-grid booking-card-grid-dog"
>
{#if allowGeneralEnquiry}
<div class="booking-field-card booking-field-card-full">
<label>
@@ -361,7 +461,10 @@
{/if}
</div>
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
<div
class="booking-field-card booking-field-card-wide"
class:booking-field-card-invalid={errors.location}
>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
</label>
@@ -385,7 +488,10 @@
</div>
{/if}
<div class="booking-field-card booking-field-card-full" class:booking-field-card-invalid={errors.message}>
<div
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
@@ -438,26 +544,25 @@
{ownerStepLabel}
<Icon name="fas fa-arrow-right" />
</button>
<p class="booking-next-note">Response from us within 24 hours</p>
</div>
{:else}
<input type="hidden" name="enquiryType" value={enquiryType} />
{#if !isGeneralEnquiry}
<input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} />
{/if}
<input type="hidden" name="fullName" value={fullName} />
<input type="hidden" name="email" value={email} />
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} />
<input type="hidden" name="message" value={message} />
{#if !isGeneralEnquiry}
{#each selectedServices as service}
<input type="hidden" name="services" value={service} />
{/each}
{/if}
<div class="booking-panel">
{#if ownerSubtitle}
<div class="booking-panel-banner">{ownerSubtitle}</div>
{#if ownerIntro}
<div class="booking-panel-banner">{ownerIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(ownerSubtitle)} class="booking-card-grid booking-card-grid-owner">
<div
class:booking-card-grid-with-banner={Boolean(ownerIntro)}
class="booking-card-grid booking-card-grid-owner"
>
<div class="booking-field-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-owner">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
@@ -542,7 +647,7 @@
<button
type="button"
class="btn btn-outline btn-outline-green"
on:click={() => { step = 1; errors = {}; }}
on:click={() => setStep(1, true)}
>
Back
</button>
+48 -70
View File
@@ -3,8 +3,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import BookingSection from './BookingSection.svelte';
import { homepageContent } from '$lib/content/homepage';
async function fillOwnerStep() {
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
}
async function fillDogStep() {
await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services'));
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
target: { value: 'Loves small group walks.' }
});
}
async function moveToOwnerStep(container: HTMLElement) {
await fillDogStep();
await fireEvent.click(container.querySelector('.booking-next-button')!);
}
describe('BookingSection', () => {
beforeEach(() => {
window.sessionStorage.clear();
Object.defineProperty(document, 'referrer', {
configurable: true,
value: 'https://www.google.com/'
@@ -29,14 +61,7 @@ describe('BookingSection', () => {
booking: homepageContent.booking
});
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await moveToOwnerStep(container);
await fireEvent.click(container.querySelector('.booking-submit-button')!);
expect(screen.getByText('Please enter your full name')).toBeInTheDocument();
@@ -55,30 +80,8 @@ describe('BookingSection', () => {
booking: homepageContent.booking
});
await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services'));
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
target: { value: 'Loves small group walks.' }
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
await moveToOwnerStep(container);
await fillOwnerStep();
await fireEvent.click(container.querySelector('.booking-submit-button')!);
@@ -102,9 +105,15 @@ describe('BookingSection', () => {
message: 'Loves small group walks.',
services: ['Pack Walks', 'Other Services'],
website: '',
referrer: 'https://www.google.com/'
referrer: 'https://www.google.com/',
stepChanges: 1,
journey: [window.location.pathname]
});
expect(payload.formStartedAt).toEqual(expect.any(Number));
expect(payload.visitStartedAt).toEqual(expect.any(Number));
expect(payload.pageEnteredAt).toEqual(expect.any(Number));
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
expect(payload.sendClickedAt).toEqual(expect.any(Number));
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
@@ -128,15 +137,7 @@ describe('BookingSection', () => {
allowGeneralEnquiry: true
});
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Grey Lynn' }
});
await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText(/General enquiry/i));
expect(screen.queryByLabelText(/Dog's Name/i)).not.toBeInTheDocument();
expect(screen.queryByText('Pack Walks')).not.toBeInTheDocument();
@@ -148,17 +149,7 @@ describe('BookingSection', () => {
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
await fillOwnerStep();
await fireEvent.click(container.querySelector('.booking-submit-button')!);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
@@ -172,7 +163,9 @@ describe('BookingSection', () => {
petName: '',
location: '',
message: 'I would like to discuss a business partnership.',
services: []
services: [],
stepChanges: 1,
journey: [window.location.pathname]
});
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
@@ -192,23 +185,8 @@ describe('BookingSection', () => {
booking: homepageContent.booking
});
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
});
await fireEvent.input(screen.getByLabelText(/^Email/i), {
target: { value: 'alex@example.com' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
});
await moveToOwnerStep(container);
await fillOwnerStep();
await fireEvent.click(container.querySelector('.booking-submit-button')!);
+33 -7
View File
@@ -9,6 +9,24 @@
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
{ label: 'Google', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
];
const aboutLink: LinkItem = { label: 'About Us', href: '/about' };
function withAboutLink(links: LinkItem[]) {
if (links.some((link) => link.href === aboutLink.href || link.label === aboutLink.label)) {
return links;
}
const contactIndex = links.findIndex((link) => link.href === '/contact-us');
if (contactIndex === -1) {
return [...links, aboutLink];
}
return [...links.slice(0, contactIndex), aboutLink, ...links.slice(contactIndex)];
}
$: navigationLinks = withAboutLink(footer.navigationLinks);
</script>
<footer>
@@ -38,9 +56,9 @@
</div>
<div class="footer-explore">
<p class="footer-col-label">Explore</p>
<p class="footer-col-label">Explore Goodwalk</p>
<ul class="footer-nav">
{#each footer.navigationLinks as link}
{#each navigationLinks as link}
<li>
<a
href={link.href}
@@ -54,21 +72,29 @@
</ul>
</div>
<div class="footer-action">
<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 &amp; Greet. Start here and well reply within 24 hours.</p>
<a href="/contact-us" class="footer-book-btn">
Book a Meet &amp; Greet
Contact Us
<Icon name="fas fa-arrow-right" />
</a>
<p class="footer-book-note">Free, no-obligation introduction</p>
<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"
>
<Icon name="fab fa-google" />
<span>See our 5&#9733; Google reviews</span>
<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}
+20
View File
@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import Footer from './Footer.svelte';
import { homepageContent } from '$lib/content/homepage';
describe('Footer', () => {
it('adds the About Us link when footer content omits it', () => {
const footer = {
...homepageContent.footer,
navigationLinks: homepageContent.footer.navigationLinks.filter((link) => link.href !== '/about')
};
render(Footer, { footer });
const aboutLinks = screen.getAllByRole('link', { name: 'About Us' });
expect(aboutLinks).toHaveLength(1);
expect(aboutLinks[0]).toHaveAttribute('href', '/about');
});
});
+27 -1
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { HeroContent } from '$lib/types';
import type { CallToAction, HeroContent } from '$lib/types';
export let hero: HeroContent;
export let reviewCta: CallToAction | undefined = undefined;
$: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
@@ -44,6 +46,30 @@
<p class="hero-subtitle">{hero.subtitle}</p>
{/if}
{#if reviewCta}
<a
class="hero-trust-chip"
href={reviewCta.href}
target={reviewCta.external ? '_blank' : undefined}
rel={reviewCta.external ? 'noopener' : undefined}
aria-label="Read our five-star Google reviews"
>
<img
class="hero-trust-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span class="hero-trust-stars" aria-hidden="true">
{#each Array(5) as _}
<Icon name="fas fa-star" />
{/each}
</span>
<span>{reviewCta.label}</span>
</a>
{/if}
<div class="hero-buttons">
<a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a>
<a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a>
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import type { HowItWorksContent } from '$lib/types';
export let content: HowItWorksContent;
</script>
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
<div class="how-it-works-inner">
<div class="how-it-works-header">
<h2 class="section-heading">{content.title}</h2>
{#if content.intro}
<p class="how-it-works-intro">{content.intro}</p>
{/if}
</div>
<div class="how-it-works-flow" aria-label="How it works">
{#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>
{#if step.icon}
<div class="how-it-works-icon-bubble">
<Icon name={step.icon} className="how-it-works-icon" />
</div>
{/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}
{/each}
</div>
</div>
</section>
<style>
: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);
}
#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>
+140 -8
View File
@@ -3,20 +3,38 @@
import type { InfoContent } from '$lib/types';
export let info: InfoContent;
$: suburbChips = info.suburbs
.split(',')
.map((suburb) => suburb.trim())
.filter(Boolean);
</script>
<section id="info">
<div class="info-inner">
<div class="info-block">
<h2><Icon name="fas fa-location-dot" /> {info.title}</h2>
<p>{info.intro}</p>
<p class="info-copy">{info.suburbs}</p>
<p class="info-copy">
{info.nearbyText}
<a href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
</p>
<h3>{info.hoursLabel}</h3>
<p>{info.hours}</p>
<p class="info-lead">{info.intro}</p>
<p class="info-support">Regular walks across the inner-west and nearby suburbs.</p>
<div class="info-suburb-chips" aria-label="Suburbs we cover">
{#each suburbChips as suburb}
<span class="info-suburb-chip">{suburb}</span>
{/each}
</div>
<div class="info-nearby-card">
<div class="info-nearby-copy">
<span class="info-nearby-kicker">Nearby but not listed?</span>
<p>{info.nearbyText} There's a good chance we can still help.</p>
</div>
<a class="info-nearby-cta" href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
</div>
<div class="info-hours-card">
<h3>{info.hoursLabel}</h3>
<p>{info.hours}</p>
</div>
</div>
<div class="info-block">
@@ -32,3 +50,117 @@
</div>
</div>
</section>
<style>
.info-lead {
margin-bottom: 8px;
}
.info-support {
margin: 0 0 22px;
color: #5f6369;
}
.info-suburb-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 26px;
}
.info-suburb-chip {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 8px 14px;
border-radius: 999px;
background: #fff;
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 10px 24px rgba(17, 20, 24, 0.04);
color: #213021;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
.info-nearby-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
padding: 22px 24px;
border-radius: 24px;
background: linear-gradient(180deg, #fffaf0 0%, #f8f1e3 100%);
box-shadow: 0 14px 30px rgba(17, 20, 24, 0.05);
}
.info-nearby-copy p {
margin: 6px 0 0;
color: #4c5056;
}
.info-nearby-kicker {
display: inline-block;
color: var(--green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.info-nearby-cta {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 18px;
border-radius: 999px;
background: var(--green);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.info-hours-card {
padding-top: 18px;
border-top: 1px dashed rgba(33, 48, 33, 0.18);
}
.info-hours-card h3 {
margin-top: 0;
}
.info-hours-card p {
margin-bottom: 0;
}
@media (max-width: 768px) {
.info-suburb-chips {
gap: 8px;
margin-bottom: 22px;
}
.info-suburb-chip {
min-height: 36px;
padding: 8px 12px;
font-size: 13px;
}
.info-nearby-card {
flex-direction: column;
align-items: flex-start;
padding: 20px 18px;
}
.info-nearby-cta {
width: 100%;
}
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import Icon from '$lib/components/Icon.svelte';
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
/*
* Sticky bottom CTA shown on mobile only.
*
* Pattern is the Airbnb-style "soft container, scroll-triggered" —
* - white card sits flush against the bottom edge with a thin top
* hairline and a soft shadow so it reads as a tray, not a banner;
* - the brand-yellow pill CTA lives inside the card so the action
* is unmistakable but the surrounding chrome stays calm;
* - the bar only appears after the user has scrolled roughly one
* viewport (~hero out of view), so it doesn't compete with the
* in-page hero CTA.
*
* Hidden on the contact / booking flows (no point reminding someone
* to book while they're already on the form).
*/
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
$: pathname = $page.url.pathname;
$: hidden = pathname === '/contact-us' || pathname === '/booking';
let visible = false;
let triggerPassed = false;
let bookingInView = false;
let triggerObserver: IntersectionObserver | null = null;
let bookingObserver: IntersectionObserver | null = null;
function refreshVisibility() {
visible = !hidden && triggerPassed && !bookingInView;
}
function cleanupObservers() {
triggerObserver?.disconnect();
bookingObserver?.disconnect();
triggerObserver = null;
bookingObserver = null;
}
async function setupObservers() {
if (!mobileCtaButtonEnabled || typeof window === 'undefined') {
return;
}
await tick();
cleanupObservers();
const triggerEl =
document.getElementById('hero') ?? document.querySelector('main section, section');
const bookingEl = document.getElementById('newlead');
triggerPassed = !triggerEl;
bookingInView = false;
if (triggerEl) {
triggerObserver = new IntersectionObserver(
([entry]) => {
triggerPassed = !entry.isIntersecting && entry.boundingClientRect.top < 0;
refreshVisibility();
},
{ threshold: 0.2 }
);
triggerObserver.observe(triggerEl);
}
if (bookingEl) {
bookingObserver = new IntersectionObserver(
([entry]) => {
bookingInView = entry.isIntersecting;
refreshVisibility();
},
{ threshold: 0.2 }
);
bookingObserver.observe(bookingEl);
}
refreshVisibility();
}
afterNavigate(() => {
if (!mobileCtaButtonEnabled) {
return;
}
visible = false;
triggerPassed = false;
bookingInView = false;
void setupObservers();
});
onMount(() => {
if (!mobileCtaButtonEnabled) {
return;
}
void setupObservers();
return () => {
cleanupObservers();
};
});
</script>
{#if mobileCtaButtonEnabled && !hidden}
<div
class="mobile-book-bar"
class:mobile-book-bar-visible={visible}
aria-hidden={!visible}
>
<a class="mobile-book-bar-cta" href="/contact-us" tabindex={visible ? 0 : -1}>
<Icon name="fas fa-paw" />
<span>Book a free Meet &amp; Greet</span>
<Icon name="fas fa-arrow-right" className="mobile-book-bar-arrow" />
</a>
</div>
{/if}
<style>
.mobile-book-bar {
display: none;
}
@media (max-width: 768px) {
.mobile-book-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
justify-content: center;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(17, 20, 24, 0.08);
box-shadow: 0 -10px 28px rgba(17, 20, 24, 0.1);
opacity: 0;
transform: translateY(110%);
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.mobile-book-bar-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.mobile-book-bar-cta {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 460px;
padding: 13px 22px;
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
box-shadow: 0 8px 18px rgba(255, 209, 0, 0.4);
transition:
background 0.18s ease,
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.mobile-book-bar-cta:active {
transform: translateY(1px) scale(0.995);
background: #e6bb00;
}
:global(.mobile-book-bar-cta .icon) {
font-size: 13px;
}
:global(.mobile-book-bar-cta .mobile-book-bar-arrow) {
font-size: 12px;
opacity: 0.75;
}
@media (prefers-reduced-motion: reduce) {
.mobile-book-bar {
transition: opacity 0.22s ease;
transform: none;
}
}
}
</style>
+156 -3
View File
@@ -96,10 +96,33 @@
{#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>
{#each pageContent.sections as section}
{#each pageContent.sections as section, index}
<section use:reveal class="pricing-section reveal-block">
<div class="pricing-inner">
<div class="pricing-section-heading">
@@ -117,7 +140,8 @@
class={`btn pricing-section-link ${section.detailCta.variant === 'yellow' ? 'btn-yellow' : section.detailCta.variant === 'outline' ? 'btn-outline' : 'btn-green'}`}
href={section.detailCta.href}
>
{section.detailCta.label}
<span>{section.detailCta.label}</span>
<Icon name="fas fa-arrow-right" />
</a>
{/if}
</div>
@@ -143,6 +167,25 @@
</article>
{/each}
</div>
<a class="btn btn-yellow pricing-section-mobile-cta" href="#newlead">
Book a Meet &amp; Greet
</a>
{#if index === 0}
<aside class="pricing-mobile-consult" aria-label="Need help choosing the right option?">
<span class="pricing-mobile-consult-kicker">
<Icon name="fas fa-comment-dots" />
Not sure which option fits?
</span>
<p>
Book a free Meet &amp; Greet and well help you choose the right walk or visit for your dog.
</p>
<a class="btn btn-outline btn-outline-green pricing-mobile-consult-cta" href="#newlead">
Talk it through with us
</a>
</aside>
{/if}
</div>
</section>
{/each}
@@ -204,6 +247,52 @@
color: rgba(255, 255, 255, 0.7);
}
.pricing-trust {
display: inline-flex;
align-items: center;
gap: 12px;
margin-top: 22px;
padding: 9px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 14px;
font-weight: 600;
text-decoration: none;
transition:
background 0.2s ease,
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.pricing-trust-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.pricing-trust:hover {
background: rgba(255, 255, 255, 0.18);
transform: translateY(-1px);
}
.pricing-trust-stars {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--yellow);
font-size: 13px;
}
.pricing-trust-label {
letter-spacing: 0.01em;
}
:global(.pricing-trust .pricing-trust-arrow) {
font-size: 12px;
opacity: 0.85;
}
.pricing-section-heading h2 {
margin: 0;
text-align: center;
@@ -251,6 +340,9 @@
}
.pricing-section-link {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 18px;
}
@@ -339,6 +431,11 @@
font-family: var(--font-head);
}
.pricing-section-mobile-cta,
.pricing-mobile-consult {
display: none;
}
.meet-greet-prompt {
position: fixed;
right: 24px;
@@ -503,6 +600,12 @@
font-size: 34px;
}
.pricing-trust {
gap: 10px;
padding: 10px 14px;
font-size: 13px;
}
.pricing-section-heading h2 {
font-size: 26px;
}
@@ -512,7 +615,7 @@
}
.pricing-section-heading {
margin-bottom: 20px;
margin-bottom: 26px;
}
.pricing-section-blurb {
@@ -520,6 +623,11 @@
line-height: 1.55;
}
.pricing-section-link {
margin-top: 22px;
margin-bottom: 8px;
}
.pricing-plan-grid,
.pricing-plan-grid-three {
grid-template-columns: 1fr;
@@ -538,6 +646,51 @@
font-size: 46px;
}
.pricing-plan-cta {
display: none;
}
.pricing-section-mobile-cta {
display: flex;
width: fit-content;
margin: 18px auto 0;
font-family: var(--font-head);
}
.pricing-mobile-consult {
display: block;
margin-top: 18px;
padding: 22px 20px;
border-radius: 24px;
background: linear-gradient(180deg, #fffaf0 0%, #f9f4e7 100%);
box-shadow: 0 12px 28px rgba(17, 20, 24, 0.05);
text-align: left;
}
.pricing-mobile-consult-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pricing-mobile-consult p {
margin: 0;
color: #34363a;
font-size: 15px;
line-height: 1.55;
}
.pricing-mobile-consult-cta {
margin-top: 16px;
}
.meet-greet-prompt {
right: 16px;
left: 16px;
@@ -113,6 +113,20 @@
{/each}
</div>
{#if pageContent.pricing.scarcityNote}
<p class="service-plan-scarcity">
<Icon name="fas fa-clock" />
{pageContent.pricing.scarcityNote}
</p>
{/if}
<p class="service-plan-reassurance">
<Icon name="fas fa-shield-heart" />
Every booking starts with a free, no-obligation Meet &amp; Greet.
</p>
<a class="btn btn-yellow service-plan-mobile-cta" href="#newlead">Book a Meet &amp; Greet</a>
{#if pageContent.pricing.extras?.length}
<div class="service-extras">
<div class="service-extras-heading">Extras</div>
@@ -554,6 +568,46 @@
font-family: var(--font-head);
}
.service-plan-mobile-cta {
display: none;
}
.service-plan-reassurance,
.service-plan-scarcity {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: fit-content;
margin: 24px auto 0;
padding: 8px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
font-size: 14px;
font-weight: 600;
}
.service-plan-scarcity {
background: rgba(255, 209, 0, 0.18);
color: #5a4500;
}
.service-plan-reassurance + .service-plan-reassurance,
.service-plan-scarcity + .service-plan-reassurance {
margin-top: 12px;
}
:global(.service-plan-reassurance .icon) {
color: var(--yellow);
font-size: 14px;
}
:global(.service-plan-scarcity .icon) {
color: #b88800;
font-size: 14px;
}
.service-extras {
margin-top: 30px;
border-radius: 28px;
@@ -683,5 +737,16 @@
flex-direction: column;
align-items: flex-start;
}
.service-plan-cta {
display: none;
}
.service-plan-mobile-cta {
display: flex;
width: fit-content;
margin: 18px auto 0;
font-family: var(--font-head);
}
}
</style>
+2 -1
View File
@@ -27,7 +27,8 @@
{#if service.href}
<a href={service.href} class="btn btn-green">
Learn more<span class="visually-hidden"> about {service.title}</span>
<span>See {service.title} pricing</span>
<Icon name="fas fa-arrow-right" />
</a>
{/if}
</div>
+66 -29
View File
@@ -7,8 +7,7 @@
export let testimonials: TestimonialContent[];
export let heading = 'Why people choose us!';
export let blurb =
"Happy owners, even happier dogs. Our Auckland dog walking clients love what the Tiny Gang brings to their dog's routine — and you can see why. Follow along on Instagram for daily adventures, wagging tails and the odd zoomie";
export let 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 instagramHref = 'https://www.instagram.com/goodwalk.nz/';
export let instagramLabel = 'goodwalk.nz';
@@ -127,10 +126,6 @@
<h2 class="section-heading">{heading}</h2>
<div class="testimonials-intro">
<p>{blurb}</p>
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
<Icon name="fab fa-instagram" />
<span>{instagramLabel}</span>
</a>
</div>
{#if slides.length}
@@ -190,14 +185,40 @@
<div class="testimonial-divider"></div>
<div class="testimonial-mobile-controls" aria-label="Testimonial navigation">
<button
class="testimonial-arrow testimonial-arrow-inline"
type="button"
aria-label="Previous testimonial"
on:click={showPrevious}
>
<Icon name="fas fa-chevron-left" />
</button>
<button
class="testimonial-arrow testimonial-arrow-inline"
type="button"
aria-label="Next testimonial"
on:click={showNext}
>
<Icon name="fas fa-chevron-right" />
</button>
</div>
<a
class="testimonial-google"
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
>
<Icon name="fab fa-google" />
<span>All 5 star reviews on Google!</span>
<img
class="testimonial-google-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span>30+ five-star Google reviews</span>
</a>
</div>
</article>
@@ -214,6 +235,11 @@
</button>
</div>
{/if}
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
<Icon name="fab fa-instagram" />
<span>{instagramLabel}</span>
</a>
</div>
</section>
@@ -232,10 +258,11 @@
}
.testimonials-instagram-link {
display: inline-flex;
display: flex;
width: fit-content;
align-items: center;
gap: 10px;
margin-top: 18px;
margin: 18px auto 0;
padding: 10px 16px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
@@ -283,7 +310,7 @@
}
.testimonials-instagram-link {
margin-top: 14px;
margin: 14px auto 0;
padding: 9px 14px;
font-size: 15px;
}
@@ -443,14 +470,20 @@
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
}
.testimonial-google :global(.icon) {
font-size: 20px;
.testimonial-google-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.testimonial-google:hover {
background: #efe6d5;
}
.testimonial-mobile-controls {
display: none;
}
.testimonial-woof {
position: absolute;
top: 40px;
@@ -559,7 +592,7 @@
.testimonial-stage {
min-height: unset;
padding-bottom: 116px;
padding-bottom: 0;
}
.testimonial-slide {
@@ -605,8 +638,24 @@
margin-top: 28px;
}
.testimonial-mobile-controls {
display: inline-flex;
align-items: center;
gap: 12px;
margin-top: 20px;
}
.testimonial-arrow-inline {
position: static;
width: 48px;
height: 48px;
font-size: 18px;
transform: none;
box-shadow: 0 10px 22px rgba(20, 24, 20, 0.08);
}
.testimonial-google {
margin-top: 28px;
margin-top: 20px;
font-size: 16px;
gap: 10px;
padding: 10px 14px;
@@ -649,21 +698,9 @@
height: 8px;
}
.testimonial-arrow {
top: auto;
bottom: 24px;
width: 54px;
height: 54px;
font-size: 20px;
transform: none;
}
.testimonial-arrow-left {
left: 20px;
}
.testimonial-arrow-left,
.testimonial-arrow-right {
right: 20px;
display: none;
}
}
</style>
+10 -10
View File
@@ -6,17 +6,17 @@ export const aboutPageContent: AboutPageContent = {
{
title: 'Who we are',
body: [
"At GoodWalk, we're not your average dog walking service. We're a team of passionate dog lovers dedicated to providing top-notch care for your furry friends. Specialising in small dogs, we understand their unique needs firsthand, being small dog owners ourselves!",
"Our commitment to excellence has quickly made us a leader in Auckland Central's dog-walking scene. From pack walks to one-on-one sessions, we ensure the happiness and well-being of every dog in our care."
'GoodWalk is 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.'
],
imageUrl: '/images/auckland-pack-walk-dog.jpg',
imageAlt: 'Dog on a Goodwalk pack walk'
imageUrl: '/images/auckland-dog-group-outing.jpg',
imageAlt: 'Group of GoodWalk dogs on an outing together'
},
{
title: 'Our impact',
title: 'Our philosophy',
body: [
"At GoodWalk, we believe in positive reinforcement training to help your dog thrive in the world. Safety, professionalism, well-being, fun, structure, and compassion are the cornerstones of our business ethos.",
"When you choose GoodWalk, you're choosing a partner who will treat your dog like family, because that's exactly what they are to us."
'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.'
],
imageUrl: '/images/auckland-dog-group-outing.jpg',
imageAlt: 'Goodwalk dogs enjoying an outing together',
@@ -24,10 +24,10 @@ export const aboutPageContent: AboutPageContent = {
accent: 'gradient'
},
{
title: 'Meet the team',
title: 'Meet Aless and Maya',
body: [
'Behind GoodWalk is Alessandra, an Italian who has a deep passion for dogs. With her love for animals and years of experience, Alessandra leads our team with dedication and expertise, ensuring that every dog receives the love and attention they deserve.',
"And let's not forget about Maya, our marketing manager! A Cavalier King Charles cross Shih Tzu, Maya is full of sass and personality, bringing a touch of charm and flair to everything we do."
'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."
],
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
imageAlt: 'Goodwalk staff member Aless'
+59 -8
View File
@@ -21,7 +21,7 @@ export const homepageContent: HomePageContent = {
{ label: 'About Us', href: '/about' },
{ label: 'Contact Us', href: '/contact-us' }
],
cta: { label: 'Book a Meet & Greet', href: '/contact-us', variant: 'yellow' },
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' },
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
megaMenuServices: [
{ icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' },
@@ -34,16 +34,16 @@ export const homepageContent: HomePageContent = {
title: 'Unleashing Fun in',
highlight: "Your Dog's Day!",
mobileTitle: "Unleashing Fun in\nYour Dog's Day!",
subtitle: 'Trusted, professional dog walking across Auckland Central — pack walks, 1:1 walks, and puppy visits.',
primaryCta: { label: 'Learn more', href: '#services', variant: 'yellow' },
secondaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'outline' },
subtitle: "Trusted Auckland Central dog walking — small packs, solo adventures, and puppy visits from a team that knows your dog 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',
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
},
intro: {
text: 'Trusted by Auckland dog parents.',
reviewCta: {
label: 'All 5 star reviews on Google!',
label: '30+ five-star Google reviews',
href: 'https://g.page/r/CUsvrWPhkYrAEB0/',
external: true
}
@@ -56,7 +56,7 @@ export const homepageContent: HomePageContent = {
'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'
],
emphasis: 'TINY GANG?',
cta: { label: 'See our services', href: '#services', variant: 'green' },
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'
},
@@ -83,6 +83,27 @@ export const homepageContent: HomePageContent = {
href: '/puppy-visits'
}
],
howItWorks: {
title: 'How it works',
//intro: 'A simple onboarding flow designed to make sure the fit is right for both you and your dog.',
steps: [
{
title: 'Meet & Greet',
body: 'We meet you and your dog first, talk through routine, temperament, and what support you need.',
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.',
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.',
icon: 'fas fa-heart'
}
]
},
values: [
{
icon: 'fas fa-heart',
@@ -125,6 +146,36 @@ export const homepageContent: HomePageContent = {
}
],
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. Shes 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 Buddys 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.',
@@ -157,9 +208,9 @@ export const homepageContent: HomePageContent = {
booking: {
title: "Let's meet!",
subtitle:
"Almost there — just your contact details so we can reach out to arrange your free, no-obligation Meet & Greet.",
"Almost there — just your contact details. We'll reply within 24 hours to arrange your free, no-obligation Meet & Greet.",
generalSubtitle:
"Almost there — just your contact details so we can reply properly to your message.",
"Almost there — just your contact details. We'll reply within 24 hours.",
formAction: '/contact-us',
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
ownerStepLabel: 'Your details',
+3 -2
View File
@@ -21,7 +21,7 @@ export const packWalksContent: ServicePageContent = {
pricing: {
title: 'Tiny Gang Prices',
intro:
'Our pack walks are a permanent booking of at least one walk day a week. Our Tiny Gang pack outing typically lasts 2 hours or more, including a one-hour walk at one of Aucklands scenic dog parks or beaches. Additionally, pick-up and drop-off services are provided for your convenience. We assist in reinforcing basic training, including recall, car manners, and leash etiquette. Gift your dog the best life!',
'Small packs of 4-8 dogs, 2-hour outings at Aucklands 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: [
{
title: '1 Walk',
@@ -53,7 +53,8 @@ export const packWalksContent: ServicePageContent = {
{ label: 'Extra Dog', note: 'From same household', price: '$35' },
{ label: 'Muddy Wash', price: '$35' },
{ label: '5 Hour Day Out', note: 'Not suitable for all dogs', price: '$90' }
]
],
scarcityNote: 'We keep packs small (4-8 dogs) — popular days fill up fast.'
},
benefits: {
title: 'Tiny Gang membership benefits',
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('feature flags', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('defaults the mobile CTA button to disabled', async () => {
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
expect(isMobileCtaButtonEnabled()).toBe(false);
});
it('enables the mobile CTA button when the public env flag is truthy', async () => {
vi.stubEnv('PUBLIC_ENABLE_MOBILE_CTA_BUTTON', 'enabled');
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
expect(isMobileCtaButtonEnabled()).toBe(true);
});
it('treats explicit false values as disabled', async () => {
vi.stubEnv('PUBLIC_ENABLE_MOBILE_CTA_BUTTON', 'off');
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
expect(isMobileCtaButtonEnabled()).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
export function parseBooleanFlag(value: string | undefined, defaultValue = false) {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) {
return false;
}
return defaultValue;
}
export function isMobileCtaButtonEnabled() {
return parseBooleanFlag(import.meta.env.PUBLIC_ENABLE_MOBILE_CTA_BUTTON, false);
}
+2
View File
@@ -76,6 +76,7 @@ describe('content server helpers', () => {
services: homepageContent.services,
testimonials: homepageContent.testimonials,
booking: homepageContent.booking,
info: homepageContent.info,
footer: homepageContent.footer
});
});
@@ -89,6 +90,7 @@ describe('content server helpers', () => {
expect(result.services).not.toBe(homepageContent.services);
expect(result.testimonials).not.toBe(homepageContent.testimonials);
expect(result.booking).not.toBe(homepageContent.booking);
expect(result.info).not.toBe(homepageContent.info);
expect(result.footer).not.toBe(homepageContent.footer);
});
});
+1
View File
@@ -55,6 +55,7 @@ export async function getSharedPageContent(): Promise<SiteSharedContent> {
services: content.services,
testimonials: content.testimonials,
booking: content.booking,
info: content.info,
footer: content.footer
};
}
+1 -17
View File
@@ -1,20 +1,4 @@
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;
}
import { parseBooleanFlag } from '$lib/feature-flags';
export function isGeneralEnquiryEnabled() {
return parseBooleanFlag(process.env.ENABLE_GENERAL_ENQUIRIES, false);
+3 -3
View File
@@ -2,9 +2,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 14px 32px;
font-size: 15px;
gap: 10px;
padding: 13px 28px;
font-size: 14px;
font-weight: 600;
border-radius: 40px;
cursor: pointer;
+9
View File
@@ -367,12 +367,21 @@
.booking-actions-next {
justify-content: center;
flex-direction: column;
}
.booking-actions-final {
justify-content: space-between;
}
.booking-next-note {
margin: 10px 0 0;
text-align: center;
font-size: 13px;
line-height: 1.45;
color: #666;
}
.booking-next-button,
.booking-submit-button {
display: inline-flex;
+4 -4
View File
@@ -58,8 +58,8 @@ nav {
}
.nav-links > li > a.nav-link-active {
background: #eadbbf;
color: #000;
background: #fff;
color: var(--green);
}
.mega-chevron {
@@ -385,8 +385,8 @@ nav {
.footer-inner {
display: grid;
grid-template-columns: 2fr 1.4fr 2fr;
gap: 60px;
grid-template-columns: 1.25fr 0.95fr 1.15fr;
gap: 24px;
margin-bottom: 48px;
align-items: start;
}
+98 -22
View File
@@ -35,6 +35,18 @@
}
@media (max-width: 768px) {
/*
* Mobile body type bumps to 16px modern legibility standard, and
* matches the iOS-Safari zoom-on-focus threshold. Reserve room at
* the bottom of the page for the sticky MobileBookBar so the footer
* never sits behind it; the bar adds its own safe-area-inset
* padding on top of this.
*/
body {
font-size: 16px;
padding-bottom: 64px;
}
@keyframes mobileMenuBounceIn {
0% {
opacity: 0;
@@ -89,14 +101,16 @@
display: inline-flex;
justify-self: center;
align-self: center;
padding: 9px 12px;
background: rgba(33, 48, 33, 0.06);
min-height: 44px;
padding: 11px 14px;
background: rgba(33, 48, 33, 0.1);
color: var(--green);
font-size: 13px;
font-weight: 600;
}
.mobile-phone .icon {
font-size: 13px;
font-size: 14px;
}
.nav-links {
@@ -181,7 +195,7 @@
.hero-inner {
flex-direction: column;
gap: 24px;
gap: 18px;
align-items: stretch;
text-align: left;
padding: 0;
@@ -208,6 +222,12 @@
line-height: 1.5;
}
.hero-trust-chip {
margin-bottom: 18px;
padding: 10px 14px;
font-size: 14px;
}
.hero-heading-desktop {
display: none;
}
@@ -225,37 +245,39 @@
.hero-buttons {
width: 100%;
justify-content: flex-start;
gap: 10px;
padding-right: 18px;
justify-content: space-between;
gap: 8px;
padding-right: 0;
}
.hero-buttons .btn {
flex: 0 0 auto;
width: auto;
flex: 1 1 0;
width: 0;
min-width: 0;
padding: 17px 28px;
font-size: 15px;
padding: 14px 10px;
font-size: 12.5px;
font-weight: 700;
text-align: center;
border-radius: 999px;
line-height: 1.15;
letter-spacing: -0.01em;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.hero-buttons .btn:last-child {
margin-right: 12px;
margin-right: 0;
}
.hero-buttons .btn-yellow {
background: #e8dbc1;
background: var(--yellow);
color: #000;
}
.hero-buttons .btn-outline {
background: var(--yellow);
color: #000;
border: none;
background: transparent;
color: #fff;
border: 2px solid rgba(255, 255, 255, 0.84);
}
.hero-buttons .btn:active {
@@ -263,11 +285,11 @@
}
.hero-buttons .btn-yellow:active {
background: #dccdb1;
background: #e6bb00;
}
.hero-buttons .btn-outline:active {
background: #e6bb00;
background: rgba(255, 255, 255, 0.08);
}
.hero-img {
@@ -281,7 +303,7 @@
}
.hero-img img {
width: min(100%, 500px);
width: min(100%, 460px);
max-width: 100%;
margin: 0 auto -7px;
object-fit: contain;
@@ -453,7 +475,13 @@
.booking-field-card input,
.booking-field-card textarea {
padding: 14px 18px;
font-size: 15px;
/*
* 16px is the iOS-Safari threshold for triggering auto-zoom on
* focus. Anything smaller and the page jolts every time a field
* is tapped kills the form's perceived quality at the most
* critical conversion step.
*/
font-size: 16px;
border-width: 2px;
border-radius: 18px;
}
@@ -525,13 +553,31 @@
}
.footer-inner {
gap: 36px;
grid-template-columns: 1fr;
gap: 16px;
}
.footer-action {
order: -1;
}
.footer-panel {
padding: 22px 18px;
border-radius: 24px;
}
.footer-nav {
columns: 1;
}
.footer-action-title {
font-size: 22px;
}
.footer-action-copy {
font-size: 14px;
}
.footer-book-note {
text-align: left;
}
@@ -540,6 +586,10 @@
padding: 11px 0;
}
.footer-contact {
gap: 6px;
}
.footer-bottom {
flex-direction: column;
align-items: flex-start;
@@ -554,11 +604,37 @@
@media (max-width: 480px) {
.mobile-phone {
gap: 6px;
padding: 9px 10px;
padding: 10px 12px;
font-size: 12px;
}
.mobile-phone span {
letter-spacing: -0.01em;
}
.hero-text h1,
.hero-heading {
font-size: 32px;
line-height: 1.12;
}
.hero-text h1 .hero-heading-mobile {
font-size: 30px;
line-height: 1.12;
}
.hero-buttons {
flex-direction: row;
gap: 8px;
padding-right: 0;
}
.hero-buttons .btn {
flex: 1 1 0;
width: 0;
margin-right: 0 !important;
padding: 13px 9px;
font-size: 12px;
line-height: 1.1;
}
}
+118 -23
View File
@@ -33,6 +33,49 @@ section {
line-height: 1.55;
}
.hero-trust-chip {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition:
background 0.2s ease,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
}
.hero-trust-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.hero-trust-stars {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--yellow);
font-size: 13px;
}
@media (hover: hover) {
.hero-trust-chip:hover {
background: rgba(255, 255, 255, 0.16);
transform: translateY(-1px);
}
}
.hero-trust-chip:active {
transform: translateY(1px) scale(0.99);
}
.hero-buttons {
display: flex;
gap: 16px;
@@ -213,12 +256,8 @@ section {
background: #fff;
}
.promise-text h2 {
text-align: center;
}
.promise-text p {
margin-bottom: 28px;
margin: 0 auto 28px;
font-size: 16px;
max-width: 520px;
}
@@ -231,6 +270,7 @@ section {
.promise-text {
order: 2;
text-align: center;
}
.promise-img {
@@ -486,34 +526,60 @@ footer {
.footer-logo {
display: block;
height: 22px;
height: 24px;
width: auto;
margin-bottom: 20px;
margin-bottom: 18px;
}
.footer-panel {
min-height: 100%;
padding: 28px 26px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.06);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 18px 34px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(6px);
}
.footer-panel-accent {
background:
radial-gradient(circle at top right, rgba(255, 209, 71, 0.22), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.11) 0%, rgba(255, 255, 255, 0.06) 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
0 20px 40px rgba(0, 0, 0, 0.12);
}
.footer-brand p {
font-size: 14px;
line-height: 1.6;
opacity: 0.65;
line-height: 1.7;
opacity: 0.76;
margin-bottom: 24px;
white-space: pre-line;
max-width: 34ch;
}
.social-links a {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
width: 44px;
height: 44px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: background 0.2s, color 0.2s;
transition:
background 0.2s,
color 0.2s,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
}
.social-links a:hover {
background: var(--yellow);
color: #000;
transform: translateY(-1px);
}
.footer-col-label {
@@ -528,15 +594,18 @@ footer {
.footer-nav {
list-style: none;
columns: 2;
column-gap: 24px;
}
.footer-nav li {
margin: 0;
break-inside: avoid;
}
.footer-nav a {
display: block;
padding: 8px 0;
padding: 9px 0;
font-size: 15px;
font-weight: 500;
opacity: 0.75;
@@ -547,13 +616,29 @@ footer {
opacity: 1;
}
.footer-action-title {
margin: 0 0 10px;
color: #fff;
font-size: 28px;
line-height: 1;
letter-spacing: -0.04em;
}
.footer-action-copy {
margin: 0 0 22px;
max-width: 34ch;
color: rgba(255, 255, 255, 0.78);
font-size: 14px;
line-height: 1.65;
}
.footer-book-btn {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 15px 22px;
padding: 16px 22px;
border-radius: 999px;
background: var(--yellow);
color: #000;
@@ -571,14 +656,16 @@ footer {
.footer-book-note {
margin: 0 0 20px;
font-size: 13px;
opacity: 0.5;
text-align: center;
opacity: 0.68;
text-align: left;
}
.footer-reviews {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 9px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
@@ -588,14 +675,19 @@ footer {
transition: background 0.2s, opacity 0.2s;
}
.footer-google-logo {
width: 16px;
height: 17px;
flex: 0 0 auto;
}
.footer-reviews:hover {
background: rgba(255, 255, 255, 0.13);
opacity: 1;
}
.footer-contact {
display: flex;
flex-direction: column;
display: grid;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
@@ -606,6 +698,8 @@ footer {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 2px;
font-size: 13px;
font-weight: 500;
opacity: 0.7;
@@ -628,14 +722,15 @@ footer {
flex-wrap: wrap;
gap: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 24px;
padding-top: 28px;
font-size: 13px;
opacity: 0.5;
opacity: 0.6;
}
.footer-legal {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.footer-legal a {
+15
View File
@@ -74,6 +74,18 @@ export interface TestimonialContent {
imageUrl?: string;
}
export interface ProcessStep {
title: string;
body: string;
icon?: string;
}
export interface HowItWorksContent {
title: string;
intro?: string;
steps: ProcessStep[];
}
export interface BookingContent {
title: string;
subtitle: string;
@@ -124,6 +136,7 @@ export interface ServicePageContent {
intro?: string;
plans: ServicePricingPlan[];
extras?: ServiceExtra[];
scarcityNote?: string;
};
benefits: {
title: string;
@@ -218,6 +231,7 @@ export interface HomePageContent {
intro: IntroContent;
promise: PromiseContent;
services: IconCard[];
howItWorks: HowItWorksContent;
values: IconCard[];
testimonials: TestimonialContent[];
booking: BookingContent;
@@ -231,5 +245,6 @@ export interface SiteSharedContent {
services: IconCard[];
testimonials: TestimonialContent[];
booking: BookingContent;
info: InfoContent;
footer: FooterContent;
}
+3
View File
@@ -3,6 +3,7 @@
import { navigating, page } from '$app/stores';
import { afterNavigate, disableScrollHandling } from '$app/navigation';
import { initClickTracking, trackPageView } from '$lib/analytics';
import MobileBookBar from '$lib/components/MobileBookBar.svelte';
import RouteSkeleton from '$lib/components/RouteSkeleton.svelte';
import '$lib/styles/variables.css';
import '$lib/styles/base.css';
@@ -72,6 +73,8 @@
{/if}
</div>
<MobileBookBar />
<style>
.layout-shell {
position: relative;
+4 -4
View File
@@ -3,9 +3,9 @@
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import HeroSection from '$lib/components/HeroSection.svelte';
import HowItWorksSection from '$lib/components/HowItWorksSection.svelte';
import InfoSection from '$lib/components/InfoSection.svelte';
import InstagramSection from '$lib/components/InstagramSection.svelte';
import IntroStrip from '$lib/components/IntroStrip.svelte';
import BookingSection from '$lib/components/BookingSection.svelte';
import PromiseSection from '$lib/components/PromiseSection.svelte';
import ServicesSection from '$lib/components/ServicesSection.svelte';
@@ -137,12 +137,12 @@
/>
<Header navigation={data.content.navigation} />
<HeroSection hero={data.content.hero} />
<IntroStrip intro={data.content.intro} />
<HeroSection hero={data.content.hero} reviewCta={data.content.intro.reviewCta} />
<PromiseSection promise={data.content.promise} />
<ServicesSection services={data.content.services} />
<ValuesSection values={data.content.values} />
<HowItWorksSection content={data.content.howItWorks} />
<TestimonialsSection testimonials={data.content.testimonials} />
<ValuesSection values={data.content.values} />
<BookingSection booking={data.content.booking} />
<InfoSection info={data.content.info} />
<InstagramSection instagram={data.content.instagram} />
+5 -1
View File
@@ -218,7 +218,11 @@
{:else if data.slug === 'privacy-policy'}
<LegalPage pageContent={privacyPolicyContent} />
{:else if data.slug === 'contact-us'}
<BookingPage booking={data.content.booking} allowGeneralEnquiry={data.generalEnquiryEnabled} />
<BookingPage
booking={data.content.booking}
info={data.content.info}
allowGeneralEnquiry={data.generalEnquiryEnabled}
/>
{:else}
<main class="static-page">
<section class="static-page-hero">
+9
View File
@@ -60,4 +60,13 @@ describe('static slug route page', () => {
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
});
it('renders the shared FAQ section on the contact page', () => {
render(SlugPage, {
data: createStaticRouteData('contact-us')
});
expect(screen.getByText('FAQs')).toBeInTheDocument();
expect(screen.getByText('Can any dog use your service?')).toBeInTheDocument();
});
});
+1
View File
@@ -6,6 +6,7 @@ export const sharedPageContent = {
services: homepageContent.services,
testimonials: homepageContent.testimonials,
booking: homepageContent.booking,
info: homepageContent.info,
footer: homepageContent.footer
};