Compare commits
15 Commits
seo-fixes
...
7edd4c7f9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7edd4c7f9d | |||
| ad9df7578a | |||
| 6d021e05ea | |||
| 2f4001b8af | |||
| a7ce4c74b5 | |||
| b8b9d12a82 | |||
| 55217f59bd | |||
| 251ec5737f | |||
| 71cdc809c6 | |||
| a665368d02 | |||
| 0eed557f95 | |||
| e2c5f38d55 | |||
| 8df0e2dfe9 | |||
| c2e6282efa | |||
| 9d87d08547 |
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
+67
-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,7 +73,7 @@ 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
|
||||
TZ=Pacific/Auckland
|
||||
|
||||
@@ -129,6 +132,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.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
APP_VERSION=4.0.2
|
||||
APP_VERSION=4.2.3
|
||||
TZ=Pacific/Auckland
|
||||
|
||||
POSTGRES_DB=goodwalk
|
||||
|
||||
+15
-1
@@ -24,6 +24,12 @@ $NginxConfigSource = 'nginx/goodwalk.co.nz.svelte.conf.example'
|
||||
$NginxConfigTarget = '/docker/nginx/conf.d/goodwalk.co.nz.conf'
|
||||
$NginxComposeFile = '/docker/nginx/docker-compose.yml'
|
||||
$NginxProjectName = 'nginx'
|
||||
# Host paths used for the maintenance page. The directory must be bind-mounted
|
||||
# into the shared nginx container at /var/www/maintenance:ro (see DEPLOYMENT.md).
|
||||
# The flag file lives in the existing conf.d bind mount; nginx ignores non-.conf
|
||||
# files, so it does not pollute the include glob.
|
||||
$MaintenanceHostDir = '/docker/nginx/maintenance'
|
||||
$MaintenanceFlagPath = '/docker/nginx/conf.d/maintenance.flag'
|
||||
|
||||
# Optional deployment settings.
|
||||
$VerifyUrl = 'https://www.goodwalk.co.nz/api/health'
|
||||
@@ -169,6 +175,8 @@ Assert-NotBlank -Name 'NginxConfigSource' -Value $NginxConfigSource
|
||||
Assert-NotBlank -Name 'NginxConfigTarget' -Value $NginxConfigTarget
|
||||
Assert-NotBlank -Name 'NginxComposeFile' -Value $NginxComposeFile
|
||||
Assert-NotBlank -Name 'NginxProjectName' -Value $NginxProjectName
|
||||
Assert-NotBlank -Name 'MaintenanceHostDir' -Value $MaintenanceHostDir
|
||||
Assert-NotBlank -Name 'MaintenanceFlagPath' -Value $MaintenanceFlagPath
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Service)) {
|
||||
$Service = $Service.Trim()
|
||||
@@ -206,6 +214,8 @@ Write-Host "[deploy] Remote compose file: $ComposeFileName"
|
||||
Write-Host "[deploy] Docker project name: $DockerProjectName"
|
||||
Write-Host "[deploy] Shared nginx config: $NginxConfigTarget"
|
||||
Write-Host "[deploy] Shared nginx compose file: $NginxComposeFile"
|
||||
Write-Host "[deploy] Maintenance host dir: $MaintenanceHostDir (must be bind-mounted at /var/www/maintenance:ro)"
|
||||
Write-Host "[deploy] Maintenance flag path: $MaintenanceFlagPath"
|
||||
Write-Host "[deploy] SSH target: $sshTarget"
|
||||
Write-Host "[deploy] SSH config: $SshConfigPath"
|
||||
if (-not [string]::IsNullOrWhiteSpace($Service)) {
|
||||
@@ -272,7 +282,11 @@ try {
|
||||
'--nginx-compose-file',
|
||||
$NginxComposeFile,
|
||||
'--nginx-project-name',
|
||||
$NginxProjectName
|
||||
$NginxProjectName,
|
||||
'--maintenance-host-dir',
|
||||
$MaintenanceHostDir,
|
||||
'--maintenance-flag',
|
||||
$MaintenanceFlagPath
|
||||
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }))
|
||||
|
||||
Write-Host ''
|
||||
|
||||
@@ -3,10 +3,10 @@ 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
|
||||
@@ -25,10 +25,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:-}
|
||||
|
||||
+4
-4
@@ -3,9 +3,9 @@ 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}
|
||||
@@ -19,9 +19,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
|
||||
|
||||
+210
-12
@@ -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;">
|
||||
{data.email}
|
||||
<div style="margin-bottom:12px;">
|
||||
<a href="mailto:{data.email}" class="gw-owner-email-chip"
|
||||
style="display:inline-block;background:#ffffff;color:#213021 !important;
|
||||
font-family:Menlo,Consolas,'SFMono-Regular',monospace;
|
||||
font-size:20px;font-weight:700;line-height:1.4;word-break:break-all;
|
||||
border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;
|
||||
text-decoration:none;">
|
||||
{data.email}
|
||||
</a>
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#b7cbb7;line-height:1.6;">
|
||||
@@ -779,8 +906,14 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_meta_row("IP address", ip)}
|
||||
{_meta_row("Browser", browser)}
|
||||
{visit_time_row}
|
||||
{page_time_row}
|
||||
{active_time_row}
|
||||
{form_time_row}
|
||||
{step_changes_row}
|
||||
{referrer_row}
|
||||
{page_row}
|
||||
{journey_row}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
@@ -853,8 +986,73 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict:
|
||||
raise last_exc
|
||||
|
||||
|
||||
async def _send_startup_test_email() -> None:
|
||||
if not STARTUP_TEST_RECIPIENT:
|
||||
logger.info("Startup test email skipped: OWNER_BCC is not set to a real address")
|
||||
return
|
||||
|
||||
request_id = "startup-test"
|
||||
payload = {
|
||||
"from": FROM_EMAIL,
|
||||
"to": [STARTUP_TEST_RECIPIENT],
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": f"GoodWalk Mail API startup check ({APP_VERSION})",
|
||||
"html": f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light only">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>GoodWalk Mail API startup check</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px;background:#f2f2f0;color:#213021;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0" style="background:#f2f2f0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
|
||||
style="max-width:560px;width:100%;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||
{_logo_header(subtitle="Mail API startup check")}
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 40px;">
|
||||
<h1 style="margin:0 0 12px;font-size:24px;line-height:1.2;color:#213021;">Startup test email</h1>
|
||||
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#4a4f4a;">
|
||||
The GoodWalk mail service started successfully and sent this boot check to the Gmail monitoring address only.
|
||||
</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<div style="font-size:13px;line-height:1.7;color:#213021;">
|
||||
<strong>Version:</strong> {APP_VERSION}<br>
|
||||
<strong>Started:</strong> {datetime.now().strftime("%d %b %Y %I:%M %p").lstrip("0")}<br>
|
||||
<strong>Recipient:</strong> {STARTUP_TEST_RECIPIENT}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>""",
|
||||
}
|
||||
|
||||
await _send_email(payload, label="startup_test_email", request_id=request_id)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup_mail_check() -> None:
|
||||
try:
|
||||
await _send_startup_test_email()
|
||||
except Exception:
|
||||
logger.exception("Startup test email failed")
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -53,6 +53,26 @@ server {
|
||||
# nginx does not keep stale upstream IPs in memory.
|
||||
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||
|
||||
# Maintenance mode: when /etc/nginx/conf.d/maintenance.flag exists,
|
||||
# serve the static "be right back" page with a 503 status. The flag is
|
||||
# toggled by the deploy script (touch / rm) without reloading nginx.
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
location = /maintenance.html {
|
||||
root /var/www/maintenance;
|
||||
internal;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
# Static assets used only by the maintenance page (logo, etc.). Served
|
||||
# from a dedicated bind mount so they remain reachable while the
|
||||
# SvelteKit app is down and do not collide with any other site's html dir.
|
||||
location /m/ {
|
||||
root /var/www/maintenance;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
}
|
||||
|
||||
location ~* /\.(git|env|htaccess) {
|
||||
deny all;
|
||||
}
|
||||
@@ -66,6 +86,10 @@ server {
|
||||
}
|
||||
|
||||
location /api/submit {
|
||||
if (-f /etc/nginx/conf.d/maintenance.flag) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
|
||||
limit_req zone=goodwalk_limit burst=10 nodelay;
|
||||
proxy_pass http://$goodwalk_mail_api/submit;
|
||||
@@ -77,6 +101,10 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
if (-f /etc/nginx/conf.d/maintenance.flag) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
set $goodwalk_frontend goodwalk_svelte_app:3000;
|
||||
proxy_pass http://$goodwalk_frontend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,189 @@
|
||||
<!doctype html>
|
||||
<html lang="en-NZ">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Be right back | Goodwalk</title>
|
||||
<style>
|
||||
:root {
|
||||
--green: #213021;
|
||||
--green-soft: #2c3f2c;
|
||||
--yellow: #ffd100;
|
||||
--ink: #1a1a1a;
|
||||
--muted: #4c5056;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 209, 0, 0.12), transparent 55%),
|
||||
radial-gradient(circle at bottom right, rgba(255, 209, 0, 0.08), transparent 60%),
|
||||
var(--green);
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.brand-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand-bar img {
|
||||
display: block;
|
||||
height: 44px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 44px 40px 38px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 6px;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.paw {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 4px auto 18px;
|
||||
border-radius: 50%;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.paw svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--green);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(28px, 4vw, 38px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 24px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta a {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.meta a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin-top: 22px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 36px 24px 28px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.brand-bar img {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage">
|
||||
<div class="brand-bar">
|
||||
<img src="/m/logo.png" alt="Goodwalk" width="241" height="48" />
|
||||
</div>
|
||||
|
||||
<main class="card" role="main">
|
||||
<span class="paw" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 10.5c1.1 0 2-1.3 2-3s-.9-3-2-3-2 1.3-2 3 .9 3 2 3Zm9 0c1.1 0 2-1.3 2-3s-.9-3-2-3-2 1.3-2 3 .9 3 2 3ZM4 13.5c1 0 1.8-1.1 1.8-2.5S5 8.5 4 8.5s-1.8 1.1-1.8 2.5S3 13.5 4 13.5Zm16 0c1 0 1.8-1.1 1.8-2.5S21 8.5 20 8.5s-1.8 1.1-1.8 2.5.8 2.5 1.8 2.5ZM12 13.5c-3.3 0-6 2.5-6 5.4 0 1.4 1.1 2.6 2.5 2.6 1 0 1.5-.4 2.2-.8.4-.2.8-.4 1.3-.4s.9.2 1.3.4c.7.4 1.2.8 2.2.8 1.4 0 2.5-1.2 2.5-2.6 0-2.9-2.7-5.4-6-5.4Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<p class="eyebrow">Goodwalk</p>
|
||||
<h1>Be right back!</h1>
|
||||
<p>We're updating the site — should only take a minute. Thanks for your patience.</p>
|
||||
|
||||
<div class="meta">
|
||||
Need us now? Email <a href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>
|
||||
or call <a href="tel:+64226421011">(022) 642 1011</a>.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<p class="footnote">Auckland Central dog walking · Tiny Gang pack walks · 1:1 walks · Puppy visits</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-recheck once a minute so visitors don't sit on this page forever.
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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.1.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"
|
||||
+141
-15
@@ -10,6 +10,8 @@ NGINX_SOURCE=""
|
||||
NGINX_TARGET=""
|
||||
NGINX_COMPOSE_FILE=""
|
||||
NGINX_PROJECT_NAME=""
|
||||
MAINTENANCE_HOST_DIR=""
|
||||
MAINTENANCE_FLAG_PATH=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -18,7 +20,8 @@ Usage:
|
||||
deploy-remote.sh --archive <path> --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
|
||||
deploy-remote.sh --archive <path> --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>]
|
||||
[--nginx-compose-file <path>] [--nginx-project-name <name>] \
|
||||
[--maintenance-host-dir <path>] [--maintenance-flag <path>]
|
||||
|
||||
This script only updates the main Goodwalk compose project at the specified
|
||||
deployment path. It does not touch unrelated Docker projects or global Docker
|
||||
@@ -69,6 +72,14 @@ while [[ $# -gt 0 ]]; do
|
||||
NGINX_PROJECT_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--maintenance-host-dir)
|
||||
MAINTENANCE_HOST_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--maintenance-flag)
|
||||
MAINTENANCE_FLAG_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -103,6 +114,8 @@ if (( nginx_args_present )); then
|
||||
[[ -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
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
@@ -114,8 +127,18 @@ else
|
||||
fi
|
||||
|
||||
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-deploy.XXXXXX")"
|
||||
MAINTENANCE_ACTIVE=0
|
||||
|
||||
clear_maintenance_flag() {
|
||||
if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then
|
||||
echo "[deploy-remote] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH"
|
||||
rm -f "$MAINTENANCE_FLAG_PATH" || true
|
||||
MAINTENANCE_ACTIVE=0
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
clear_maintenance_flag
|
||||
rm -rf "$STAGING_DIR"
|
||||
}
|
||||
|
||||
@@ -133,6 +156,8 @@ if (( nginx_args_present )); then
|
||||
echo "[deploy-remote] Nginx config target: $NGINX_TARGET"
|
||||
echo "[deploy-remote] Nginx compose file: $NGINX_COMPOSE_FILE"
|
||||
echo "[deploy-remote] Nginx project: $NGINX_PROJECT_NAME"
|
||||
echo "[deploy-remote] Maintenance host dir: $MAINTENANCE_HOST_DIR"
|
||||
echo "[deploy-remote] Maintenance flag path: $MAINTENANCE_FLAG_PATH"
|
||||
fi
|
||||
echo "[deploy-remote] Staging archive in: $STAGING_DIR"
|
||||
|
||||
@@ -182,6 +207,70 @@ if [[ ! -f "$DEPLOY_PATH/.env" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
merge_env_file() {
|
||||
local template="$1"
|
||||
local live="$2"
|
||||
|
||||
[[ -f "$template" ]] || { echo "[deploy-remote] No env template at $template, skipping merge"; return 0; }
|
||||
[[ -f "$live" ]] || { echo "[deploy-remote] 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-remote] Adding env keys present in template but missing from $live:"
|
||||
sed 's/^/ + /' "$added"
|
||||
{
|
||||
printf '\n# Appended by deploy-remote.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
cat "$added"
|
||||
} >> "$live"
|
||||
echo "[deploy-remote] Backup of previous .env written to $backup"
|
||||
else
|
||||
echo "[deploy-remote] .env is up to date with template (no missing keys)"
|
||||
fi
|
||||
|
||||
if [[ -s "$diffs" ]]; then
|
||||
echo "[deploy-remote] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:"
|
||||
sed 's/^/ ! /' "$diffs"
|
||||
echo "[deploy-remote] If a live value is stale (e.g. an old OWNER_EMAIL), edit $live and re-deploy."
|
||||
fi
|
||||
|
||||
rm -f "$added" "$diffs"
|
||||
}
|
||||
|
||||
merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
echo "[deploy-remote] Validating compose configuration"
|
||||
@@ -194,6 +283,56 @@ if [[ -n "$SERVICE_NAME" ]]; then
|
||||
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"
|
||||
|
||||
# Pre-flight: the nginx container must have a bind mount whose source is
|
||||
# MAINTENANCE_HOST_DIR and whose destination is /var/www/maintenance. If the
|
||||
# one-time droplet setup has not been done, fail fast with a clear message
|
||||
# rather than silently serving stale content.
|
||||
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-remote] 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-remote] 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-remote] Validating nginx configuration"
|
||||
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
|
||||
|
||||
echo "[deploy-remote] 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-remote] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH"
|
||||
: > "$MAINTENANCE_FLAG_PATH"
|
||||
MAINTENANCE_ACTIVE=1
|
||||
fi
|
||||
|
||||
if [[ -n "$SERVICE_NAME" ]]; then
|
||||
echo "[deploy-remote] Stopping only the Goodwalk service: $SERVICE_NAME"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
|
||||
@@ -216,19 +355,6 @@ if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
|
||||
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"
|
||||
|
||||
echo "[deploy-remote] Updating shared nginx config to avoid stale container IPs"
|
||||
mkdir -p "$(dirname "$NGINX_TARGET")"
|
||||
cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET"
|
||||
|
||||
echo "[deploy-remote] Validating nginx configuration"
|
||||
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
|
||||
|
||||
echo "[deploy-remote] Reloading shared nginx"
|
||||
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
|
||||
fi
|
||||
clear_maintenance_flag
|
||||
|
||||
echo "[deploy-remote] Remote deployment finished"
|
||||
|
||||
+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>
|
||||
|
||||
@@ -25,6 +25,25 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||
node.style.setProperty('--reveal-distance', `${settings.distance}px`);
|
||||
node.classList.add('reveal-ready');
|
||||
|
||||
// If the element is already visible at all in the initial viewport,
|
||||
// reveal it immediately so the first section below the hero doesn't
|
||||
// appear blank on page load.
|
||||
const initialCheck = () => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (rect.top < viewportHeight && rect.bottom > 0) {
|
||||
node.classList.add('reveal-visible');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (initialCheck()) {
|
||||
return {
|
||||
destroy() {}
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
|
||||
+732
-180
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -58,7 +61,7 @@
|
||||
.booking-page-hero h1 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(32px, 4vw, 52px);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
export let booking: BookingContent;
|
||||
export let allowGeneralEnquiry = false;
|
||||
type EnquiryType = 'booking' | 'general';
|
||||
const visitStartedStorageKey = 'goodwalk_visit_started_at';
|
||||
const journeyStorageKey = 'goodwalk_journey';
|
||||
const maxJourneyEntries = 8;
|
||||
|
||||
let step = 1;
|
||||
$: headingParts = splitBookingTitle(booking.title);
|
||||
@@ -23,6 +26,12 @@
|
||||
let selectedServices: string[] = [];
|
||||
let website = '';
|
||||
let formStartedAt = 0;
|
||||
let visitStartedAt = 0;
|
||||
let pageEnteredAt = 0;
|
||||
let firstInteractionAt = 0;
|
||||
let sendClickedAt = 0;
|
||||
let stepChanges = 0;
|
||||
let journey: string[] = [];
|
||||
|
||||
let fullNameInput: HTMLInputElement;
|
||||
let emailInput: HTMLInputElement;
|
||||
@@ -74,17 +83,21 @@
|
||||
enquiryType = 'booking';
|
||||
}
|
||||
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
|
||||
$: ownerSubtitle = isGeneralEnquiry
|
||||
$: ownerIntro = isGeneralEnquiry
|
||||
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
|
||||
: booking.subtitle;
|
||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
$: firstStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
|
||||
$: firstStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
|
||||
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
|
||||
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
|
||||
$: successPetName = petName.trim() || 'your dog';
|
||||
|
||||
onMount(() => {
|
||||
formStartedAt = Date.now();
|
||||
const now = Date.now();
|
||||
formStartedAt = now;
|
||||
pageEnteredAt = now;
|
||||
visitStartedAt = readOrCreateVisitStartedAt(now);
|
||||
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
|
||||
});
|
||||
|
||||
function splitBookingTitle(title: string) {
|
||||
@@ -107,7 +120,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
function readOrCreateVisitStartedAt(fallback: number) {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(visitStartedStorageKey);
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(visitStartedStorageKey, String(fallback));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function updateJourneySnapshot(pathname: string, search: string) {
|
||||
const nextEntry = `${pathname}${search}`;
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(journeyStorageKey);
|
||||
const previous = raw ? (JSON.parse(raw) as string[]) : [];
|
||||
const cleaned = previous.filter((value) => typeof value === 'string' && value.trim());
|
||||
const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry];
|
||||
const nextJourney = deduped.slice(-maxJourneyEntries);
|
||||
|
||||
window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney));
|
||||
return nextJourney;
|
||||
} catch {
|
||||
return [nextEntry];
|
||||
}
|
||||
}
|
||||
|
||||
function noteInteraction() {
|
||||
if (!firstInteractionAt) {
|
||||
firstInteractionAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function setStep(nextStep: number, trackTransition = false) {
|
||||
if (step !== nextStep && trackTransition) {
|
||||
stepChanges += 1;
|
||||
}
|
||||
|
||||
step = nextStep;
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function toggleService(service: string, checked: boolean) {
|
||||
noteInteraction();
|
||||
|
||||
if (checked) {
|
||||
selectedServices = [...selectedServices, service];
|
||||
return;
|
||||
@@ -117,6 +181,7 @@
|
||||
}
|
||||
|
||||
function setEnquiryType(nextType: EnquiryType) {
|
||||
noteInteraction();
|
||||
enquiryType = nextType;
|
||||
if (nextType === 'general') {
|
||||
petName = '';
|
||||
@@ -126,7 +191,37 @@
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function validateFirstStep(): boolean {
|
||||
function validateOwnerStep(): boolean {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
if (!fullName.trim()) next.fullName = 'Please enter your full name';
|
||||
|
||||
const emailError = validateEmail(email);
|
||||
if (emailError) next.email = emailError;
|
||||
|
||||
if (!phone.trim()) next.phone = 'Please enter your contact number';
|
||||
|
||||
errors = next;
|
||||
|
||||
if (next.fullName) {
|
||||
fullNameInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.email) {
|
||||
emailInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.phone) {
|
||||
phoneInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateDetailsStep(): boolean {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
if (isGeneralEnquiry) {
|
||||
@@ -156,9 +251,9 @@
|
||||
}
|
||||
|
||||
function goToOwnerStep() {
|
||||
if (!validateFirstStep()) return;
|
||||
errors = {};
|
||||
step = 2;
|
||||
noteInteraction();
|
||||
if (!validateDetailsStep()) return;
|
||||
setStep(2, true);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
@@ -169,22 +264,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Record<string, string> = {};
|
||||
if (!fullName.trim()) next.fullName = 'Please enter your full name';
|
||||
|
||||
const emailError = validateEmail(email);
|
||||
if (emailError) next.email = emailError;
|
||||
|
||||
if (!phone.trim()) next.phone = 'Please enter your contact number';
|
||||
|
||||
if (Object.keys(next).length > 0) {
|
||||
errors = next;
|
||||
if (next.fullName) fullNameInput?.focus();
|
||||
else if (next.email) emailInput?.focus();
|
||||
else if (next.phone) phoneInput?.focus();
|
||||
if (!validateOwnerStep()) {
|
||||
return;
|
||||
}
|
||||
|
||||
errors = {};
|
||||
noteInteraction();
|
||||
sendClickedAt = Date.now();
|
||||
submitting = true;
|
||||
submitErrorDetail = '';
|
||||
showErrorModal = false;
|
||||
@@ -194,26 +280,33 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enquiryType,
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
petName: isGeneralEnquiry ? '' : petName,
|
||||
location: isGeneralEnquiry ? '' : location,
|
||||
message,
|
||||
services: isGeneralEnquiry ? [] : selectedServices,
|
||||
website,
|
||||
formStartedAt,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
})
|
||||
enquiryType,
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
petName: isGeneralEnquiry ? '' : petName,
|
||||
location: isGeneralEnquiry ? '' : location,
|
||||
message,
|
||||
services: isGeneralEnquiry ? [] : selectedServices,
|
||||
website,
|
||||
formStartedAt,
|
||||
visitStartedAt,
|
||||
pageEnteredAt,
|
||||
firstInteractionAt,
|
||||
sendClickedAt,
|
||||
stepChanges,
|
||||
journey,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const detail = typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
|
||||
const detail =
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
@@ -229,7 +322,6 @@
|
||||
|
||||
<section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
|
||||
<div class="form-inner">
|
||||
|
||||
{#if submitted}
|
||||
<SuccessModal
|
||||
firstName={fullName.split(' ')[0]}
|
||||
@@ -251,7 +343,8 @@
|
||||
|
||||
<div class="booking-header">
|
||||
<h2 class="booking-title">
|
||||
<span class="booking-title-plain">{headingParts.plain}</span>{' '}<span class="booking-title-highlight">{headingParts.highlight}</span>
|
||||
<span class="booking-title-plain">{headingParts.plain}</span>{' '}
|
||||
<span class="booking-title-highlight">{headingParts.highlight}</span>
|
||||
</h2>
|
||||
|
||||
<div class="booking-stepper" aria-label="Booking form steps">
|
||||
@@ -259,10 +352,10 @@
|
||||
type="button"
|
||||
class:active={step === 1}
|
||||
class="booking-step"
|
||||
on:click={() => (step = 1)}
|
||||
on:click={() => setStep(1, step !== 1)}
|
||||
>
|
||||
<span class="booking-step-number">1</span>
|
||||
<span class="booking-step-label">{firstStepLabel}</span>
|
||||
<span class="booking-step-label">{detailsStepLabel}</span>
|
||||
</button>
|
||||
<span class="booking-step-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
@@ -282,6 +375,9 @@
|
||||
id="bookingForm"
|
||||
novalidate
|
||||
on:submit={handleSubmit}
|
||||
on:focusin={noteInteraction}
|
||||
on:input={noteInteraction}
|
||||
on:change={noteInteraction}
|
||||
>
|
||||
<div class="booking-honeypot" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
@@ -296,12 +392,16 @@
|
||||
</div>
|
||||
|
||||
{#if step === 1}
|
||||
<input type="hidden" name="enquiryType" value={enquiryType} />
|
||||
<div class="booking-panel">
|
||||
{#if firstStepIntro}
|
||||
<div class="booking-panel-banner">{firstStepIntro}</div>
|
||||
{#if detailsStepIntro}
|
||||
<div class="booking-panel-banner">{detailsStepIntro}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={Boolean(firstStepIntro)} class="booking-card-grid booking-card-grid-dog">
|
||||
<div
|
||||
class:booking-card-grid-with-banner={Boolean(detailsStepIntro)}
|
||||
class="booking-card-grid booking-card-grid-dog"
|
||||
>
|
||||
{#if allowGeneralEnquiry}
|
||||
<div class="booking-field-card booking-field-card-full">
|
||||
<label>
|
||||
@@ -361,7 +461,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
|
||||
<div
|
||||
class="booking-field-card booking-field-card-wide"
|
||||
class:booking-field-card-invalid={errors.location}
|
||||
>
|
||||
<label for="location">
|
||||
<Icon name="fas fa-location-dot" /> Location <span class="booking-required">*</span>
|
||||
</label>
|
||||
@@ -372,7 +475,7 @@
|
||||
id="location"
|
||||
name="location"
|
||||
required
|
||||
placeholder="Neighborhood, street..."
|
||||
placeholder="Suburb, street..."
|
||||
class:input-invalid={errors.location}
|
||||
on:input={() => clearError('location')}
|
||||
/>
|
||||
@@ -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="petName" value={petName} />
|
||||
<input type="hidden" name="location" value={location} />
|
||||
{/if}
|
||||
<input type="hidden" name="fullName" value={fullName} />
|
||||
<input type="hidden" name="email" value={email} />
|
||||
<input type="hidden" name="phone" value={phone} />
|
||||
<input type="hidden" name="petName" value={petName} />
|
||||
<input type="hidden" name="location" value={location} />
|
||||
<input type="hidden" name="message" value={message} />
|
||||
{#if !isGeneralEnquiry}
|
||||
{#each selectedServices as service}
|
||||
<input type="hidden" name="services" value={service} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="booking-panel">
|
||||
{#if ownerSubtitle}
|
||||
<div class="booking-panel-banner">{ownerSubtitle}</div>
|
||||
{#if ownerIntro}
|
||||
<div class="booking-panel-banner">{ownerIntro}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={Boolean(ownerSubtitle)} class="booking-card-grid booking-card-grid-owner">
|
||||
<div
|
||||
class:booking-card-grid-with-banner={Boolean(ownerIntro)}
|
||||
class="booking-card-grid booking-card-grid-owner"
|
||||
>
|
||||
<div class="booking-field-card booking-field-card-group booking-field-card-full">
|
||||
<div class="booking-field-group booking-field-group-owner">
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
|
||||
@@ -542,7 +647,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-outline-green"
|
||||
on:click={() => { step = 1; errors = {}; }}
|
||||
on:click={() => setStep(1, true)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
@@ -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,20 +3,38 @@
|
||||
import type { InfoContent } from '$lib/types';
|
||||
|
||||
export let info: InfoContent;
|
||||
|
||||
$: suburbChips = info.suburbs
|
||||
.split(',')
|
||||
.map((suburb) => suburb.trim())
|
||||
.filter(Boolean);
|
||||
</script>
|
||||
|
||||
<section id="info">
|
||||
<div class="info-inner">
|
||||
<div class="info-block">
|
||||
<h2><Icon name="fas fa-location-dot" /> {info.title}</h2>
|
||||
<p>{info.intro}</p>
|
||||
<p class="info-copy">{info.suburbs}</p>
|
||||
<p class="info-copy">
|
||||
{info.nearbyText}
|
||||
<a href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
|
||||
</p>
|
||||
<h3>{info.hoursLabel}</h3>
|
||||
<p>{info.hours}</p>
|
||||
<p class="info-lead">{info.intro}</p>
|
||||
<p class="info-support">Regular walks across the inner-west and nearby suburbs.</p>
|
||||
|
||||
<div class="info-suburb-chips" aria-label="Suburbs we cover">
|
||||
{#each suburbChips as suburb}
|
||||
<span class="info-suburb-chip">{suburb}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="info-nearby-card">
|
||||
<div class="info-nearby-copy">
|
||||
<span class="info-nearby-kicker">Nearby but not listed?</span>
|
||||
<p>{info.nearbyText} There's a good chance we can still help.</p>
|
||||
</div>
|
||||
<a class="info-nearby-cta" href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
|
||||
</div>
|
||||
|
||||
<div class="info-hours-card">
|
||||
<h3>{info.hoursLabel}</h3>
|
||||
<p>{info.hours}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
@@ -32,3 +50,117 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.info-lead {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-support {
|
||||
margin: 0 0 22px;
|
||||
color: #5f6369;
|
||||
}
|
||||
|
||||
.info-suburb-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.info-suburb-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
|
||||
0 10px 24px rgba(17, 20, 24, 0.04);
|
||||
color: #213021;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.info-nearby-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #fffaf0 0%, #f8f1e3 100%);
|
||||
box-shadow: 0 14px 30px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
|
||||
.info-nearby-copy p {
|
||||
margin: 6px 0 0;
|
||||
color: #4c5056;
|
||||
}
|
||||
|
||||
.info-nearby-kicker {
|
||||
display: inline-block;
|
||||
color: var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-nearby-cta {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-hours-card {
|
||||
padding-top: 18px;
|
||||
border-top: 1px dashed rgba(33, 48, 33, 0.18);
|
||||
}
|
||||
|
||||
.info-hours-card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.info-hours-card p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-suburb-chips {
|
||||
gap: 8px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.info-suburb-chip {
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-nearby-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.info-nearby-cta {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="instagram-stage">
|
||||
<div class="instagram-panel">
|
||||
<div class="instagram-copy">
|
||||
<span class="instagram-kicker">Daily walks, happy dogs</span>
|
||||
<span class="eyebrow instagram-kicker">Daily walks, happy dogs</span>
|
||||
<h2>{instagram.title}</h2>
|
||||
<p class="instagram-blurb">See our dogs in action — walks, play, and happy pups</p>
|
||||
<a href={instagram.href} target="_blank" rel="noopener" class="btn btn-green instagram-button">
|
||||
@@ -43,7 +43,7 @@
|
||||
position: relative;
|
||||
min-height: 150px;
|
||||
padding: 24px 320px 24px 44px;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.52), transparent 42%),
|
||||
linear-gradient(135deg, rgba(255, 252, 242, 0.98), rgba(255, 243, 198, 0.96));
|
||||
@@ -63,16 +63,9 @@
|
||||
}
|
||||
|
||||
.instagram-kicker {
|
||||
display: inline-flex;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: var(--green);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
/* All visual styling comes from the shared .eyebrow utility. */
|
||||
display: inline-block;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.instagram-copy :global(h2) {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
.legal-card {
|
||||
padding: 40px 44px;
|
||||
border-radius: 32px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
.legal-card {
|
||||
padding: 28px 22px;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.legal-section + .legal-section {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<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';
|
||||
|
||||
/*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
$: 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 (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(() => {
|
||||
visible = false;
|
||||
triggerPassed = false;
|
||||
bookingInView = false;
|
||||
void setupObservers();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void setupObservers();
|
||||
|
||||
return () => {
|
||||
cleanupObservers();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !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;
|
||||
@@ -349,7 +446,7 @@
|
||||
gap: 18px;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 18px 18px 18px 20px;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 243, 232, 0.98));
|
||||
box-shadow:
|
||||
@@ -460,7 +557,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.pricing-plan-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -547,7 +700,7 @@
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 16px;
|
||||
border-radius: 20px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.meet-greet-copy p {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-inner service-hero-grid">
|
||||
<div class="service-hero-copy">
|
||||
<p class="service-eyebrow">{pageContent.hero.eyebrow}</p>
|
||||
<p class="eyebrow">{pageContent.hero.eyebrow}</p>
|
||||
<h1>{pageContent.hero.title}</h1>
|
||||
|
||||
{#each pageContent.hero.paragraphs as paragraph}
|
||||
@@ -63,7 +63,7 @@
|
||||
{#if pageContent.highlight}
|
||||
<section use:reveal class="service-highlight reveal-block">
|
||||
<div class="service-inner service-highlight-copy">
|
||||
<p class="service-highlight-eyebrow">{pageContent.highlight.eyebrow}</p>
|
||||
<p class="eyebrow service-highlight-eyebrow">{pageContent.highlight.eyebrow}</p>
|
||||
<h2>{pageContent.highlight.title}</h2>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -351,16 +365,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.service-eyebrow {
|
||||
margin: 0 0 18px;
|
||||
color: var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-hero-copy h1,
|
||||
.service-section-heading h2,
|
||||
.service-highlight-copy h2 {
|
||||
@@ -391,6 +395,11 @@
|
||||
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.service-hero-media img {
|
||||
aspect-ratio: 4 / 3;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.service-highlight {
|
||||
padding: 0 0 96px;
|
||||
}
|
||||
@@ -400,12 +409,13 @@
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Decorative dingbat eyebrow on the highlight section. Inherits sizing /
|
||||
* colour from the shared .eyebrow utility; this rule only adjusts the
|
||||
* downward spacing relative to the H2 underneath.
|
||||
*/
|
||||
.service-highlight-eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-highlight-image img {
|
||||
@@ -473,7 +483,7 @@
|
||||
@media (hover: hover) {
|
||||
.service-plan-card:hover,
|
||||
.service-benefit-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -558,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,13 +733,20 @@
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.service-highlight-eyebrow {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.service-extra-row {
|
||||
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>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let services: IconCard[];
|
||||
export let heading = 'What we do';
|
||||
|
||||
</script>
|
||||
|
||||
<section id="services" use:reveal={{ delay: 20 }} class="reveal-block">
|
||||
<div class="services-inner">
|
||||
<h2 class="section-heading">What we do</h2>
|
||||
<h2 class="section-heading">{heading}</h2>
|
||||
|
||||
<div class="services-grid">
|
||||
{#each services as service}
|
||||
@@ -25,7 +26,10 @@
|
||||
{/if}
|
||||
|
||||
{#if service.href}
|
||||
<a href={service.href} class="btn btn-green">Learn more</a>
|
||||
<a href={service.href} class="btn btn-green">
|
||||
<span>See {service.title} pricing</span>
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -326,7 +353,7 @@
|
||||
.testimonial-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
|
||||
min-height: 620px;
|
||||
@@ -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. Specializing 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'
|
||||
|
||||
@@ -6,7 +6,7 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
title: 'Walks for larger breeds, too!',
|
||||
paragraphs: [
|
||||
'Looking for personal attention for your big pup? Our professional dog walking service is perfect for larger dogs who walk well on leash and have good recall. Safety is paramount, so we only accommodate dogs with no reactivity issues. At Goodwalk, we pride ourselves on building relationships with both you and your furry family member.',
|
||||
"Give your dog his best life while joining our growing community of happy pet parents! For those seeking extra care, we offer specialized one-on-one walks tailored to your dog's individual needs and personality"
|
||||
"Give your dog his best life while joining our growing community of happy pet parents! For those seeking extra care, we offer specialised one-on-one walks tailored to your dog's individual needs and personality"
|
||||
],
|
||||
imageUrl:
|
||||
'/images/auckland-large-dog-one-on-one-walk.jpg',
|
||||
@@ -14,7 +14,7 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
},
|
||||
highlight: {
|
||||
eyebrow: '▼・ᴥ・▼',
|
||||
title: 'Personalized adventures for your dog!',
|
||||
title: 'Personalised adventures for your dog!',
|
||||
imageUrl: '/images/auckland-dogs-outdoor-pack.jpg',
|
||||
imageAlt: 'Goodwalk dogs gathered together outdoors'
|
||||
},
|
||||
@@ -46,16 +46,16 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
title: 'Benefits of our 1:1 walks',
|
||||
items: [
|
||||
{
|
||||
title: 'Individualized Attention',
|
||||
body: 'Large breeds receive personalized care and undivided attention from the walker, addressing their unique needs and preferences without competition from other dogs.'
|
||||
title: 'Individualised Attention',
|
||||
body: 'Large breeds receive personalised care and undivided attention from the walker, addressing their unique needs and preferences without competition from other dogs.'
|
||||
},
|
||||
{
|
||||
title: 'Tailored Exercise',
|
||||
body: 'Walkers can customize the pace, duration, and route of the walk to accommodate the energy levels and physical abilities of large breeds, providing an appropriate level of exercise tailored to their specific needs.'
|
||||
body: 'Walkers can customise the pace, duration, and route of the walk to accommodate the energy levels and physical abilities of large breeds, providing an appropriate level of exercise tailored to their specific needs.'
|
||||
},
|
||||
{
|
||||
title: 'Bonding and Socialization',
|
||||
body: 'During one-on-one walks, large breeds bond closely with their walker and socialize with people and animals encountered, promoting confidence and social skills'
|
||||
title: 'Bonding and Socialisation',
|
||||
body: 'During one-on-one walks, large breeds bond closely with their walker and socialise with people and animals encountered, promoting confidence and social skills'
|
||||
},
|
||||
{
|
||||
title: 'Enhanced safety',
|
||||
@@ -63,7 +63,7 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
},
|
||||
{
|
||||
title: 'Training Opportunities',
|
||||
body: 'One-on-one walks offer dedicated time for training and reinforcement of good behaviors, such as loose leash walking or obedience commands, helping large breeds develop and maintain positive habits.'
|
||||
body: 'One-on-one walks offer dedicated time for training and reinforcement of good behaviours, such as loose leash walking or obedience commands, helping large breeds develop and maintain positive habits.'
|
||||
},
|
||||
{
|
||||
title: 'Stress Reduction',
|
||||
|
||||
+62
-10
@@ -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,12 +83,33 @@ 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',
|
||||
title: 'Kindness',
|
||||
body:
|
||||
'With gentle care and genuine affection, we make every walk a calm, happy experience. We use positive reinforcement to encourage good behavior because kindness is at the heart of everything we do.'
|
||||
'With gentle care and genuine affection, we make every walk a calm, happy experience. We use positive reinforcement to encourage good behaviour because kindness is at the heart of everything we do.'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-camera',
|
||||
@@ -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',
|
||||
@@ -176,7 +227,7 @@ export const homepageContent: HomePageContent = {
|
||||
nearbyCta: { label: 'Book a Meet & Greet', href: '#newlead' },
|
||||
hoursLabel: 'Opening Hours',
|
||||
hours: 'Monday to Friday, 8am - 4pm.',
|
||||
faqTitle: "FAQ's",
|
||||
faqTitle: 'FAQs',
|
||||
faqs: [
|
||||
{
|
||||
question: 'Can any dog use your service?',
|
||||
@@ -219,6 +270,7 @@ export const homepageContent: HomePageContent = {
|
||||
{ label: '1:1 Walks', href: '/dog-walking' },
|
||||
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
||||
{ label: 'Our Pricing', href: '/our-pricing' },
|
||||
{ label: 'About Us', href: '/about' },
|
||||
{ label: 'Contact Us', href: '/contact-us' }
|
||||
],
|
||||
contactLinks: [
|
||||
|
||||
@@ -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,17 +53,18 @@ 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',
|
||||
items: [
|
||||
{
|
||||
title: 'Socialization with other dogs',
|
||||
body: 'Tiny Gang pack walks help small and medium-sized dogs mingle and learn social skills from each other, boosting their confidence and positive behavior.'
|
||||
title: 'Socialisation with other dogs',
|
||||
body: 'Tiny Gang pack walks help small and medium-sized dogs mingle and learn social skills from each other, boosting their confidence and positive behaviour.'
|
||||
},
|
||||
{
|
||||
title: 'Taliored peace',
|
||||
title: 'Tailored pace',
|
||||
body: 'Our handlers can adjust the pace and intensity of the walk to suit the energy levels and abilities of small and medium-sized dogs, ensuring a pleasant and enjoyable experience for all participants.'
|
||||
},
|
||||
{
|
||||
@@ -75,8 +76,8 @@ export const packWalksContent: ServicePageContent = {
|
||||
body: 'Tiny Gang pack walks foster stronger bonds between dogs and their walker, as well as between the dogs themselves, enhancing trust and companionship among the group.'
|
||||
},
|
||||
{
|
||||
title: 'Individualized attention',
|
||||
body: 'Small pack sizes allow for more personalized care and attention from the walker, addressing the unique needs and preferences of small and medium-sized breeds.'
|
||||
title: 'Individualised attention',
|
||||
body: 'Small pack sizes allow for more personalised care and attention from the walker, addressing the unique needs and preferences of small and medium-sized breeds.'
|
||||
},
|
||||
{
|
||||
title: 'Safety',
|
||||
|
||||
@@ -46,10 +46,10 @@ export const puppyVisitsContent: ServicePageContent = {
|
||||
},
|
||||
{
|
||||
title: 'Reduce anxiety',
|
||||
body: "With time your pup will know when to expect a visit, reducing the chances of accidents while you're away. With regular visits, your pup will feel loved and secure, minimizing any time spent at home alone."
|
||||
body: "With time your pup will know when to expect a visit, reducing the chances of accidents while you're away. With regular visits, your pup will feel loved and secure, minimising any time spent at home alone."
|
||||
},
|
||||
{
|
||||
title: 'Expert advise',
|
||||
title: 'Expert advice',
|
||||
body: "As experienced dog pawrents, we've been through it all with many adorable puppies. Consider us your go-to for any questions or concerns as your furry friend grows up."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -86,7 +86,7 @@ export const termsAndConditionsContent: LegalPageContent = {
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.1 Following the assessment walks, you must select your preferred permanent days of the week for walks, with a minimum commitment of one walk per week. Dogs must attend on these chosen days regularly for a minimum period of at least 6 months. Walks for dogs that show aggressive behavior may be cancelled with immediate effect.',
|
||||
'5.1 Following the assessment walks, you must select your preferred permanent days of the week for walks, with a minimum commitment of one walk per week. Dogs must attend on these chosen days regularly for a minimum period of at least 6 months. Walks for dogs that show aggressive behaviour may be cancelled with immediate effect.',
|
||||
'5.2 Walks will not take place during severe weather conditions such as high winds, heavy snow, heavy rain, thunder, and lightning. In these cases, your dog will be returned to your residence, or the walk may be shortened, cancelled or rescheduled.',
|
||||
'5.3 If you decide to cancel a walk due to bad weather or heat, you agree to pay for the cancelled walk in full. However, we will endeavour to walk dogs in heavy rain or hot weather as long as we consider that it is safe for the dogs and our dog walkers.',
|
||||
'5.4 As part of our service, we will work with you to reinforce recall training, leash training, and car behaviour for your dog, using positive reinforcement methods. However, we do not provide individual training sessions and are not responsible for training your dog or for its behaviour.',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,3 +34,15 @@ input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-29
@@ -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 {
|
||||
@@ -249,7 +289,7 @@ section {
|
||||
|
||||
.service-card {
|
||||
background: var(--off-white);
|
||||
border-radius: 20px;
|
||||
border-radius: 28px;
|
||||
padding: 40px 32px;
|
||||
text-align: center;
|
||||
transition:
|
||||
@@ -280,7 +320,7 @@ section {
|
||||
|
||||
@media (hover: hover) {
|
||||
.service-card:hover {
|
||||
transform: translateY(-6px) scale(1.01);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +395,7 @@ footer {
|
||||
|
||||
.value-card {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
border-radius: 28px;
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition:
|
||||
@@ -366,7 +406,7 @@ footer {
|
||||
|
||||
@media (hover: hover) {
|
||||
.value-card:hover {
|
||||
transform: translateY(-5px);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
@@ -390,7 +430,7 @@ footer {
|
||||
|
||||
.testimonial-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: 28px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
@@ -400,7 +440,7 @@ footer {
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,13 +35,28 @@
|
||||
|
||||
.hero-text h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 50.2px;
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
* Shared eyebrow utility for the small uppercase label that introduces a
|
||||
* heading. Use this instead of bespoke per-section kickers.
|
||||
*/
|
||||
.eyebrow {
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--green);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.hero-text h1 .hero-heading-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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