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');
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const aboutPageContent: AboutPageContent = {
|
||||
email: 'info@goodwalk.co.nz',
|
||||
phone: '(022) 642 1011',
|
||||
cta: {
|
||||
label: 'Contact us',
|
||||
label: 'Book a Meet & Greet',
|
||||
href: '/contact-us',
|
||||
variant: 'yellow'
|
||||
}
|
||||
|
||||
+30
-21
@@ -21,25 +21,27 @@ export const homepageContent: HomePageContent = {
|
||||
{ label: 'About Us', href: '/about' },
|
||||
{ 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 },
|
||||
megaMenuServices: [
|
||||
{ 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-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' }
|
||||
]
|
||||
],
|
||||
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
|
||||
},
|
||||
hero: {
|
||||
title: 'Unleashing Fun in',
|
||||
highlight: "Your Dog's Day!",
|
||||
mobileTitle: "Unleashing Fun in\nYour Dog's Day!",
|
||||
subtitle: 'Trusted, professional dog walking across Auckland Central — pack walks, 1:1 walks, and puppy visits.',
|
||||
primaryCta: { label: 'Learn more', href: '#services', variant: 'yellow' },
|
||||
secondaryCta: { label: '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',
|
||||
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
|
||||
},
|
||||
intro: {
|
||||
text: 'Goodwalk delivers trusted, professional dog walking services across Auckland Central.',
|
||||
text: 'Trusted by Auckland dog parents.',
|
||||
reviewCta: {
|
||||
label: 'All 5 star reviews on Google!',
|
||||
href: 'https://g.page/r/CUsvrWPhkYrAEB0/',
|
||||
@@ -49,10 +51,12 @@ export const homepageContent: HomePageContent = {
|
||||
promise: {
|
||||
title: 'Happy pets,',
|
||||
subtitle: 'happy humans',
|
||||
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',
|
||||
body: [
|
||||
'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?',
|
||||
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',
|
||||
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
|
||||
},
|
||||
@@ -61,18 +65,21 @@ export const homepageContent: HomePageContent = {
|
||||
icon: 'fas fa-dog',
|
||||
title: 'Pack Walks',
|
||||
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'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-person-walking',
|
||||
title: '1:1 Walks',
|
||||
body: "One-on-one walks tailored to your dog's individual pace, personality, and needs.",
|
||||
priceFrom: 'From $45 / walk',
|
||||
href: '/dog-walking'
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-house',
|
||||
title: 'Puppy Visits',
|
||||
body: 'In-home visits to check in on your puppy, play, and keep them company.',
|
||||
priceFrom: 'From $39 / visit',
|
||||
href: '/puppy-visits'
|
||||
}
|
||||
],
|
||||
@@ -150,9 +157,11 @@ export const homepageContent: HomePageContent = {
|
||||
booking: {
|
||||
title: "Let's meet!",
|
||||
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',
|
||||
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: {
|
||||
title: 'Locations & Hours',
|
||||
@@ -160,21 +169,11 @@ export const homepageContent: HomePageContent = {
|
||||
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.',
|
||||
nearbyText: 'Live in a nearby suburb?',
|
||||
nearbyCta: { label: 'Get in touch!', href: '#newlead' },
|
||||
nearbyCta: { label: 'Book a Meet & Greet', href: '#newlead' },
|
||||
hoursLabel: 'Opening Hours',
|
||||
hours: 'Monday to Friday, 8am - 4pm.',
|
||||
faqTitle: "FAQ's",
|
||||
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?',
|
||||
answer:
|
||||
@@ -184,10 +183,20 @@ export const homepageContent: HomePageContent = {
|
||||
question: 'How does payment work?',
|
||||
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?',
|
||||
answer:
|
||||
'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' }
|
||||
],
|
||||
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: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
||||
],
|
||||
|
||||
@@ -91,14 +91,43 @@ nav {
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.04),
|
||||
0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
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 {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
@@ -201,6 +201,13 @@
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: -10px 0 22px;
|
||||
max-width: none;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero-heading-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ section {
|
||||
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 {
|
||||
display: flex;
|
||||
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 {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.service-card-price + a.btn {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
#values,
|
||||
footer {
|
||||
background: var(--green);
|
||||
|
||||
+4
-1
@@ -29,12 +29,14 @@ export interface NavigationContent {
|
||||
cta: CallToAction;
|
||||
instagram?: { href: string; external?: boolean };
|
||||
megaMenuServices?: MegaMenuService[];
|
||||
megaMenuFooter?: LinkItem;
|
||||
}
|
||||
|
||||
export interface HeroContent {
|
||||
title: string;
|
||||
highlight: string;
|
||||
mobileTitle?: string;
|
||||
subtitle?: string;
|
||||
primaryCta: CallToAction;
|
||||
secondaryCta: CallToAction;
|
||||
imageUrl: string;
|
||||
@@ -49,7 +51,7 @@ export interface IntroContent {
|
||||
export interface PromiseContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body: string;
|
||||
body: string[];
|
||||
emphasis: string;
|
||||
cta: CallToAction;
|
||||
imageUrl: string;
|
||||
@@ -62,6 +64,7 @@ export interface IconCard {
|
||||
body: string;
|
||||
href?: string;
|
||||
order?: number;
|
||||
priceFrom?: string;
|
||||
}
|
||||
|
||||
export interface TestimonialContent {
|
||||
|
||||
Reference in New Issue
Block a user