Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d70993817 | |||
| 32ccd49d78 | |||
| 7edd4c7f9d | |||
| ad9df7578a | |||
| 6d021e05ea | |||
| 2f4001b8af | |||
| a7ce4c74b5 | |||
| b8b9d12a82 | |||
| 55217f59bd | |||
| 251ec5737f | |||
| 71cdc809c6 |
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
+73
-1
@@ -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
@@ -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
@@ -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 we’ll 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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
ARG APP_VERSION=4.0.2
|
||||
ARG APP_VERSION=4.2.3
|
||||
|
||||
FROM python:3.12-slim
|
||||
ARG APP_VERSION
|
||||
|
||||
+209
-11
@@ -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 & 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;">
|
||||
<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"}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodwalk-svelte-port",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -1,35 +1,153 @@
|
||||
<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">
|
||||
<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-hero-media">
|
||||
<img
|
||||
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-media">
|
||||
<div class="about-editorial-media">
|
||||
<img
|
||||
src={section.imageUrl}
|
||||
alt={section.imageAlt}
|
||||
@@ -40,24 +158,97 @@
|
||||
/>
|
||||
</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">
|
||||
<div class="about-contact-band">
|
||||
<div class="about-contact-copy">
|
||||
<span class="about-kicker">Get in touch</span>
|
||||
<h2>{pageContent.contact.title}</h2>
|
||||
<div class="about-contact-grid">
|
||||
<p>Tell us a little about your dog and we’ll 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -204,14 +290,21 @@
|
||||
services: isGeneralEnquiry ? [] : selectedServices,
|
||||
website,
|
||||
formStartedAt,
|
||||
visitStartedAt,
|
||||
pageEnteredAt,
|
||||
firstInteractionAt,
|
||||
sendClickedAt,
|
||||
stepChanges,
|
||||
journey,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
page: window.location.href
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const detail = typeof body?.detail === 'string'
|
||||
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" /> 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" /> {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="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} />
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
@@ -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')!);
|
||||
|
||||
|
||||
@@ -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 & Greet. Start here and we’ll reply within 24 hours.</p>
|
||||
<a href="/contact-us" class="footer-book-btn">
|
||||
Book a Meet & 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★ 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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -3,21 +3,39 @@
|
||||
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>
|
||||
<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">
|
||||
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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 & 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 & Greet and we’ll help you choose the right walk or visit for your dog.
|
||||
</p>
|
||||
<a class="btn btn-outline btn-outline-green pricing-mobile-consult-cta" href="#newlead">
|
||||
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 & Greet.
|
||||
</p>
|
||||
|
||||
<a class="btn btn-yellow service-plan-mobile-cta" href="#newlead">Book a Meet & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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. She’s taken the time to bond and he LOVES her. I can go to work with absolutely no worries and my favourite part of my day are the updates and seeing how happy he is. Highly recommend this service!',
|
||||
reviewer: 'Brigid',
|
||||
detail: "Dog mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'My dog has an amazing time each week on his adventure walk with Aless and comes home pooped! Aless is a lovely and super friendly person and she runs a fantastic dog walking business which I highly recommend.',
|
||||
reviewer: 'Kate',
|
||||
detail: "Dog mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Alessandra is amazing. Not only she looked after my dog but since I left him with her, he has improved on his behaviour. If I could rate 6 stars I would. I couldn\'t imagine a better person to leave my lovely Rusty with. Thank you Alessandra.',
|
||||
reviewer: 'Sara',
|
||||
detail: "Rusty's mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Aless is top notch, you and your dog would be lucky to have her. Aless took the time to get to know Buddy’s personality to make sure he was a good fit for the pack and went above to work with him on recall and obedience training. Reliable, trustworthy and all around awesome.',
|
||||
reviewer: 'Lori',
|
||||
detail: "Buddy's mum"
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Love Aless! She is so amazing with my slightly hyper and anxious dog. She is great with communication if anything on either of our ends need to change. Archie love his walks, and I love the photos she posts of him.',
|
||||
@@ -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',
|
||||
|
||||
@@ -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 Auckland’s 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 Auckland’s scenic dog parks and beaches, with free pick-up and drop-off included. We reinforce recall, car manners, and leash etiquette while your dog plays. Booked as a permanent weekly slot — gift your dog the best life!',
|
||||
plans: [
|
||||
{
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('feature flags', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('defaults the mobile CTA button to disabled', async () => {
|
||||
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
|
||||
|
||||
expect(isMobileCtaButtonEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the mobile CTA button when the public env flag is truthy', async () => {
|
||||
vi.stubEnv('PUBLIC_ENABLE_MOBILE_CTA_BUTTON', 'enabled');
|
||||
|
||||
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
|
||||
|
||||
expect(isMobileCtaButtonEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats explicit false values as disabled', async () => {
|
||||
vi.stubEnv('PUBLIC_ENABLE_MOBILE_CTA_BUTTON', 'off');
|
||||
|
||||
const { isMobileCtaButtonEnabled } = await import('./feature-flags');
|
||||
|
||||
expect(isMobileCtaButtonEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export function parseBooleanFlag(value: string | undefined, defaultValue = false) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function isMobileCtaButtonEnabled() {
|
||||
return parseBooleanFlag(import.meta.env.PUBLIC_ENABLE_MOBILE_CTA_BUTTON, false);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export const sharedPageContent = {
|
||||
services: homepageContent.services,
|
||||
testimonials: homepageContent.testimonials,
|
||||
booking: homepageContent.booking,
|
||||
info: homepageContent.info,
|
||||
footer: homepageContent.footer
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user