SEO tweaks, design tweaks

This commit is contained in:
2026-05-03 11:49:59 +12:00
parent f27e0fed07
commit 6cd50965e5
17 changed files with 359 additions and 244 deletions
+121 -121
View File
@@ -99,27 +99,22 @@
selectedServices = selectedServices.filter((item) => item !== service); selectedServices = selectedServices.filter((item) => item !== service);
} }
function validateStepOne(): boolean { function validateDogStep(): boolean {
const next: Record<string, string> = {}; const next: Record<string, string> = {};
if (!fullName.trim()) next.fullName = 'Please enter your full name'; if (!petName.trim()) next.petName = "Please enter your dog's name";
if (!location.trim()) next.location = 'Please enter your location';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
errors = next; errors = next;
if (next.fullName) { fullNameInput?.focus(); return false; } if (next.petName) { petNameInput?.focus(); return false; }
if (next.email) { emailInput?.focus(); return false; } if (next.location) { locationInput?.focus(); return false; }
if (next.phone) { phoneInput?.focus(); return false; }
return true; return true;
} }
function goToDogStep() { function goToOwnerStep() {
if (!validateStepOne()) return; if (!validateDogStep()) return;
errors = {}; errors = {};
step = 2; step = 2;
} }
@@ -128,18 +123,23 @@
event.preventDefault(); event.preventDefault();
if (step === 1) { if (step === 1) {
goToDogStep(); goToOwnerStep();
return; return;
} }
const next: Record<string, string> = {}; const next: Record<string, string> = {};
if (!petName.trim()) next.petName = "Please enter your dog's name"; if (!fullName.trim()) next.fullName = 'Please enter your full name';
if (!location.trim()) next.location = 'Please enter your location';
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) { if (Object.keys(next).length > 0) {
errors = next; errors = next;
if (next.petName) petNameInput?.focus(); if (next.fullName) fullNameInput?.focus();
else if (next.location) locationInput?.focus(); else if (next.email) emailInput?.focus();
else if (next.phone) phoneInput?.focus();
return; return;
} }
@@ -212,17 +212,17 @@
on:click={() => (step = 1)} on:click={() => (step = 1)}
> >
<span class="booking-step-number">1</span> <span class="booking-step-number">1</span>
<span class="booking-step-label">{ownerStepLabel}</span> <span class="booking-step-label">{dogStepLabel}</span>
</button> </button>
<span class="booking-step-divider" aria-hidden="true"></span> <span class="booking-step-divider" aria-hidden="true"></span>
<button <button
type="button" type="button"
class:active={step === 2} class:active={step === 2}
class="booking-step" class="booking-step"
on:click={goToDogStep} on:click={goToOwnerStep}
> >
<span class="booking-step-number">2</span> <span class="booking-step-number">2</span>
<span class="booking-step-label">{dogStepLabel}</span> <span class="booking-step-label">{ownerStepLabel}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -246,6 +246,107 @@
</div> </div>
{#if step === 1} {#if step === 1}
<div class="booking-panel">
{#if dogIntro}
<div class="booking-panel-banner">{dogIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(dogIntro)} class="booking-card-grid booking-card-grid-dog">
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Pet's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-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>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Neighborhood, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-full">
<label for="message"><Icon name="fas fa-comment" />&nbsp;About Your Dog</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder="Describe your pet, any special needs, or anything we should know."
></textarea>
</div>
</div>
{#if hasServices}
<div class="booking-service-row">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
<div class="booking-actions booking-actions-next">
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToOwnerStep}>
{ownerStepLabel}
<Icon name="fas fa-arrow-right" />
</button>
</div>
{:else}
<input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} />
<input type="hidden" name="message" value={message} />
{#each selectedServices as service}
<input type="hidden" name="services" value={service} />
{/each}
<div class="booking-panel"> <div class="booking-panel">
{#if hasBanner} {#if hasBanner}
<div class="booking-panel-banner">{booking.subtitle}</div> <div class="booking-panel-banner">{booking.subtitle}</div>
@@ -330,107 +431,6 @@
</div> </div>
</div> </div>
</div> </div>
{#if hasServices}
<div class="booking-service-row">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
</div>
{/if}
</div>
<div class="booking-actions booking-actions-next">
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToDogStep}>
{dogStepLabel}
<Icon name="fas fa-arrow-right" />
</button>
</div>
{:else}
<input type="hidden" name="fullName" value={fullName} />
<input type="hidden" name="email" value={email} />
<input type="hidden" name="phone" value={phone} />
{#each selectedServices as service}
<input type="hidden" name="services" value={service} />
{/each}
<div class="booking-panel">
{#if dogIntro}
<div class="booking-panel-banner">{dogIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(dogIntro)} class="booking-card-grid booking-card-grid-dog">
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Pet's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-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>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Neighborhood, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
</p>
{/if}
</div>
<div class="booking-field-card booking-field-card-full">
<label for="message"><Icon name="fas fa-comment" />&nbsp;About Your Dog</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder="Describe your pet, any special needs, or anything we should know."
></textarea>
</div>
</div>
</div> </div>
<div class="booking-actions booking-actions-final"> <div class="booking-actions booking-actions-final">
+31 -34
View File
@@ -11,38 +11,35 @@ describe('BookingSection', () => {
}); });
}); });
it('validates the owner details step before progressing', async () => { it('validates the dog details step before progressing', async () => {
const { container } = render(BookingSection, { const { container } = render(BookingSection, {
booking: homepageContent.booking booking: homepageContent.booking
}); });
await fireEvent.click(container.querySelector('.booking-next-button')!); await fireEvent.click(container.querySelector('.booking-next-button')!);
expect(screen.getByText('Please enter your full name')).toBeInTheDocument(); expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument(); expect(screen.getByText('Please enter your location')).toBeInTheDocument();
expect(screen.getByText('Please enter your contact number')).toBeInTheDocument();
}); });
it('validates the dog details step before submitting', async () => { it('validates the owner details step before submitting', async () => {
const { container } = render(BookingSection, { const { container } = render(BookingSection, {
booking: homepageContent.booking booking: homepageContent.booking
}); });
await fireEvent.input(screen.getByLabelText(/Full Name/i), { await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
target: { value: 'Alex Walker' } target: { value: 'Maya' }
}); });
await fireEvent.input(screen.getByLabelText(/^Email/i), { await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'alex@example.com' } target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' }
}); });
await fireEvent.click(container.querySelector('.booking-next-button')!); await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.click(container.querySelector('.booking-submit-button')!); await fireEvent.click(container.querySelector('.booking-submit-button')!);
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument(); expect(screen.getByText('Please enter your full name')).toBeInTheDocument();
expect(screen.getByText('Please enter your location')).toBeInTheDocument(); expect(screen.getByText('Please enter your email address')).toBeInTheDocument();
expect(screen.getByText('Please enter your contact number')).toBeInTheDocument();
}); });
it('submits the completed booking flow and shows the success modal', async () => { it('submits the completed booking flow and shows the success modal', async () => {
@@ -59,18 +56,6 @@ describe('BookingSection', () => {
await fireEvent.click(screen.getByLabelText('Pack Walks')); await fireEvent.click(screen.getByLabelText('Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services')); await fireEvent.click(screen.getByLabelText('Other Services'));
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 fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), { await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
target: { value: 'Maya' } target: { value: 'Maya' }
}); });
@@ -81,6 +66,18 @@ describe('BookingSection', () => {
target: { value: 'Loves small group walks.' } 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 fireEvent.click(container.querySelector('.booking-submit-button')!); await fireEvent.click(container.querySelector('.booking-submit-button')!);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
@@ -129,6 +126,14 @@ describe('BookingSection', () => {
booking: homepageContent.booking booking: homepageContent.booking
}); });
await fireEvent.input(screen.getByLabelText(/Pet'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), { await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' } target: { value: 'Alex Walker' }
}); });
@@ -138,14 +143,6 @@ describe('BookingSection', () => {
await fireEvent.input(screen.getByLabelText(/Contact #/i), { await fireEvent.input(screen.getByLabelText(/Contact #/i), {
target: { value: '021 123 4567' } target: { value: '021 123 4567' }
}); });
await fireEvent.click(container.querySelector('.booking-next-button')!);
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-submit-button')!); await fireEvent.click(container.querySelector('.booking-submit-button')!);
+13
View File
@@ -90,6 +90,7 @@
{#if i === 0 && navigation.megaMenuServices?.length} {#if i === 0 && navigation.megaMenuServices?.length}
<div class="mega-menu"> <div class="mega-menu">
<div class="mega-menu-inner"> <div class="mega-menu-inner">
<div class="mega-menu-services">
{#each navigation.megaMenuServices as service} {#each navigation.megaMenuServices as service}
<a <a
href={service.href} href={service.href}
@@ -107,6 +108,18 @@
</a> </a>
{/each} {/each}
</div> </div>
{#if navigation.megaMenuFooter}
<a
href={navigation.megaMenuFooter.href}
target={linkTarget(navigation.megaMenuFooter.external)}
rel={linkRel(navigation.megaMenuFooter.external)}
class="mega-menu-footer"
>
<span>{navigation.megaMenuFooter.label}</span>
<Icon name="fas fa-arrow-right" className="mega-menu-footer-arrow" />
</a>
{/if}
</div>
</div> </div>
{/if} {/if}
</li> </li>
+4
View File
@@ -40,6 +40,10 @@
<span class="hero-heading-mobile">{mobileTitle}</span> <span class="hero-heading-mobile">{mobileTitle}</span>
</h1> </h1>
{#if hero.subtitle}
<p class="hero-subtitle">{hero.subtitle}</p>
{/if}
<div class="hero-buttons"> <div class="hero-buttons">
<a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a> <a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a>
<a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a> <a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a>
+15 -37
View File
@@ -9,9 +9,7 @@
export let content: SiteSharedContent; export let content: SiteSharedContent;
export let pageContent: PricingPageContent; export let pageContent: PricingPageContent;
const promptDelayMs = 10000; const scrollDepthThreshold = 0.65;
const promptIdleMs = 6000;
const minPromptDwellMs = 3500;
const desktopPromptMediaQuery = '(min-width: 769px)'; const desktopPromptMediaQuery = '(min-width: 769px)';
let showMeetGreetPrompt = false; let showMeetGreetPrompt = false;
@@ -44,44 +42,28 @@
} }
onMount(() => { onMount(() => {
const startedAt = Date.now();
let lastInteractionAt = startedAt;
const desktopPromptQuery = window.matchMedia(desktopPromptMediaQuery); const desktopPromptQuery = window.matchMedia(desktopPromptMediaQuery);
canShowDesktopPrompt = desktopPromptQuery.matches; canShowDesktopPrompt = desktopPromptQuery.matches;
const recordInteraction = () => {
lastInteractionAt = Date.now();
};
const handleDesktopPromptViewportChange = (event: MediaQueryListEvent) => { const handleDesktopPromptViewportChange = (event: MediaQueryListEvent) => {
canShowDesktopPrompt = event.matches; canShowDesktopPrompt = event.matches;
}; };
const interactionEvents: Array<keyof WindowEventMap> = [ const handleScroll = () => {
'pointerdown', if (promptShown || dismissMeetGreetPrompt || bookingInView || !canShowDesktopPrompt) {
'mousemove', return;
'keydown', }
'scroll',
'touchstart'
];
interactionEvents.forEach((eventName) => { const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight;
window.addEventListener(eventName, recordInteraction); if (scrollableHeight <= 0) return;
});
const delayedPrompt = window.setTimeout(() => { const scrollPercent = window.scrollY / scrollableHeight;
revealMeetGreetPrompt(); if (scrollPercent >= scrollDepthThreshold) {
}, promptDelayMs);
const idlePromptCheck = window.setInterval(() => {
const now = Date.now();
const hasWaitedLongEnough = now - startedAt >= minPromptDwellMs;
const looksIdle = now - lastInteractionAt >= promptIdleMs;
if (document.visibilityState === 'visible' && hasWaitedLongEnough && looksIdle) {
revealMeetGreetPrompt(); revealMeetGreetPrompt();
} }
}, 1000); };
window.addEventListener('scroll', handleScroll, { passive: true });
const bookingSection = document.getElementById('newlead'); const bookingSection = document.getElementById('newlead');
const bookingObserver = bookingSection const bookingObserver = bookingSection
@@ -100,11 +82,7 @@
desktopPromptQuery.addEventListener('change', handleDesktopPromptViewportChange); desktopPromptQuery.addEventListener('change', handleDesktopPromptViewportChange);
return () => { return () => {
window.clearTimeout(delayedPrompt); window.removeEventListener('scroll', handleScroll);
window.clearInterval(idlePromptCheck);
interactionEvents.forEach((eventName) => {
window.removeEventListener(eventName, recordInteraction);
});
desktopPromptQuery.removeEventListener('change', handleDesktopPromptViewportChange); desktopPromptQuery.removeEventListener('change', handleDesktopPromptViewportChange);
bookingObserver?.disconnect(); bookingObserver?.disconnect();
}; };
@@ -161,7 +139,7 @@
{/each} {/each}
</ul> </ul>
<a class="btn btn-yellow pricing-plan-cta" href="#newlead">Book Now</a> <a class="btn btn-yellow pricing-plan-cta" href="#newlead">Book a Meet &amp; Greet</a>
</article> </article>
{/each} {/each}
</div> </div>
@@ -188,7 +166,7 @@
</p> </p>
</div> </div>
<a class="meet-greet-cta" href="#newlead" on:click={handleMeetGreetCta}>Let's chat</a> <a class="meet-greet-cta" href="#newlead" on:click={handleMeetGreetCta}>Book a Meet &amp; Greet</a>
</aside> </aside>
{/if} {/if}
</main> </main>
+5 -1
View File
@@ -15,10 +15,14 @@
{promise.subtitle} {promise.subtitle}
</h2> </h2>
{#each promise.body as paragraph, idx}
<p> <p>
{promise.body} {paragraph}
{#if idx === promise.body.length - 1}
<strong>{promise.emphasis}</strong> <strong>{promise.emphasis}</strong>
{/if}
</p> </p>
{/each}
<a href={promise.cta.href} class="btn btn-green">{promise.cta.label}</a> <a href={promise.cta.href} class="btn btn-green">{promise.cta.label}</a>
</div> </div>
+1 -1
View File
@@ -87,7 +87,7 @@
{/each} {/each}
</ul> </ul>
<a class="btn btn-yellow service-plan-cta" href="#newlead">Book Now</a> <a class="btn btn-yellow service-plan-cta" href="#newlead">Book a Meet &amp; Greet</a>
</article> </article>
{/each} {/each}
</div> </div>
@@ -20,6 +20,10 @@
<h3>{service.title}</h3> <h3>{service.title}</h3>
<p>{service.body}</p> <p>{service.body}</p>
{#if service.priceFrom}
<p class="service-card-price">{service.priceFrom}</p>
{/if}
{#if service.href} {#if service.href}
<a href={service.href} class="btn btn-green">Learn more</a> <a href={service.href} class="btn btn-green">Learn more</a>
{/if} {/if}
+37 -7
View File
@@ -47,6 +47,9 @@
let activeIndex = 0; let activeIndex = 0;
let paused = false; let paused = false;
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
$: slides = testimonials $: slides = testimonials
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial) .map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
@@ -73,13 +76,37 @@
} }
onMount(() => { onMount(() => {
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotion = motionQuery.matches;
const onMotionChange = (event: MediaQueryListEvent) => {
prefersReducedMotion = event.matches;
};
motionQuery.addEventListener('change', onMotionChange);
const observer = carouselEl
? new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
},
{ threshold: 0.25 }
)
: null;
if (observer && carouselEl) {
observer.observe(carouselEl);
}
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
if (!paused && slides.length > 1) { if (!paused && !prefersReducedMotion && inView && slides.length > 1) {
showNext(); showNext();
} }
}, 5000); }, 9000);
return () => window.clearInterval(interval); return () => {
window.clearInterval(interval);
motionQuery.removeEventListener('change', onMotionChange);
observer?.disconnect();
};
}); });
</script> </script>
@@ -96,11 +123,14 @@
{#if slides.length} {#if slides.length}
<div <div
bind:this={carouselEl}
class="testimonials-carousel" class="testimonials-carousel"
role="region" role="region"
aria-label="Customer testimonials" aria-label="Customer testimonials"
on:mouseenter={() => (paused = true)} on:mouseenter={() => (paused = true)}
on:mouseleave={() => (paused = false)} on:mouseleave={() => (paused = false)}
on:focusin={() => (paused = true)}
on:focusout={() => (paused = false)}
> >
<button <button
class="testimonial-arrow testimonial-arrow-left" class="testimonial-arrow testimonial-arrow-left"
@@ -140,7 +170,7 @@
<div class="testimonial-copy"> <div class="testimonial-copy">
<span class="testimonial-quote-mark">"</span> <span class="testimonial-quote-mark">"</span>
<h5>{testimonial.quote}</h5> <blockquote class="testimonial-quote">{testimonial.quote}</blockquote>
<div class="testimonial-author"> <div class="testimonial-author">
<span class="testimonial-author-name">{testimonial.reviewer}</span> <span class="testimonial-author-name">{testimonial.reviewer}</span>
<span class="testimonial-author-detail">{testimonial.detail}</span> <span class="testimonial-author-detail">{testimonial.detail}</span>
@@ -344,7 +374,7 @@
user-select: none; user-select: none;
} }
.testimonial-copy h5 { .testimonial-copy .testimonial-quote {
max-width: 500px; max-width: 500px;
margin: 0; margin: 0;
font-size: 17px; font-size: 17px;
@@ -500,7 +530,7 @@
padding: 96px 72px 64px 8px; padding: 96px 72px 64px 8px;
} }
.testimonial-copy h5 { .testimonial-copy .testimonial-quote {
max-width: 460px; max-width: 460px;
font-size: 17px; font-size: 17px;
} }
@@ -554,7 +584,7 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.testimonial-copy h5 { .testimonial-copy .testimonial-quote {
font-size: 16px; font-size: 16px;
line-height: 1.55; line-height: 1.55;
} }
@@ -59,7 +59,7 @@ describe('TestimonialsSection', () => {
await fireEvent.click(nextButton); await fireEvent.click(nextButton);
expect(getActiveReviewer(container)).toBe('Estelle'); expect(getActiveReviewer(container)).toBe('Estelle');
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(9000);
expect(getActiveReviewer(container)).toBe('Ross'); expect(getActiveReviewer(container)).toBe('Ross');
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ export const aboutPageContent: AboutPageContent = {
email: 'info@goodwalk.co.nz', email: 'info@goodwalk.co.nz',
phone: '(022) 642 1011', phone: '(022) 642 1011',
cta: { cta: {
label: 'Contact us', label: 'Book a Meet & Greet',
href: '/contact-us', href: '/contact-us',
variant: 'yellow' variant: 'yellow'
} }
+30 -21
View File
@@ -21,25 +21,27 @@ export const homepageContent: HomePageContent = {
{ label: 'About Us', href: '/about' }, { label: 'About Us', href: '/about' },
{ label: 'Contact Us', href: '/contact-us' } { label: 'Contact Us', href: '/contact-us' }
], ],
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' }, cta: { label: 'Book a Meet & Greet', href: '/contact-us', variant: 'yellow' },
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true }, instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
megaMenuServices: [ megaMenuServices: [
{ icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' }, { icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' },
{ icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' }, { icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' },
{ icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' } { icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' }
] ],
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
}, },
hero: { hero: {
title: 'Unleashing Fun in', title: 'Unleashing Fun in',
highlight: "Your Dog's Day!", highlight: "Your Dog's Day!",
mobileTitle: "Unleashing Fun in\nYour 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' }, primaryCta: { label: 'Learn more', href: '#services', variant: 'yellow' },
secondaryCta: { label: 'Enroll today', href: '#newlead', variant: 'outline' }, secondaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'outline' },
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png', imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service' imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
}, },
intro: { intro: {
text: 'Goodwalk delivers trusted, professional dog walking services across Auckland Central.', text: 'Trusted by Auckland dog parents.',
reviewCta: { reviewCta: {
label: 'All 5 star reviews on Google!', label: 'All 5 star reviews on Google!',
href: 'https://g.page/r/CUsvrWPhkYrAEB0/', href: 'https://g.page/r/CUsvrWPhkYrAEB0/',
@@ -49,10 +51,12 @@ export const homepageContent: HomePageContent = {
promise: { promise: {
title: 'Happy pets,', title: 'Happy pets,',
subtitle: 'happy humans', subtitle: 'happy humans',
body: body: [
'Professional dog walking services in Auckland for small, medium and large breeds. Our experienced walkers provide tailored pack walks for smaller dogs and one-on-one walks for larger breeds - giving every dog the personalised attention they deserve. We specialise in understanding the unique needs of small-to-medium breeds, helping to ease stress and anxiety while keeping tails wagging. Ready to join our', 'We specialise in the unique needs of small-to-medium breeds easing stress and anxiety while keeping tails wagging.',
'Professional dog walking across Auckland for small, medium and large breeds, with tailored pack walks for smaller dogs and one-on-one walks for larger breeds — giving every dog the personalised attention they deserve. Ready to join our'
],
emphasis: 'TINY GANG?', emphasis: 'TINY GANG?',
cta: { label: 'Book now', href: '#newlead', variant: 'green' }, cta: { label: 'See our services', href: '#services', variant: 'green' },
imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.webp', imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.webp',
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services' imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
}, },
@@ -61,18 +65,21 @@ export const homepageContent: HomePageContent = {
icon: 'fas fa-dog', icon: 'fas fa-dog',
title: 'Pack Walks', title: 'Pack Walks',
body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.', body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.',
priceFrom: 'From $49.50 / walk',
href: '/pack-walks' href: '/pack-walks'
}, },
{ {
icon: 'fas fa-person-walking', icon: 'fas fa-person-walking',
title: '1:1 Walks', title: '1:1 Walks',
body: "One-on-one walks tailored to your dog's individual pace, personality, and needs.", body: "One-on-one walks tailored to your dog's individual pace, personality, and needs.",
priceFrom: 'From $45 / walk',
href: '/dog-walking' href: '/dog-walking'
}, },
{ {
icon: 'fas fa-house', icon: 'fas fa-house',
title: 'Puppy Visits', title: 'Puppy Visits',
body: 'In-home visits to check in on your puppy, play, and keep them company.', body: 'In-home visits to check in on your puppy, play, and keep them company.',
priceFrom: 'From $39 / visit',
href: '/puppy-visits' href: '/puppy-visits'
} }
], ],
@@ -150,9 +157,11 @@ export const homepageContent: HomePageContent = {
booking: { booking: {
title: "Let's meet!", title: "Let's meet!",
subtitle: subtitle:
'Ready to get started? Book your free, no-obligation Meet & Greet today — just enter your details below', "Almost there — just your contact details so we can reach out to arrange your free, no-obligation Meet & Greet.",
formAction: '/contact-us', formAction: '/contact-us',
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'] serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
ownerStepLabel: 'Your details',
dogStepLabel: 'Your dog'
}, },
info: { info: {
title: 'Locations & Hours', title: 'Locations & Hours',
@@ -160,21 +169,11 @@ export const homepageContent: HomePageContent = {
suburbs: suburbs:
'Morningside, Kingsland, Ponsonby, Grey Lynn, Mt Albert, Mt Eden, Sandringham, Mt Roskill, Arch Hill, Freemans Bay, Herne Bay, Pt Chevalier, Avondale, Three Kings, Hillsborough, Eden Terrace, Balmoral.', 'Morningside, Kingsland, Ponsonby, Grey Lynn, Mt Albert, Mt Eden, Sandringham, Mt Roskill, Arch Hill, Freemans Bay, Herne Bay, Pt Chevalier, Avondale, Three Kings, Hillsborough, Eden Terrace, Balmoral.',
nearbyText: 'Live in a nearby suburb?', nearbyText: 'Live in a nearby suburb?',
nearbyCta: { label: 'Get in touch!', href: '#newlead' }, nearbyCta: { label: 'Book a Meet & Greet', href: '#newlead' },
hoursLabel: 'Opening Hours', hoursLabel: 'Opening Hours',
hours: 'Monday to Friday, 8am - 4pm.', hours: 'Monday to Friday, 8am - 4pm.',
faqTitle: "FAQ's", faqTitle: "FAQ's",
faqs: [ faqs: [
{
question: 'What happens if the weather is bad?',
answer:
"We operate in all weather conditions, except when there is a danger to the dog's health and safety."
},
{
question: 'What requirements does my dog need?',
answer:
'All dogs onboarding with Goodwalk need to have a current Auckland Council dog registration and be up to date with vaccinations to ensure the health and safety of other dogs.'
},
{ {
question: 'Can any dog use your service?', question: 'Can any dog use your service?',
answer: answer:
@@ -184,10 +183,20 @@ export const homepageContent: HomePageContent = {
question: 'How does payment work?', question: 'How does payment work?',
answer: 'All walks are paid for a week in advance, via invoice.' answer: 'All walks are paid for a week in advance, via invoice.'
}, },
{
question: 'What requirements does my dog need?',
answer:
'All dogs onboarding with Goodwalk need to have a current Auckland Council dog registration and be up to date with vaccinations to ensure the health and safety of other dogs.'
},
{ {
question: 'Do you have insurance or First Aid training?', question: 'Do you have insurance or First Aid training?',
answer: answer:
'All walkers are covered by public liability insurance, and all walkers hold a current First Aid training certificate.' 'All walkers are covered by public liability insurance, and all walkers hold a current First Aid training certificate.'
},
{
question: 'What happens if the weather is bad?',
answer:
"We operate in all weather conditions, except when there is a danger to the dog's health and safety."
} }
] ]
}, },
@@ -209,7 +218,7 @@ export const homepageContent: HomePageContent = {
{ label: 'Contact Us', href: '/contact-us' } { label: 'Contact Us', href: '/contact-us' }
], ],
contactLinks: [ contactLinks: [
{ label: 'Book a walk', href: '/contact-us' }, { label: 'Book a Meet & Greet', href: '/contact-us' },
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true }, { label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
{ label: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true } { label: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
], ],
+31 -2
View File
@@ -91,14 +91,43 @@ nav {
border-radius: 20px; border-radius: 20px;
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
gap: 6px; gap: 0;
box-shadow: box-shadow:
0 4px 6px rgba(0, 0, 0, 0.04), 0 4px 6px rgba(0, 0, 0, 0.04),
0 16px 40px rgba(0, 0, 0, 0.12); 0 16px 40px rgba(0, 0, 0, 0.12);
min-width: 400px; min-width: 400px;
} }
.mega-menu-services {
display: flex;
flex-direction: row;
gap: 6px;
}
.mega-menu-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(33, 48, 33, 0.04);
color: var(--green);
font-size: 13px;
font-weight: 700;
text-decoration: none;
transition: background 0.16s ease;
}
.mega-menu-footer:hover {
background: rgba(33, 48, 33, 0.09);
}
:global(.mega-menu-footer-arrow) {
font-size: 12px;
}
.has-mega:hover .mega-menu { .has-mega:hover .mega-menu {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
+7
View File
@@ -201,6 +201,13 @@
line-height: 1.08; line-height: 1.08;
} }
.hero-subtitle {
margin: -10px 0 22px;
max-width: none;
font-size: 15.5px;
line-height: 1.5;
}
.hero-heading-desktop { .hero-heading-desktop {
display: none; display: none;
} }
+25
View File
@@ -25,6 +25,14 @@ section {
padding-bottom: 44px; padding-bottom: 44px;
} }
.hero-subtitle {
margin: -8px 0 26px;
max-width: 460px;
color: rgba(255, 255, 255, 0.86);
font-size: 18px;
line-height: 1.55;
}
.hero-buttons { .hero-buttons {
display: flex; display: flex;
gap: 16px; gap: 16px;
@@ -301,10 +309,27 @@ section {
} }
.service-card-price {
display: inline-block;
margin: 14px 0 0;
padding: 6px 14px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
color: var(--green);
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.service-card a.btn { .service-card a.btn {
margin-top: 18px; margin-top: 18px;
} }
.service-card-price + a.btn {
margin-top: 14px;
}
#values, #values,
footer { footer {
background: var(--green); background: var(--green);
+4 -1
View File
@@ -29,12 +29,14 @@ export interface NavigationContent {
cta: CallToAction; cta: CallToAction;
instagram?: { href: string; external?: boolean }; instagram?: { href: string; external?: boolean };
megaMenuServices?: MegaMenuService[]; megaMenuServices?: MegaMenuService[];
megaMenuFooter?: LinkItem;
} }
export interface HeroContent { export interface HeroContent {
title: string; title: string;
highlight: string; highlight: string;
mobileTitle?: string; mobileTitle?: string;
subtitle?: string;
primaryCta: CallToAction; primaryCta: CallToAction;
secondaryCta: CallToAction; secondaryCta: CallToAction;
imageUrl: string; imageUrl: string;
@@ -49,7 +51,7 @@ export interface IntroContent {
export interface PromiseContent { export interface PromiseContent {
title: string; title: string;
subtitle: string; subtitle: string;
body: string; body: string[];
emphasis: string; emphasis: string;
cta: CallToAction; cta: CallToAction;
imageUrl: string; imageUrl: string;
@@ -62,6 +64,7 @@ export interface IconCard {
body: string; body: string;
href?: string; href?: string;
order?: number; order?: number;
priceFrom?: string;
} }
export interface TestimonialContent { export interface TestimonialContent {
+13 -1
View File
@@ -14,7 +14,19 @@ vi.mock('canvas-confetti', () => ({
})); }));
class MockIntersectionObserver { class MockIntersectionObserver {
observe() {} private callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
this.callback(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
unobserve() {} unobserve() {}
disconnect() {} disconnect() {}
} }