4.2.1 final fixes

This commit is contained in:
2026-05-06 11:36:19 +12:00
parent b8b9d12a82
commit a7ce4c74b5
22 changed files with 613 additions and 203 deletions
+96
View File
@@ -1,5 +1,101 @@
# Mobile Polish — Conversion & Comfort Audit Tracker
## New Rescan Items — Mobile Conversion Opportunities
Fresh opportunities from a second mobile-first pass over the main site.
These are intentionally only the new items, kept separate from the
existing audit below.
### High — conversion strategy and flow
- [ ] **Hero CTA hierarchy still prioritises browsing over booking**
- Files: `src/lib/content/homepage.ts:37-39`, `src/lib/components/HeroSection.svelte`
- Current: the yellow primary CTA is `Explore our services →`, while
`Book a Meet & Greet` is secondary.
- Why: on mobile, high-intent users should be able to choose the next
step immediately. Making the exploratory path more visually dominant
adds friction before the user reaches the lead form.
- Opportunity: test flipping the hierarchy on mobile so booking
becomes the primary CTA and service exploration becomes secondary.
- [ ] **Homepage social proof appears too late in the scroll**
- File: `src/routes/+page.svelte:143-147`
- Current order: `Services -> Values -> Testimonials -> Booking`.
- Why: testimonials are one of the strongest conversion levers, but
on mobile they arrive after several large sections. Users are asked
to keep scrolling before seeing the strongest emotional proof.
- Opportunity: move testimonials above values on the homepage, or
surface one featured review snippet earlier in the page.
- [ ] **Hero still relies on the next section for trust**
- Files: `src/lib/content/homepage.ts:43-49`, `src/lib/components/HeroSection.svelte`,
`src/lib/components/IntroStrip.svelte`
- Current: the hero presents the headline and CTAs, but the review
proof sits in the intro strip below.
- Why: on mobile, the hero needs to answer both "what is this?" and
"can I trust them?" before the user scrolls away. Separating those
two jobs weakens the first decision moment.
- Opportunity: add a compact review/trust chip directly under the
hero subtitle or near the hero CTAs on mobile.
- [ ] **Booking flow asks for dog details before it captures the lead**
- File: `src/lib/components/BookingSection.svelte:298-441`
- Current: step 1 asks for dog name, location, message, and services;
contact details are only requested in step 2.
- Why: this is a higher-friction sequence on mobile. Users often feel
more comfortable giving owner details first, then expanding into pet
specifics once they have mentally committed.
- Opportunity: test reversing the step order so step 1 captures name,
email, and phone first, then dog details second.
### Medium — mobile persuasion and CTA timing
- [ ] **Sticky mobile CTA appears on a fixed pixel threshold rather than page context**
- File: `src/lib/components/MobileBookBar.svelte:26-37`
- Current: visibility is driven by `SHOW_AFTER_PX = 480` and
`HIDE_BELOW_PX = 120`.
- Why: a fixed threshold will feel early on some phones and late on
others. It also ignores whether the hero or booking section is
actually in view.
- Opportunity: switch to an `IntersectionObserver` tied to the hero or
booking section so the bar appears based on user context rather than
raw scroll position.
- [ ] **Testimonials section pushes users off-site before finishing the proof story**
- File: `src/lib/components/TestimonialsSection.svelte:128-134`
- Current: the Instagram CTA appears near the top of the testimonials
section, before the user has fully consumed the review content.
- Why: on mobile, sending users to Instagram this early interrupts the
conversion journey and competes with the booking path.
- Opportunity: demote the Instagram CTA below the carousel, or replace
it with a tighter trust-oriented proof CTA higher up.
- [ ] **Mobile pricing pages lose the consultative "not sure?" nudge**
- File: `src/lib/components/PricingPage.svelte:12-19`
- Current: the meet-and-greet reminder prompt is gated behind
`min-width: 769px`, so desktop gets a tailored nudge and mobile
does not.
- Why: mobile users are more likely to feel overwhelmed by stacked
pricing cards, not less. Removing the consultative reassurance on
the smallest screens is directionally backwards for conversion.
- Opportunity: add an inline mobile prompt after the first pricing
section that says, in effect, "Not sure which option fits? Book a
free Meet & Greet and well help you choose."
### Medium — stacked-page CTA noise
- [ ] **Stacked pricing/service cards repeat the same CTA too many times**
- Files: `src/lib/components/PricingPage.svelte:141-159`,
`src/lib/components/ServiceLandingPage.svelte:94-112`
- Current: when cards collapse to one column on mobile, each card
keeps a full "Book a Meet & Greet" button.
- Why: the repetition turns persuasive choice architecture into visual
noise. Instead of helping the user decide, the page starts feeling
like a stack of repeated asks.
- Opportunity: treat this as a shared mobile pattern across pricing
and service pages. Keep one strong CTA per section, let the popular
card carry the primary action, and demote the rest.
Findings from a focused mobile-experience review (≤768px, with extra
attention to 375px small-phones). Desktop is considered done. Each item
records the where, why, and the concrete change.
+4 -1
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import Icon from '$lib/components/Icon.svelte';
import type { BookingContent } from '$lib/types';
import InfoSection from '$lib/components/InfoSection.svelte';
import type { BookingContent, InfoContent } from '$lib/types';
export let booking: BookingContent;
export let info: InfoContent;
export let allowGeneralEnquiry = false;
const email = 'info@goodwalk.co.nz';
@@ -35,6 +37,7 @@
</section>
<BookingSection {booking} {allowGeneralEnquiry} />
<InfoSection {info} />
</main>
<style>
+93 -63
View File
@@ -74,13 +74,13 @@
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(() => {
@@ -126,7 +126,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,7 +186,7 @@
}
function goToOwnerStep() {
if (!validateFirstStep()) return;
if (!validateDetailsStep()) return;
errors = {};
step = 2;
}
@@ -169,22 +199,11 @@
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 = {};
submitting = true;
submitErrorDetail = '';
showErrorModal = false;
@@ -194,26 +213,27 @@
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,
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 +249,6 @@
<section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
<div class="form-inner">
{#if submitted}
<SuccessModal
firstName={fullName.split(' ')[0]}
@@ -251,7 +270,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 +279,13 @@
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => (step = 1)}
on:click={() => {
step = 1;
errors = {};
}}
>
<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
@@ -277,12 +300,7 @@
</div>
</div>
<form
class="booking-form"
id="bookingForm"
novalidate
on:submit={handleSubmit}
>
<form class="booking-form" id="bookingForm" novalidate on:submit={handleSubmit}>
<div class="booking-honeypot" aria-hidden="true">
<label for="website">Website</label>
<input
@@ -296,12 +314,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 +383,10 @@
{/if}
</div>
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
<div
class="booking-field-card booking-field-card-wide"
class:booking-field-card-invalid={errors.location}
>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
</label>
@@ -385,7 +410,10 @@
</div>
{/if}
<div class="booking-field-card booking-field-card-full" class:booking-field-card-invalid={errors.message}>
<div
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
@@ -438,26 +466,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 +569,10 @@
<button
type="button"
class="btn btn-outline btn-outline-green"
on:click={() => { step = 1; errors = {}; }}
on:click={() => {
step = 1;
errors = {};
}}
>
Back
</button>
+37 -68
View File
@@ -3,6 +3,37 @@ 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(() => {
Object.defineProperty(document, 'referrer', {
@@ -29,14 +60,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 +79,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')!);
@@ -128,15 +130,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 +142,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));
@@ -192,23 +176,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')!);
+7 -1
View File
@@ -85,7 +85,13 @@
rel="noopener"
class="footer-reviews"
>
<Icon name="fab fa-google" />
<img
class="footer-google-logo"
src="/images/google-g-logo.svg"
alt=""
width="16"
height="17"
/>
<span>30+ five-star Google reviews</span>
</a>
+27 -1
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { HeroContent } from '$lib/types';
import type { CallToAction, HeroContent } from '$lib/types';
export let hero: HeroContent;
export let reviewCta: CallToAction | undefined = undefined;
$: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
@@ -44,6 +46,30 @@
<p class="hero-subtitle">{hero.subtitle}</p>
{/if}
{#if reviewCta}
<a
class="hero-trust-chip"
href={reviewCta.href}
target={reviewCta.external ? '_blank' : undefined}
rel={reviewCta.external ? 'noopener' : undefined}
aria-label="Read our five-star Google reviews"
>
<img
class="hero-trust-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span class="hero-trust-stars" aria-hidden="true">
{#each Array(5) as _}
<Icon name="fas fa-star" />
{/each}
</span>
<span>{reviewCta.label}</span>
</a>
{/if}
<div class="hero-buttons">
<a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a>
<a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a>
+61 -15
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import Icon from '$lib/components/Icon.svelte';
@@ -23,32 +23,78 @@
$: pathname = $page.url.pathname;
$: hidden = pathname === '/contact-us' || pathname === '/booking';
const SHOW_AFTER_PX = 480; // close to one mobile viewport
const HIDE_BELOW_PX = 120; // generous so the bar doesn't flicker near the top
let visible = false;
let triggerPassed = false;
let bookingInView = false;
let triggerObserver: IntersectionObserver | null = null;
let bookingObserver: IntersectionObserver | null = null;
function evaluateVisibility() {
const y = window.scrollY;
if (y > SHOW_AFTER_PX) {
visible = true;
} else if (y < HIDE_BELOW_PX) {
visible = false;
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(() => {
evaluateVisibility();
window.addEventListener('scroll', evaluateVisibility, { passive: true });
window.addEventListener('resize', evaluateVisibility, { passive: true });
void setupObservers();
return () => {
window.removeEventListener('scroll', evaluateVisibility);
window.removeEventListener('resize', evaluateVisibility);
cleanupObservers();
};
});
</script>
+101 -4
View File
@@ -104,18 +104,25 @@
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+ five-star Google reviews</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">
@@ -133,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>
@@ -159,6 +167,25 @@
</article>
{/each}
</div>
<a class="btn btn-yellow pricing-section-mobile-cta" href="#newlead">
Book a Meet &amp; Greet
</a>
{#if index === 0}
<aside class="pricing-mobile-consult" aria-label="Need help choosing the right option?">
<span class="pricing-mobile-consult-kicker">
<Icon name="fas fa-comment-dots" />
Not sure which option fits?
</span>
<p>
Book a free Meet &amp; Greet and well help you choose the right walk or visit for your dog.
</p>
<a class="btn btn-outline btn-outline-green pricing-mobile-consult-cta" href="#newlead">
Talk it through with us
</a>
</aside>
{/if}
</div>
</section>
{/each}
@@ -238,6 +265,12 @@
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);
@@ -307,6 +340,9 @@
}
.pricing-section-link {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 18px;
}
@@ -395,6 +431,11 @@
font-family: var(--font-head);
}
.pricing-section-mobile-cta,
.pricing-mobile-consult {
display: none;
}
.meet-greet-prompt {
position: fixed;
right: 24px;
@@ -559,6 +600,12 @@
font-size: 34px;
}
.pricing-trust {
gap: 10px;
padding: 10px 14px;
font-size: 13px;
}
.pricing-section-heading h2 {
font-size: 26px;
}
@@ -568,7 +615,7 @@
}
.pricing-section-heading {
margin-bottom: 20px;
margin-bottom: 26px;
}
.pricing-section-blurb {
@@ -576,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;
@@ -594,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;
@@ -125,6 +125,8 @@
Every booking starts with a free, no-obligation Meet &amp; Greet.
</p>
<a class="btn btn-yellow service-plan-mobile-cta" href="#newlead">Book a Meet &amp; Greet</a>
{#if pageContent.pricing.extras?.length}
<div class="service-extras">
<div class="service-extras-heading">Extras</div>
@@ -566,6 +568,10 @@
font-family: var(--font-head);
}
.service-plan-mobile-cta {
display: none;
}
.service-plan-reassurance,
.service-plan-scarcity {
display: flex;
@@ -731,5 +737,16 @@
flex-direction: column;
align-items: flex-start;
}
.service-plan-cta {
display: none;
}
.service-plan-mobile-cta {
display: flex;
width: fit-content;
margin: 18px auto 0;
font-family: var(--font-head);
}
}
</style>
+2 -1
View File
@@ -27,7 +27,8 @@
{#if service.href}
<a href={service.href} class="btn btn-green">
See {service.title} pricing
<span>See {service.title} pricing</span>
<Icon name="fas fa-arrow-right" />
</a>
{/if}
</div>
+64 -26
View File
@@ -127,10 +127,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,13 +186,39 @@
<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" />
<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>
@@ -214,6 +236,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 +259,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 +311,7 @@
}
.testimonials-instagram-link {
margin-top: 14px;
margin: 14px auto 0;
padding: 9px 14px;
font-size: 15px;
}
@@ -443,14 +471,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 +593,7 @@
.testimonial-stage {
min-height: unset;
padding-bottom: 116px;
padding-bottom: 0;
}
.testimonial-slide {
@@ -605,8 +639,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 +699,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>
+2 -2
View File
@@ -35,8 +35,8 @@ export const homepageContent: HomePageContent = {
highlight: "Your Dog's Day!",
mobileTitle: "Unleashing Fun in\nYour Dog's Day!",
subtitle: 'Trusted, on-time dog walking across Auckland Central — pack walks, 1:1 walks, and puppy visits.',
primaryCta: { label: 'Explore our services →', href: '#services', variant: 'yellow' },
secondaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'outline' },
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'
},
+2
View File
@@ -76,6 +76,7 @@ describe('content server helpers', () => {
services: homepageContent.services,
testimonials: homepageContent.testimonials,
booking: homepageContent.booking,
info: homepageContent.info,
footer: homepageContent.footer
});
});
@@ -89,6 +90,7 @@ describe('content server helpers', () => {
expect(result.services).not.toBe(homepageContent.services);
expect(result.testimonials).not.toBe(homepageContent.testimonials);
expect(result.booking).not.toBe(homepageContent.booking);
expect(result.info).not.toBe(homepageContent.info);
expect(result.footer).not.toBe(homepageContent.footer);
});
});
+1
View File
@@ -55,6 +55,7 @@ export async function getSharedPageContent(): Promise<SiteSharedContent> {
services: content.services,
testimonials: content.testimonials,
booking: content.booking,
info: content.info,
footer: content.footer
};
}
+9
View File
@@ -367,12 +367,21 @@
.booking-actions-next {
justify-content: center;
flex-direction: column;
}
.booking-actions-final {
justify-content: space-between;
}
.booking-next-note {
margin: 10px 0 0;
text-align: center;
font-size: 13px;
line-height: 1.45;
color: #666;
}
.booking-next-button,
.booking-submit-button {
display: inline-flex;
+23 -16
View File
@@ -195,7 +195,7 @@
.hero-inner {
flex-direction: column;
gap: 24px;
gap: 18px;
align-items: stretch;
text-align: left;
padding: 0;
@@ -222,6 +222,12 @@
line-height: 1.5;
}
.hero-trust-chip {
margin-bottom: 18px;
padding: 10px 14px;
font-size: 14px;
}
.hero-heading-desktop {
display: none;
}
@@ -239,37 +245,38 @@
.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: 15px 12px;
font-size: 13.5px;
font-weight: 700;
text-align: center;
border-radius: 999px;
line-height: 1.25;
-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 {
@@ -277,11 +284,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 {
@@ -295,7 +302,7 @@
}
.hero-img img {
width: min(100%, 500px);
width: min(100%, 460px);
max-width: 100%;
margin: 0 auto -7px;
object-fit: contain;
+49
View File
@@ -33,6 +33,49 @@ section {
line-height: 1.55;
}
.hero-trust-chip {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition:
background 0.2s ease,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
}
.hero-trust-logo {
width: 18px;
height: 19px;
flex: 0 0 auto;
}
.hero-trust-stars {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--yellow);
font-size: 13px;
}
@media (hover: hover) {
.hero-trust-chip:hover {
background: rgba(255, 255, 255, 0.16);
transform: translateY(-1px);
}
}
.hero-trust-chip:active {
transform: translateY(1px) scale(0.99);
}
.hero-buttons {
display: flex;
gap: 16px;
@@ -585,6 +628,12 @@ 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;
+1
View File
@@ -232,5 +232,6 @@ export interface SiteSharedContent {
services: IconCard[];
testimonials: TestimonialContent[];
booking: BookingContent;
info: InfoContent;
footer: FooterContent;
}
+2 -4
View File
@@ -5,7 +5,6 @@
import HeroSection from '$lib/components/HeroSection.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 +136,11 @@
/>
<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} />
<TestimonialsSection testimonials={data.content.testimonials} />
<ValuesSection values={data.content.values} />
<BookingSection booking={data.content.booking} />
<InfoSection info={data.content.info} />
<InstagramSection instagram={data.content.instagram} />
+5 -1
View File
@@ -218,7 +218,11 @@
{:else if data.slug === 'privacy-policy'}
<LegalPage pageContent={privacyPolicyContent} />
{:else if data.slug === 'contact-us'}
<BookingPage booking={data.content.booking} allowGeneralEnquiry={data.generalEnquiryEnabled} />
<BookingPage
booking={data.content.booking}
info={data.content.info}
allowGeneralEnquiry={data.generalEnquiryEnabled}
/>
{:else}
<main class="static-page">
<section class="static-page-hero">
+9
View File
@@ -60,4 +60,13 @@ describe('static slug route page', () => {
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
});
it('renders the shared FAQ section on the contact page', () => {
render(SlugPage, {
data: createStaticRouteData('contact-us')
});
expect(screen.getByText('FAQs')).toBeInTheDocument();
expect(screen.getByText('Can any dog use your service?')).toBeInTheDocument();
});
});
+1
View File
@@ -6,6 +6,7 @@ export const sharedPageContent = {
services: homepageContent.services,
testimonials: homepageContent.testimonials,
booking: homepageContent.booking,
info: homepageContent.info,
footer: homepageContent.footer
};