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);
}
function validateStepOne(): boolean {
function validateDogStep(): 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';
if (!petName.trim()) next.petName = "Please enter your dog's name";
if (!location.trim()) next.location = 'Please enter your location';
errors = next;
if (next.fullName) { fullNameInput?.focus(); return false; }
if (next.email) { emailInput?.focus(); return false; }
if (next.phone) { phoneInput?.focus(); return false; }
if (next.petName) { petNameInput?.focus(); return false; }
if (next.location) { locationInput?.focus(); return false; }
return true;
}
function goToDogStep() {
if (!validateStepOne()) return;
function goToOwnerStep() {
if (!validateDogStep()) return;
errors = {};
step = 2;
}
@@ -128,18 +123,23 @@
event.preventDefault();
if (step === 1) {
goToDogStep();
goToOwnerStep();
return;
}
const next: Record<string, string> = {};
if (!petName.trim()) next.petName = "Please enter your dog's name";
if (!location.trim()) next.location = 'Please enter your location';
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.petName) petNameInput?.focus();
else if (next.location) locationInput?.focus();
if (next.fullName) fullNameInput?.focus();
else if (next.email) emailInput?.focus();
else if (next.phone) phoneInput?.focus();
return;
}
@@ -212,17 +212,17 @@
on:click={() => (step = 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{ownerStepLabel}</span>
<span class="booking-step-label">{dogStepLabel}</span>
</button>
<span class="booking-step-divider" aria-hidden="true"></span>
<button
type="button"
class:active={step === 2}
class="booking-step"
on:click={goToDogStep}
on:click={goToOwnerStep}
>
<span class="booking-step-number">2</span>
<span class="booking-step-label">{dogStepLabel}</span>
<span class="booking-step-label">{ownerStepLabel}</span>
</button>
</div>
</div>
@@ -246,6 +246,107 @@
</div>
{#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">
{#if hasBanner}
<div class="booking-panel-banner">{booking.subtitle}</div>
@@ -330,107 +431,6 @@
</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 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, {
booking: homepageContent.booking
});
await fireEvent.click(container.querySelector('.booking-next-button')!);
expect(screen.getByText('Please enter your full name')).toBeInTheDocument();
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
expect(screen.getByText('Please enter your contact number')).toBeInTheDocument();
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
expect(screen.getByText('Please enter your location')).toBeInTheDocument();
});
it('validates the dog details step before submitting', async () => {
it('validates the owner details step before submitting', async () => {
const { container } = render(BookingSection, {
booking: homepageContent.booking
});
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
target: { value: 'Alex Walker' }
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
target: { value: 'Maya' }
});
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.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-next-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 location')).toBeInTheDocument();
expect(screen.getByText('Please enter your full name')).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 () => {
@@ -59,18 +56,6 @@ describe('BookingSection', () => {
await fireEvent.click(screen.getByLabelText('Pack Walks'));
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), {
target: { value: 'Maya' }
});
@@ -81,6 +66,18 @@ describe('BookingSection', () => {
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 waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
@@ -129,6 +126,14 @@ describe('BookingSection', () => {
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), {
target: { value: 'Alex Walker' }
});
@@ -138,14 +143,6 @@ describe('BookingSection', () => {
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), {
target: { value: 'Maya' }
});
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.click(container.querySelector('.booking-submit-button')!);
+26 -13
View File
@@ -90,22 +90,35 @@
{#if i === 0 && navigation.megaMenuServices?.length}
<div class="mega-menu">
<div class="mega-menu-inner">
{#each navigation.megaMenuServices as service}
<div class="mega-menu-services">
{#each navigation.megaMenuServices as service}
<a
href={service.href}
target={linkTarget(service.href.startsWith('http'))}
rel={linkRel(service.href.startsWith('http'))}
class="mega-service"
>
<div class="mega-icon">
<Icon name={service.icon} />
</div>
<span class="mega-service-label">{service.label}</span>
{#if service.description}
<span class="mega-service-desc">{service.description}</span>
{/if}
</a>
{/each}
</div>
{#if navigation.megaMenuFooter}
<a
href={service.href}
target={linkTarget(service.href.startsWith('http'))}
rel={linkRel(service.href.startsWith('http'))}
class="mega-service"
href={navigation.megaMenuFooter.href}
target={linkTarget(navigation.megaMenuFooter.external)}
rel={linkRel(navigation.megaMenuFooter.external)}
class="mega-menu-footer"
>
<div class="mega-icon">
<Icon name={service.icon} />
</div>
<span class="mega-service-label">{service.label}</span>
{#if service.description}
<span class="mega-service-desc">{service.description}</span>
{/if}
<span>{navigation.megaMenuFooter.label}</span>
<Icon name="fas fa-arrow-right" className="mega-menu-footer-arrow" />
</a>
{/each}
{/if}
</div>
</div>
{/if}
+4
View File
@@ -40,6 +40,10 @@
<span class="hero-heading-mobile">{mobileTitle}</span>
</h1>
{#if hero.subtitle}
<p class="hero-subtitle">{hero.subtitle}</p>
{/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>
+15 -37
View File
@@ -9,9 +9,7 @@
export let content: SiteSharedContent;
export let pageContent: PricingPageContent;
const promptDelayMs = 10000;
const promptIdleMs = 6000;
const minPromptDwellMs = 3500;
const scrollDepthThreshold = 0.65;
const desktopPromptMediaQuery = '(min-width: 769px)';
let showMeetGreetPrompt = false;
@@ -44,44 +42,28 @@
}
onMount(() => {
const startedAt = Date.now();
let lastInteractionAt = startedAt;
const desktopPromptQuery = window.matchMedia(desktopPromptMediaQuery);
canShowDesktopPrompt = desktopPromptQuery.matches;
const recordInteraction = () => {
lastInteractionAt = Date.now();
};
const handleDesktopPromptViewportChange = (event: MediaQueryListEvent) => {
canShowDesktopPrompt = event.matches;
};
const interactionEvents: Array<keyof WindowEventMap> = [
'pointerdown',
'mousemove',
'keydown',
'scroll',
'touchstart'
];
const handleScroll = () => {
if (promptShown || dismissMeetGreetPrompt || bookingInView || !canShowDesktopPrompt) {
return;
}
interactionEvents.forEach((eventName) => {
window.addEventListener(eventName, recordInteraction);
});
const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollableHeight <= 0) return;
const delayedPrompt = window.setTimeout(() => {
revealMeetGreetPrompt();
}, 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) {
const scrollPercent = window.scrollY / scrollableHeight;
if (scrollPercent >= scrollDepthThreshold) {
revealMeetGreetPrompt();
}
}, 1000);
};
window.addEventListener('scroll', handleScroll, { passive: true });
const bookingSection = document.getElementById('newlead');
const bookingObserver = bookingSection
@@ -100,11 +82,7 @@
desktopPromptQuery.addEventListener('change', handleDesktopPromptViewportChange);
return () => {
window.clearTimeout(delayedPrompt);
window.clearInterval(idlePromptCheck);
interactionEvents.forEach((eventName) => {
window.removeEventListener(eventName, recordInteraction);
});
window.removeEventListener('scroll', handleScroll);
desktopPromptQuery.removeEventListener('change', handleDesktopPromptViewportChange);
bookingObserver?.disconnect();
};
@@ -161,7 +139,7 @@
{/each}
</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>
{/each}
</div>
@@ -188,7 +166,7 @@
</p>
</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>
{/if}
</main>
+8 -4
View File
@@ -15,10 +15,14 @@
{promise.subtitle}
</h2>
<p>
{promise.body}
<strong>{promise.emphasis}</strong>
</p>
{#each promise.body as paragraph, idx}
<p>
{paragraph}
{#if idx === promise.body.length - 1}
<strong>{promise.emphasis}</strong>
{/if}
</p>
{/each}
<a href={promise.cta.href} class="btn btn-green">{promise.cta.label}</a>
</div>
+1 -1
View File
@@ -87,7 +87,7 @@
{/each}
</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>
{/each}
</div>
@@ -20,6 +20,10 @@
<h3>{service.title}</h3>
<p>{service.body}</p>
{#if service.priceFrom}
<p class="service-card-price">{service.priceFrom}</p>
{/if}
{#if service.href}
<a href={service.href} class="btn btn-green">Learn more</a>
{/if}
+37 -7
View File
@@ -47,6 +47,9 @@
let activeIndex = 0;
let paused = false;
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
$: slides = testimonials
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
@@ -73,13 +76,37 @@
}
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(() => {
if (!paused && slides.length > 1) {
if (!paused && !prefersReducedMotion && inView && slides.length > 1) {
showNext();
}
}, 5000);
}, 9000);
return () => window.clearInterval(interval);
return () => {
window.clearInterval(interval);
motionQuery.removeEventListener('change', onMotionChange);
observer?.disconnect();
};
});
</script>
@@ -96,11 +123,14 @@
{#if slides.length}
<div
bind:this={carouselEl}
class="testimonials-carousel"
role="region"
aria-label="Customer testimonials"
on:mouseenter={() => (paused = true)}
on:mouseleave={() => (paused = false)}
on:focusin={() => (paused = true)}
on:focusout={() => (paused = false)}
>
<button
class="testimonial-arrow testimonial-arrow-left"
@@ -140,7 +170,7 @@
<div class="testimonial-copy">
<span class="testimonial-quote-mark">"</span>
<h5>{testimonial.quote}</h5>
<blockquote class="testimonial-quote">{testimonial.quote}</blockquote>
<div class="testimonial-author">
<span class="testimonial-author-name">{testimonial.reviewer}</span>
<span class="testimonial-author-detail">{testimonial.detail}</span>
@@ -344,7 +374,7 @@
user-select: none;
}
.testimonial-copy h5 {
.testimonial-copy .testimonial-quote {
max-width: 500px;
margin: 0;
font-size: 17px;
@@ -500,7 +530,7 @@
padding: 96px 72px 64px 8px;
}
.testimonial-copy h5 {
.testimonial-copy .testimonial-quote {
max-width: 460px;
font-size: 17px;
}
@@ -554,7 +584,7 @@
margin-bottom: 8px;
}
.testimonial-copy h5 {
.testimonial-copy .testimonial-quote {
font-size: 16px;
line-height: 1.55;
}
@@ -59,7 +59,7 @@ describe('TestimonialsSection', () => {
await fireEvent.click(nextButton);
expect(getActiveReviewer(container)).toBe('Estelle');
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(9000);
expect(getActiveReviewer(container)).toBe('Ross');
});