SEO tweaks, design tweaks
This commit is contained in:
@@ -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" /> 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" /> 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" /> 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" /> 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" /> 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" /> 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" /> 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" /> 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">
|
||||
|
||||
@@ -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')!);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & Greet</a>
|
||||
</aside>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user