Initial commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: AboutPageContent;
|
||||
</script>
|
||||
|
||||
<main class="about-page">
|
||||
<section class="about-hero">
|
||||
<div class="about-inner">
|
||||
<h1>{pageContent.title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#each pageContent.sections as section}
|
||||
<section
|
||||
use:reveal
|
||||
class:about-section-gradient={section.accent === 'gradient'}
|
||||
class="about-section reveal-block"
|
||||
>
|
||||
<div class:about-section-reverse={section.reverse} class="about-inner about-section-grid">
|
||||
<div class="about-copy">
|
||||
<h2>{section.title}</h2>
|
||||
{#each section.body as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="about-media">
|
||||
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<section use:reveal={{ delay: 40 }} class="about-services reveal-block">
|
||||
<div class="about-inner">
|
||||
<div class="about-section-heading">
|
||||
<h2>{pageContent.servicesTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div class="about-service-grid">
|
||||
{#each content.services as service}
|
||||
<a class="about-service-card" href={service.href}>
|
||||
<div class="about-service-icon" aria-hidden="true">
|
||||
<Icon name={service.icon} />
|
||||
</div>
|
||||
<span>{service.title}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section use:reveal={{ delay: 70 }} class="about-contact reveal-block">
|
||||
<div class="about-inner">
|
||||
<div class="about-contact-card">
|
||||
<h2>{pageContent.contact.title}</h2>
|
||||
<div class="about-contact-grid">
|
||||
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
|
||||
{pageContent.contact.email}
|
||||
</a>
|
||||
<a class="btn btn-yellow" href={pageContent.contact.cta.href}>
|
||||
{pageContent.contact.cta.label}
|
||||
</a>
|
||||
<a class="about-contact-link" href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}>
|
||||
{pageContent.contact.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.about-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.about-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
padding: 72px 0 40px;
|
||||
}
|
||||
|
||||
.about-hero h1,
|
||||
.about-section-heading h2,
|
||||
.about-copy h2,
|
||||
.about-contact-card h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.about-hero h1,
|
||||
.about-section-heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.about-section-gradient {
|
||||
margin: 0 24px 88px;
|
||||
padding: 40px 0;
|
||||
border-radius: 36px;
|
||||
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
|
||||
}
|
||||
|
||||
.about-section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr);
|
||||
gap: 44px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-section-reverse {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
|
||||
}
|
||||
|
||||
.about-section-reverse .about-copy {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.about-section-reverse .about-media {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.about-copy h2 {
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
}
|
||||
|
||||
.about-copy p {
|
||||
margin: 18px 0 0;
|
||||
color: #34363a;
|
||||
font-size: 17px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.about-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.about-services {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.about-section-heading {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.about-section-heading h2 {
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
}
|
||||
|
||||
.about-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.about-service-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
color: #000;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.about-service-card:hover {
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 20px 40px rgba(17, 20, 24, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
.about-service-card:active {
|
||||
transform: translateY(-1px) scale(0.992);
|
||||
}
|
||||
|
||||
.about-service-icon {
|
||||
font-size: 42px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.about-service-card span {
|
||||
font-family: var(--font-head);
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.about-contact {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.about-contact-card {
|
||||
border-radius: 36px;
|
||||
background: #fff;
|
||||
padding: 42px 48px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-contact-card h2 {
|
||||
font-size: clamp(28px, 3vw, 42px);
|
||||
}
|
||||
|
||||
.about-contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.about-contact-link {
|
||||
color: #34363a;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.about-section-grid,
|
||||
.about-section-reverse {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-section-reverse .about-copy,
|
||||
.about-section-reverse .about-media {
|
||||
order: initial;
|
||||
}
|
||||
|
||||
.about-service-grid,
|
||||
.about-contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
padding: 56px 0 24px;
|
||||
}
|
||||
|
||||
.about-section,
|
||||
.about-services,
|
||||
.about-contact {
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.about-section-gradient {
|
||||
margin: 0 12px 64px;
|
||||
padding: 28px 0;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.about-section-grid {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.about-copy h2,
|
||||
.about-section-heading h2,
|
||||
.about-contact-card h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.about-copy p {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.about-service-card {
|
||||
min-height: 168px;
|
||||
}
|
||||
|
||||
.about-contact-card {
|
||||
padding: 30px 24px;
|
||||
}
|
||||
|
||||
.about-contact-grid {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.about-contact-link {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
|
||||
const email = 'info@goodwalk.co.nz';
|
||||
const phone = '(022) 642 1011';
|
||||
</script>
|
||||
|
||||
<main class="booking-page">
|
||||
<section class="booking-page-hero">
|
||||
<div class="booking-page-inner">
|
||||
<h1>Book a Meet & Greet</h1>
|
||||
<p class="booking-page-sub">Fill in the form below and we'll be in touch to arrange a free introduction.</p>
|
||||
<div class="booking-page-contact">
|
||||
<a href="mailto:{email}" class="booking-contact-link">
|
||||
<Icon name="fas fa-envelope" />
|
||||
{email}
|
||||
</a>
|
||||
<a href="tel:{phone.replace(/[^0-9+]/g, '')}" class="booking-contact-link">
|
||||
<Icon name="fas fa-phone" />
|
||||
{phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BookingSection {booking} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.booking-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.booking-page-hero {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
padding: 64px 0 72px;
|
||||
}
|
||||
|
||||
.booking-page-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.booking-page-hero h1 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(32px, 4vw, 52px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.booking-page-sub {
|
||||
margin: 0 auto 32px;
|
||||
max-width: 480px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.booking-page-contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.booking-contact-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.booking-contact-link:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.booking-page-hero {
|
||||
padding: 48px 0 56px;
|
||||
}
|
||||
|
||||
.booking-page-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.booking-page-contact {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.booking-contact-link {
|
||||
font-size: 13px;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import SuccessModal from '$lib/components/SuccessModal.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
|
||||
let step = 1;
|
||||
$: headingParts = splitBookingTitle(booking.title);
|
||||
|
||||
let fullName = '';
|
||||
let email = '';
|
||||
let phone = '';
|
||||
let petName = '';
|
||||
let location = '';
|
||||
let message = '';
|
||||
let selectedServices: string[] = [];
|
||||
|
||||
let fullNameInput: HTMLInputElement;
|
||||
let emailInput: HTMLInputElement;
|
||||
let phoneInput: HTMLInputElement;
|
||||
let petNameInput: HTMLInputElement;
|
||||
let locationInput: HTMLInputElement;
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
let submitting = false;
|
||||
let submitted = false;
|
||||
let submitError = '';
|
||||
|
||||
const defaultDogIntro =
|
||||
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.';
|
||||
|
||||
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
|
||||
$: hasBanner = Boolean(booking.subtitle?.trim());
|
||||
$: hasServices = booking.serviceOptions.length > 0;
|
||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
|
||||
function splitBookingTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
const lastSpace = trimmed.lastIndexOf(' ');
|
||||
|
||||
if (lastSpace === -1) {
|
||||
return { plain: trimmed, highlight: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
plain: trimmed.slice(0, lastSpace),
|
||||
highlight: trimmed.slice(lastSpace + 1)
|
||||
};
|
||||
}
|
||||
|
||||
function clearError(field: string) {
|
||||
if (errors[field]) {
|
||||
errors = { ...errors, [field]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleService(service: string, checked: boolean) {
|
||||
if (checked) {
|
||||
selectedServices = [...selectedServices, service];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedServices = selectedServices.filter((item) => item !== service);
|
||||
}
|
||||
|
||||
function validateStepOne(): boolean {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
if (!fullName.trim()) next.fullName = 'Please enter your full name';
|
||||
if (!email.trim() || !emailInput?.checkValidity()) next.email = 'Please enter a valid email address';
|
||||
if (!phone.trim()) next.phone = 'Please enter your contact number';
|
||||
|
||||
errors = next;
|
||||
|
||||
if (next.fullName) { fullNameInput?.focus(); return false; }
|
||||
if (next.email) { emailInput?.focus(); return false; }
|
||||
if (next.phone) { phoneInput?.focus(); return false; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function goToDogStep() {
|
||||
if (!validateStepOne()) return;
|
||||
errors = {};
|
||||
step = 2;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (step === 1) {
|
||||
goToDogStep();
|
||||
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 (Object.keys(next).length > 0) {
|
||||
errors = next;
|
||||
if (next.petName) petNameInput?.focus();
|
||||
else if (next.location) locationInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
submitError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fullName, email, phone, petName, location, message,
|
||||
services: selectedServices,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail ?? 'Something went wrong. Please try again.');
|
||||
}
|
||||
|
||||
submitted = true;
|
||||
} catch (err: unknown) {
|
||||
submitError = err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="reservation" use:reveal={{ delay: 70 }} class="reveal-block">
|
||||
<div class="form-inner">
|
||||
|
||||
{#if submitted}
|
||||
<SuccessModal
|
||||
firstName={fullName.split(' ')[0]}
|
||||
{petName}
|
||||
{email}
|
||||
onClose={() => (submitted = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="booking-header">
|
||||
<h2 class="booking-title">
|
||||
<span class="booking-title-plain">{headingParts.plain}</span>
|
||||
<span class="booking-title-highlight">{headingParts.highlight}</span>
|
||||
</h2>
|
||||
|
||||
<div class="booking-stepper" aria-label="Booking form steps">
|
||||
<button
|
||||
type="button"
|
||||
class:active={step === 1}
|
||||
class="booking-step"
|
||||
on:click={() => (step = 1)}
|
||||
>
|
||||
<span class="booking-step-number">1</span>
|
||||
<span class="booking-step-label">{ownerStepLabel}</span>
|
||||
</button>
|
||||
<span class="booking-step-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class:active={step === 2}
|
||||
class="booking-step"
|
||||
on:click={goToDogStep}
|
||||
>
|
||||
<span class="booking-step-number">2</span>
|
||||
<span class="booking-step-label">{dogStepLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="booking-form"
|
||||
id="bookingForm"
|
||||
novalidate
|
||||
on:submit={handleSubmit}
|
||||
>
|
||||
{#if step === 1}
|
||||
<div class="booking-panel">
|
||||
{#if hasBanner}
|
||||
<div class="booking-panel-banner">{booking.subtitle}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={hasBanner} class="booking-card-grid booking-card-grid-owner">
|
||||
<div class="booking-field-card booking-field-card-group booking-field-card-full">
|
||||
<div class="booking-field-group booking-field-group-owner">
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
|
||||
<label for="fullName">
|
||||
<Icon name="fas fa-user" /> Full Name <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={fullNameInput}
|
||||
bind:value={fullName}
|
||||
type="text"
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
required
|
||||
placeholder="Enter full name"
|
||||
class:input-invalid={errors.fullName}
|
||||
on:input={() => clearError('fullName')}
|
||||
/>
|
||||
{#if errors.fullName}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.fullName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.email}>
|
||||
<label for="email">
|
||||
<Icon name="fas fa-envelope" /> Email <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={emailInput}
|
||||
bind:value={email}
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="Email"
|
||||
class:input-invalid={errors.email}
|
||||
on:input={() => clearError('email')}
|
||||
/>
|
||||
{#if errors.email}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.phone}>
|
||||
<label for="phone">
|
||||
<Icon name="fas fa-phone" /> Contact # <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={phoneInput}
|
||||
bind:value={phone}
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
placeholder="E.g. 021 1234567"
|
||||
class:input-invalid={errors.phone}
|
||||
on:input={() => clearError('phone')}
|
||||
/>
|
||||
{#if errors.phone}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.phone}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-outline-green"
|
||||
on:click={() => { step = 1; errors = {}; }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" class="btn btn-yellow booking-submit-button" disabled={submitting}>
|
||||
{#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if submitError}
|
||||
<p class="booking-submit-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{submitError}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.booking-submit-error {
|
||||
margin: 16px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #c0392b;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import BookingSection from './BookingSection.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
describe('BookingSection', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document, 'referrer', {
|
||||
configurable: true,
|
||||
value: 'https://www.google.com/'
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the owner 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();
|
||||
});
|
||||
|
||||
it('validates the dog 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(/^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.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your location')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the completed booking flow and shows the success modal', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
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' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Kingsland' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
|
||||
target: { value: 'Loves small group walks.' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/submit',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
petName: 'Maya',
|
||||
location: 'Kingsland',
|
||||
message: 'Loves small group walks.',
|
||||
services: ['Pack Walks', 'Other Services'],
|
||||
referrer: 'https://www.google.com/'
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: /Booking confirmed/i })).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the API error message when submission fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: vi.fn().mockResolvedValue({ detail: 'Mail API unavailable' })
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
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' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Kingsland' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
expect(await screen.findByText('Mail API unavailable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { FooterContent, LinkItem } from '$lib/types';
|
||||
|
||||
export let footer: FooterContent;
|
||||
|
||||
const socialLinks: LinkItem[] = [
|
||||
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
|
||||
{ label: 'Google', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
||||
];
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img
|
||||
src="/images/goodwalk-auckland-dog-walking-logo.png"
|
||||
alt="Goodwalk – Auckland dog walking service logo"
|
||||
class="footer-logo"
|
||||
height="28"
|
||||
/>
|
||||
<p>{footer.brandText}</p>
|
||||
<div class="social-links">
|
||||
<a href={socialLinks[0].href} target="_blank" rel="noopener" aria-label="Instagram">
|
||||
<Icon name="fab fa-instagram" />
|
||||
</a>
|
||||
<a href={socialLinks[1].href} target="_blank" rel="noopener" aria-label="Facebook">
|
||||
<Icon name="fab fa-facebook-f" />
|
||||
</a>
|
||||
<a href={socialLinks[2].href} target="_blank" rel="noopener" aria-label="Google">
|
||||
<Icon name="fab fa-google" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-explore">
|
||||
<p class="footer-col-label">Explore</p>
|
||||
<ul class="footer-nav">
|
||||
{#each footer.navigationLinks as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noopener' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-action">
|
||||
<p class="footer-col-label">Get Started</p>
|
||||
<a href="/booking" class="footer-book-btn">
|
||||
Book a Meet & Greet
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
<p class="footer-book-note">Free, no-obligation introduction</p>
|
||||
<a
|
||||
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="footer-reviews"
|
||||
>
|
||||
<Icon name="fab fa-google" />
|
||||
<span>See our 5★ Google reviews</span>
|
||||
</a>
|
||||
|
||||
{#if footer.email || footer.phone}
|
||||
<div class="footer-contact">
|
||||
{#if footer.email}
|
||||
<a href="mailto:{footer.email}" class="footer-contact-link">
|
||||
<Icon name="fas fa-envelope" />
|
||||
{footer.email}
|
||||
</a>
|
||||
{/if}
|
||||
{#if footer.phone}
|
||||
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
|
||||
<Icon name="fas fa-phone" />
|
||||
{footer.phone}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<span>{footer.copyright}</span>
|
||||
<nav class="footer-legal">
|
||||
<a href="/terms-and-conditions">Terms & Conditions</a>
|
||||
<a href="/privacy-policy">Privacy Policy</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { NavigationContent } from '$lib/types';
|
||||
|
||||
export let navigation: NavigationContent;
|
||||
|
||||
let mobileMenuOpen = false;
|
||||
const mobilePhoneDisplay = '(022) 642 1011';
|
||||
const mobilePhoneHref = '+64226421011';
|
||||
|
||||
function closeMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function linkTarget(external?: boolean) {
|
||||
return external ? '_blank' : undefined;
|
||||
}
|
||||
|
||||
function linkRel(external?: boolean) {
|
||||
return external ? 'noopener' : undefined;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path || path === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const cleaned = path.split('#')[0].split('?')[0];
|
||||
return cleaned.endsWith('/') ? cleaned.slice(0, -1) : cleaned;
|
||||
}
|
||||
|
||||
function isServicesActive() {
|
||||
const pathname = normalizePath($page.url.pathname);
|
||||
return pathname === '/pack-walks' || pathname === '/dog-walking' || pathname === '/puppy-visits';
|
||||
}
|
||||
|
||||
function isActiveLink(href: string, isServicesLink = false) {
|
||||
if (!href || href.startsWith('http')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isServicesLink) {
|
||||
return isServicesActive();
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return $page.url.pathname === '/' && $page.url.hash === href;
|
||||
}
|
||||
|
||||
return normalizePath($page.url.pathname) === normalizePath(href);
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (window.innerWidth > 768) {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
handleViewportChange();
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
|
||||
return () => window.removeEventListener('resize', handleViewportChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<ul class="nav-links">
|
||||
{#each navigation.desktopLinks as link, i}
|
||||
<li class:has-mega={i === 0 && navigation.megaMenuServices?.length}>
|
||||
<a
|
||||
href={link.href}
|
||||
target={linkTarget(link.external)}
|
||||
rel={linkRel(link.external)}
|
||||
class:nav-link-active={isActiveLink(link.href, i === 0 && Boolean(navigation.megaMenuServices?.length))}
|
||||
>
|
||||
{link.label}
|
||||
{#if i === 0 && navigation.megaMenuServices?.length}
|
||||
<Icon name="fas fa-chevron-down" className="mega-chevron" />
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if i === 0 && navigation.megaMenuServices?.length}
|
||||
<div class="mega-menu">
|
||||
<div class="mega-menu-inner">
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<a href="/" class="logo" aria-label="Goodwalk – Auckland Dog Walking, home">
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width: 768px)"
|
||||
srcset="/images/goodwalk-auckland-dog-walking-logo-mobile.png"
|
||||
/>
|
||||
<img src="/images/goodwalk-auckland-dog-walking-logo.png" alt="Goodwalk – Auckland dog walking service logo" height="21" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<a href={`tel:${mobilePhoneHref}`} class="mobile-phone" aria-label={`Call Goodwalk on ${mobilePhoneDisplay}`}>
|
||||
<Icon name="fas fa-phone" />
|
||||
<span>{mobilePhoneDisplay}</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-right">
|
||||
{#if navigation.instagram}
|
||||
<a
|
||||
href={navigation.instagram.href}
|
||||
target={linkTarget(navigation.instagram.external)}
|
||||
rel={linkRel(navigation.instagram.external)}
|
||||
class="instagram-icon"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<Icon name="fab fa-instagram" />
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href={navigation.cta.href}
|
||||
class="btn btn-yellow"
|
||||
>{navigation.cta.label}</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="hamburger"
|
||||
type="button"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label="Toggle menu"
|
||||
on:click={toggleMenu}
|
||||
>
|
||||
<Icon name={mobileMenuOpen ? 'fas fa-xmark' : 'fas fa-bars'} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class:open={mobileMenuOpen} class="mobile-menu" id="mobile-menu">
|
||||
{#each navigation.mobileLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
target={linkTarget(link.external)}
|
||||
rel={linkRel(link.external)}
|
||||
class:mobile-link-active={isActiveLink(link.href)}
|
||||
on:click={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Header from './Header.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import { setMockPage } from '../../test/mocks/app-stores';
|
||||
|
||||
describe('Header', () => {
|
||||
it('marks the services link active for service detail pages', () => {
|
||||
setMockPage('https://www.goodwalk.co.nz/pack-walks');
|
||||
|
||||
const { container } = render(Header, {
|
||||
navigation: homepageContent.navigation
|
||||
});
|
||||
|
||||
expect(container.querySelector('a.nav-link-active[href="#services"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens and closes the mobile menu', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 390
|
||||
});
|
||||
|
||||
const { container } = render(Header, {
|
||||
navigation: homepageContent.navigation
|
||||
});
|
||||
|
||||
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
|
||||
const mobileMenu = container.querySelector('.mobile-menu') as HTMLDivElement;
|
||||
const firstMobileLink = mobileMenu.querySelector('a') as HTMLAnchorElement;
|
||||
|
||||
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await fireEvent.click(menuToggle);
|
||||
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(mobileMenu.classList.contains('open')).toBe(true);
|
||||
|
||||
await fireEvent.click(firstMobileLink);
|
||||
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(mobileMenu.classList.contains('open')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { HeroContent } from '$lib/types';
|
||||
|
||||
export let hero: HeroContent;
|
||||
|
||||
$: titleParts = splitTitle(hero.title);
|
||||
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
|
||||
|
||||
function splitTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
|
||||
if (trimmed.toLowerCase().endsWith(' in')) {
|
||||
return {
|
||||
lead: trimmed.slice(0, -3),
|
||||
connector: 'in'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lead: trimmed,
|
||||
connector: ''
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-heading">
|
||||
<span class="hero-heading-desktop">
|
||||
<span class="hero-title-main">{titleParts.lead}</span>
|
||||
{#if titleParts.connector}
|
||||
<span class="hero-title-connector"> {titleParts.connector}</span>
|
||||
{/if}
|
||||
<br />
|
||||
<span class="hero-title-highlight">{hero.highlight}</span>
|
||||
</span>
|
||||
<span class="hero-heading-mobile">{mobileTitle}</span>
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-img">
|
||||
<img
|
||||
src={hero.imageUrl}
|
||||
alt={hero.imageAlt}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let className = '';
|
||||
export let title: string | undefined = undefined;
|
||||
|
||||
$: classes = `${name} icon ${className}`.trim();
|
||||
</script>
|
||||
|
||||
<i class={classes} aria-hidden={title ? undefined : 'true'} title={title}></i>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
line-height: 1;
|
||||
font-style: normal;
|
||||
text-rendering: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { InfoContent } from '$lib/types';
|
||||
|
||||
export let info: InfoContent;
|
||||
</script>
|
||||
|
||||
<section id="info">
|
||||
<div class="info-inner">
|
||||
<div class="info-block">
|
||||
<h2><Icon name="fas fa-location-dot" /> {info.title}</h2>
|
||||
<p>{info.intro}</p>
|
||||
<p class="info-copy">{info.suburbs}</p>
|
||||
<p class="info-copy">
|
||||
{info.nearbyText}
|
||||
<a href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
|
||||
</p>
|
||||
<h3>{info.hoursLabel}</h3>
|
||||
<p>{info.hours}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
|
||||
<div class="faq">
|
||||
{#each info.faqs as faq}
|
||||
<details>
|
||||
<summary>{faq.question}</summary>
|
||||
<p>{faq.answer}</p>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { HomePageContent } from '$lib/types';
|
||||
|
||||
export let instagram: HomePageContent['instagram'];
|
||||
</script>
|
||||
|
||||
<section id="instagram">
|
||||
<h2>{instagram.title}</h2>
|
||||
<p class="instagram-blurb">See our dogs in action — walks, play, and happy pups</p>
|
||||
<a href={instagram.href} target="_blank" rel="noopener" class="btn btn-green">
|
||||
<Icon name="fab fa-instagram" />
|
||||
{instagram.label}
|
||||
</a>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IntroContent } from '$lib/types';
|
||||
|
||||
export let intro: IntroContent;
|
||||
|
||||
const stars = Array.from({ length: 5 });
|
||||
</script>
|
||||
|
||||
<div id="intro">
|
||||
<div class="intro-trust-badge">
|
||||
<div class="intro-trust-mark" aria-hidden="true">
|
||||
<Icon name="fab fa-google" />
|
||||
</div>
|
||||
|
||||
<div class="intro-trust-copy">
|
||||
<p>{intro.text}</p>
|
||||
|
||||
<div class="intro-trust-meta">
|
||||
<div class="intro-trust-stars" aria-label="5 star rating">
|
||||
{#each stars as _, index}
|
||||
<Icon name="fas fa-star" className={`intro-star intro-star-${index + 1}`} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a href={intro.reviewCta.href} target="_blank" rel="noopener">
|
||||
{intro.reviewCta.label}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import type { LegalPageBlock, LegalPageContent } from '$lib/types';
|
||||
|
||||
export let pageContent: LegalPageContent;
|
||||
|
||||
function isParagraph(block: LegalPageBlock): boolean {
|
||||
return block.type === 'paragraph';
|
||||
}
|
||||
|
||||
function getParagraphContent(block: LegalPageBlock): string {
|
||||
return typeof block.content === 'string' ? block.content : block.content.join(' ');
|
||||
}
|
||||
|
||||
function getListItems(block: LegalPageBlock): string[] {
|
||||
return Array.isArray(block.content) ? block.content : [block.content];
|
||||
}
|
||||
|
||||
function isSubItem(item: string): boolean {
|
||||
return /^\([a-z]\)/.test(item.trimStart());
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="legal-page">
|
||||
<section class="legal-hero">
|
||||
<div class="legal-inner">
|
||||
<h1>{pageContent.title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="legal-body">
|
||||
<div class="legal-inner">
|
||||
<div class="legal-card">
|
||||
{#each pageContent.sections as section}
|
||||
<section class="legal-section">
|
||||
<h2>{section.title}</h2>
|
||||
|
||||
{#each section.blocks as block}
|
||||
{#if isParagraph(block)}
|
||||
<p>{getParagraphContent(block)}</p>
|
||||
{:else}
|
||||
<ul class="legal-list">
|
||||
{#each getListItems(block) as item}
|
||||
<li class:sub-item={isSubItem(item)}>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.legal-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.legal-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.legal-hero {
|
||||
padding: 72px 0 28px;
|
||||
}
|
||||
|
||||
.legal-hero h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.legal-body {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.legal-card {
|
||||
padding: 40px 44px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
|
||||
.legal-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.legal-section + .legal-section {
|
||||
margin-top: 34px;
|
||||
padding-top: 34px;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 14px;
|
||||
border-left: 3px solid var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(14px, 1.4vw, 17px);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.legal-section p {
|
||||
margin: 16px 0 0;
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.legal-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.legal-list + .legal-list,
|
||||
.legal-section p + .legal-list,
|
||||
.legal-list + p {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.legal-list li {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.legal-list li::before {
|
||||
content: '–';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--green);
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.legal-list li.sub-item {
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.legal-list li + li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.legal-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.legal-hero {
|
||||
padding: 56px 0 20px;
|
||||
}
|
||||
|
||||
.legal-hero h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.legal-body {
|
||||
padding: 0 0 64px;
|
||||
}
|
||||
|
||||
.legal-card {
|
||||
padding: 28px 22px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.legal-section + .legal-section {
|
||||
margin-top: 26px;
|
||||
padding-top: 26px;
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
||||
import type { PricingPageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: PricingPageContent;
|
||||
</script>
|
||||
|
||||
<main class="pricing-page">
|
||||
<section class="pricing-page-hero">
|
||||
<div class="pricing-inner">
|
||||
<h1>{pageContent.title}</h1>
|
||||
{#if pageContent.subtitle}
|
||||
<p class="pricing-page-sub">{pageContent.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#each pageContent.sections as section}
|
||||
<section use:reveal class="pricing-section reveal-block">
|
||||
<div class="pricing-inner">
|
||||
<div class="pricing-section-heading">
|
||||
{#if section.icon}
|
||||
<div class="pricing-section-icon">
|
||||
<Icon name={section.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<h2>{section.title}</h2>
|
||||
{#if section.blurb}
|
||||
<p class="pricing-section-blurb">{section.blurb}</p>
|
||||
{/if}
|
||||
{#if section.detailCta}
|
||||
<a
|
||||
class={`btn pricing-section-link ${section.detailCta.variant === 'yellow' ? 'btn-yellow' : section.detailCta.variant === 'outline' ? 'btn-outline' : 'btn-green'}`}
|
||||
href={section.detailCta.href}
|
||||
>
|
||||
{section.detailCta.label}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class:pricing-plan-grid-three={section.plans.length === 3} class="pricing-plan-grid">
|
||||
{#each section.plans as plan}
|
||||
<article class:pricing-plan-popular={plan.popular} class="pricing-plan-card">
|
||||
{#if plan.popular}
|
||||
<span class="pricing-plan-ribbon">Popular</span>
|
||||
{/if}
|
||||
|
||||
<h3>{plan.title}</h3>
|
||||
<div class="pricing-plan-price">{plan.price}</div>
|
||||
<p class="pricing-plan-period">{plan.period}</p>
|
||||
|
||||
<ul class="pricing-plan-features">
|
||||
{#each plan.features as feature}
|
||||
<li>{feature}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<a class="btn btn-yellow pricing-plan-cta" href="#reservation">Book Now</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
|
||||
<BookingSection booking={pageContent.booking} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.pricing-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.pricing-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.pricing-page-hero {
|
||||
background: var(--green);
|
||||
padding: 56px 0 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pricing-page-hero h1 {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pricing-page-sub {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.pricing-section-heading h2 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(24px, 2.8vw, 36px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pricing-section {
|
||||
padding: 48px 0 72px;
|
||||
}
|
||||
|
||||
.pricing-section-heading {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pricing-section-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.pricing-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.pricing-section-blurb {
|
||||
max-width: 680px;
|
||||
margin: 14px auto 0;
|
||||
color: #4c5056;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.pricing-section-link {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pricing-plan-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pricing-plan-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 30px 26px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.pricing-plan-popular {
|
||||
border: 2px solid var(--yellow);
|
||||
}
|
||||
|
||||
.pricing-plan-ribbon {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pricing-plan-card h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pricing-plan-price {
|
||||
margin-top: 22px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 52px;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pricing-plan-period {
|
||||
margin: 10px 0 0;
|
||||
color: #5e6167;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pricing-plan-features {
|
||||
width: 100%;
|
||||
margin: 24px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pricing-plan-features li {
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pricing-plan-cta {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.pricing-plan-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-plan-card:active {
|
||||
transform: translateY(-2px) scale(0.992);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.pricing-plan-grid,
|
||||
.pricing-plan-grid-three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pricing-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.pricing-page-hero {
|
||||
padding: 56px 0 20px;
|
||||
}
|
||||
|
||||
.pricing-page-hero h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.pricing-section-heading h2 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.pricing-section {
|
||||
padding: 30px 0 52px;
|
||||
}
|
||||
|
||||
.pricing-section-heading {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pricing-section-blurb {
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.pricing-plan-grid,
|
||||
.pricing-plan-grid-three {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pricing-plan-card {
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.pricing-plan-price {
|
||||
font-size: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { PromiseContent } from '$lib/types';
|
||||
|
||||
export let promise: PromiseContent;
|
||||
</script>
|
||||
|
||||
<section id="promise">
|
||||
<div class="promise-inner">
|
||||
<div class="promise-text">
|
||||
<h2>
|
||||
{promise.title}<br />
|
||||
{promise.subtitle}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{promise.body}
|
||||
<strong>{promise.emphasis}</strong>
|
||||
</p>
|
||||
|
||||
<a href={promise.cta.href} class="btn btn-green">{promise.cta.label}</a>
|
||||
</div>
|
||||
|
||||
<div class="promise-img">
|
||||
<img src={promise.imageUrl} alt={promise.imageAlt} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let description: string;
|
||||
export let canonicalPath: string;
|
||||
export let image = '/images/auckland-dog-walking-happy-dog-hero.png';
|
||||
export let imageAlt = 'Goodwalk Auckland dog walking services';
|
||||
export let type = 'website';
|
||||
export let structuredData: Record<string, unknown>[] = [];
|
||||
export let noindex = false;
|
||||
export let preloadImage = false;
|
||||
|
||||
const siteName = 'Goodwalk';
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
|
||||
function absoluteUrl(value: string) {
|
||||
if (!value) {
|
||||
return siteUrl;
|
||||
}
|
||||
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
}
|
||||
|
||||
function fullTitle(value: string) {
|
||||
return value.includes(siteName) ? value : `${value} | ${siteName}`;
|
||||
}
|
||||
|
||||
$: pageTitle = fullTitle(title);
|
||||
$: canonicalUrl = absoluteUrl(canonicalPath);
|
||||
$: imageUrl = absoluteUrl(image);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta
|
||||
name="robots"
|
||||
content={noindex
|
||||
? 'noindex, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'
|
||||
: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'}
|
||||
/>
|
||||
<meta name="author" content="Goodwalk" />
|
||||
<meta name="publisher" content="Goodwalk" />
|
||||
<meta name="geo.region" content="NZ-AUK" />
|
||||
<meta name="geo.placename" content="Auckland Central" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
{#if preloadImage}
|
||||
<link rel="preload" as="image" href={imageUrl} />
|
||||
{/if}
|
||||
<link rel="alternate" hreflang="en-NZ" href={canonicalUrl} />
|
||||
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:locale" content="en_NZ" />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:secure_url" content={imageUrl} />
|
||||
<meta property="og:image:alt" content={imageAlt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="twitter:image:alt" content={imageAlt} />
|
||||
|
||||
{#each structuredData as schema}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(schema)}</script>`}
|
||||
{/each}
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
||||
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: ServicePageContent;
|
||||
</script>
|
||||
|
||||
<main class="service-page">
|
||||
<section class="service-hero">
|
||||
<div class="service-inner service-hero-grid">
|
||||
<div class="service-hero-copy">
|
||||
<p class="service-eyebrow">{pageContent.hero.eyebrow}</p>
|
||||
<h1>{pageContent.hero.title}</h1>
|
||||
|
||||
{#each pageContent.hero.paragraphs as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="service-hero-media">
|
||||
<img
|
||||
src={pageContent.hero.imageUrl}
|
||||
alt={pageContent.hero.imageAlt}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if pageContent.highlight}
|
||||
<section use:reveal class="service-highlight reveal-block">
|
||||
<div class="service-inner service-highlight-copy">
|
||||
<p class="service-highlight-eyebrow">{pageContent.highlight.eyebrow}</p>
|
||||
<h2>{pageContent.highlight.title}</h2>
|
||||
</div>
|
||||
|
||||
<div class="service-inner">
|
||||
<div class="service-highlight-image">
|
||||
<img
|
||||
src={pageContent.highlight.imageUrl}
|
||||
alt={pageContent.highlight.imageAlt}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section use:reveal class="service-pricing reveal-block">
|
||||
<div class="service-inner">
|
||||
<div class="service-section-heading">
|
||||
<h2>{pageContent.pricing.title}</h2>
|
||||
{#if pageContent.pricing.intro}
|
||||
<p>{pageContent.pricing.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class:service-plan-grid-three={pageContent.pricing.plans.length === 3} class="service-plan-grid">
|
||||
{#each pageContent.pricing.plans as plan}
|
||||
<article class:service-plan-popular={plan.popular} class="service-plan-card">
|
||||
{#if plan.popular}
|
||||
<span class="service-plan-ribbon">Popular</span>
|
||||
{/if}
|
||||
|
||||
<h3>{plan.title}</h3>
|
||||
<div class="service-plan-price">{plan.price}</div>
|
||||
<p class="service-plan-period">{plan.period}</p>
|
||||
|
||||
<ul class="service-plan-features">
|
||||
{#each plan.features as feature}
|
||||
<li>{feature}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<a class="btn btn-yellow service-plan-cta" href="#reservation">Book Now</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if pageContent.pricing.extras?.length}
|
||||
<div class="service-extras">
|
||||
<div class="service-extras-heading">Extras</div>
|
||||
{#each pageContent.pricing.extras as extra}
|
||||
<div class="service-extra-row">
|
||||
<span class="service-extra-label">
|
||||
{extra.label}
|
||||
{#if extra.note}
|
||||
<span class="service-extra-pill">{extra.note}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="service-extra-price">{extra.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section use:reveal class="service-benefits reveal-block">
|
||||
<div class="service-inner">
|
||||
<div class="service-section-heading">
|
||||
<h2>{pageContent.benefits.title}</h2>
|
||||
</div>
|
||||
|
||||
<div class="service-benefit-grid">
|
||||
{#each pageContent.benefits.items as benefit}
|
||||
<article class="service-benefit-card">
|
||||
<div class="service-benefit-icon" aria-hidden="true">
|
||||
<Icon name="fas fa-paw" />
|
||||
</div>
|
||||
<h3>{benefit.title}</h3>
|
||||
<p>{benefit.body}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
|
||||
<BookingSection booking={pageContent.booking} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.service-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.service-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.service-hero {
|
||||
padding: 72px 0 96px;
|
||||
}
|
||||
|
||||
.service-hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
|
||||
gap: 52px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.service-eyebrow {
|
||||
margin: 0 0 18px;
|
||||
color: var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-hero-copy h1,
|
||||
.service-section-heading h2,
|
||||
.service-highlight-copy h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.service-hero-copy p,
|
||||
.service-section-heading p,
|
||||
.service-benefit-card p {
|
||||
margin: 20px 0 0;
|
||||
max-width: 680px;
|
||||
color: #34363a;
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.service-hero-media img,
|
||||
.service-highlight-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.service-highlight {
|
||||
padding: 0 0 96px;
|
||||
}
|
||||
|
||||
.service-highlight-copy {
|
||||
text-align: center;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.service-highlight-eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-highlight-image img {
|
||||
max-height: 620px;
|
||||
}
|
||||
|
||||
.service-pricing,
|
||||
.service-benefits {
|
||||
padding: 0 0 96px;
|
||||
}
|
||||
|
||||
.service-section-heading {
|
||||
text-align: center;
|
||||
margin-bottom: 38px;
|
||||
}
|
||||
|
||||
.service-section-heading p {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.service-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.service-plan-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-plan-card,
|
||||
.service-benefit-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 30px 26px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.service-plan-popular {
|
||||
border: 2px solid var(--yellow);
|
||||
}
|
||||
|
||||
.service-plan-ribbon {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.service-plan-card:hover,
|
||||
.service-benefit-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.service-plan-card:active,
|
||||
.service-benefit-card:active {
|
||||
transform: translateY(-2px) scale(0.992);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.service-plan-card h3,
|
||||
.service-benefit-card h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.service-plan-price {
|
||||
margin-top: 20px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 44px;
|
||||
line-height: 1;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.service-plan-period {
|
||||
margin: 8px 0 0;
|
||||
color: #5d6166;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.service-plan-features {
|
||||
margin: 24px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.service-plan-features li {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
color: #34363a;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.service-plan-features li + li {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.service-plan-features li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
color: var(--yellow);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-plan-cta {
|
||||
display: inline-flex;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.service-extras {
|
||||
margin-top: 30px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-extras-heading {
|
||||
padding: 18px 28px 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
border-bottom: 1px solid #ece9e3;
|
||||
}
|
||||
|
||||
.service-extra-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 18px 28px;
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.service-extra-row + .service-extra-row {
|
||||
border-top: 1px solid #ece9e3;
|
||||
}
|
||||
|
||||
.service-extra-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.service-extra-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.service-extra-price {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.service-benefit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.service-benefit-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: #efe4d1;
|
||||
color: var(--green);
|
||||
font-size: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.service-benefit-card p {
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.service-hero-grid,
|
||||
.service-plan-grid,
|
||||
.service-plan-grid-three,
|
||||
.service-benefit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-hero-grid {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.service-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.service-hero {
|
||||
padding: 48px 0 72px;
|
||||
}
|
||||
|
||||
.service-hero-grid,
|
||||
.service-plan-grid,
|
||||
.service-plan-grid-three,
|
||||
.service-benefit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.service-highlight,
|
||||
.service-pricing,
|
||||
.service-benefits {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.service-highlight-eyebrow {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.service-extra-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let services: IconCard[];
|
||||
|
||||
</script>
|
||||
|
||||
<section id="services">
|
||||
<div class="services-inner">
|
||||
<h2 class="section-heading">What we do</h2>
|
||||
|
||||
<div class="services-grid">
|
||||
{#each services as service}
|
||||
<div class="service-card">
|
||||
<div class="service-icon-bubble">
|
||||
<Icon name={service.icon} className="service-card-icon" />
|
||||
</div>
|
||||
<h3>{service.title}</h3>
|
||||
<p>{service.body}</p>
|
||||
|
||||
{#if service.href}
|
||||
<a href={service.href} class="btn btn-green">Learn more</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export let firstName: string;
|
||||
export let petName: string;
|
||||
export let email: string;
|
||||
export let onClose: () => void;
|
||||
|
||||
onMount(() => {
|
||||
const duration = 3200;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
const frame = () => {
|
||||
confetti({
|
||||
particleCount: 3,
|
||||
angle: 60,
|
||||
spread: 65,
|
||||
origin: { x: 0, y: 0.75 },
|
||||
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
|
||||
gravity: 0.9,
|
||||
scalar: 1.1,
|
||||
});
|
||||
confetti({
|
||||
particleCount: 3,
|
||||
angle: 120,
|
||||
spread: 65,
|
||||
origin: { x: 1, y: 0.75 },
|
||||
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
|
||||
gravity: 0.9,
|
||||
scalar: 1.1,
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
|
||||
frame();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Booking confirmed"
|
||||
on:click|self={onClose}
|
||||
on:keydown={(e) => e.key === 'Escape' && onClose()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-card">
|
||||
<button class="modal-close" type="button" aria-label="Close" on:click={onClose}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="modal-paw" aria-hidden="true">🐾</div>
|
||||
|
||||
<h2 class="modal-heading">You’re on our radar!</h2>
|
||||
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
||||
Meet & Greet with <strong>{petName}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="modal-divider"></div>
|
||||
|
||||
<p class="modal-sub">
|
||||
In the meantime, feel free to follow along on Instagram for daily walks and happy dogs.
|
||||
</p>
|
||||
|
||||
<button class="modal-btn" type="button" on:click={onClose}>
|
||||
Sounds great!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(10, 20, 10, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: backdrop-in 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 52px 48px 44px;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 24px 80px rgba(10, 20, 10, 0.22);
|
||||
text-align: center;
|
||||
animation: card-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #f2f2f0;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #e8e8e4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-paw {
|
||||
font-size: 52px;
|
||||
line-height: 1;
|
||||
margin-bottom: 20px;
|
||||
animation: bounce-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
|
||||
}
|
||||
|
||||
.modal-heading {
|
||||
margin: 0 0 14px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #213021;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.modal-divider {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: #FFD100;
|
||||
border-radius: 999px;
|
||||
margin: 28px auto;
|
||||
}
|
||||
|
||||
.modal-sub {
|
||||
margin: 0 0 32px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
display: inline-block;
|
||||
padding: 14px 36px;
|
||||
background: #213021;
|
||||
color: #FFD100;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
background: #2e4a2e;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: scale(0.88) translateY(16px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
from { opacity: 0; transform: scale(0.4); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,623 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { TestimonialContent } from '$lib/types';
|
||||
|
||||
export let testimonials: TestimonialContent[];
|
||||
export let heading = 'Why people choose us!';
|
||||
export let blurb =
|
||||
'Real dogs, real routines, real happy humans. Follow along on Instagram to see the Tiny Gang out on their daily adventures.';
|
||||
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
|
||||
export let instagramLabel = '@goodwalk.nz';
|
||||
|
||||
type TestimonialSlide = TestimonialContent & { imageUrl: string };
|
||||
|
||||
const wordpressTestimonials: Record<string, TestimonialSlide> = {
|
||||
Kate: {
|
||||
reviewer: 'Kate',
|
||||
detail: "Archie's mum",
|
||||
quote:
|
||||
'Love Aless! She is so amazing with my slightly hyper and anxious dog. She is great with communication if anything on either of our ends need to change. Archie love his walks, and I love the photos she posts of him.',
|
||||
imageUrl: '/images/archie-auckland-dog-walking-review.png'
|
||||
},
|
||||
Estelle: {
|
||||
reviewer: 'Estelle',
|
||||
detail: "Monty's mum",
|
||||
quote:
|
||||
'GoodWalk was the best dog walking service for my little pooch ! Aless was very helpful - basically doubled as a second mum to Monty. She always provided feedback on his outings and assisted where possible with any additional training that she felt he could work on and made recommendations where necessary which i feel is what every dog mum wants and needs!',
|
||||
imageUrl: '/images/monty-auckland-dog-walking-review.png'
|
||||
},
|
||||
Ross: {
|
||||
reviewer: 'Ross',
|
||||
detail: "Otis's Dad",
|
||||
quote:
|
||||
'Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, there’s a reason she has 5 stars!',
|
||||
imageUrl: '/images/otis-auckland-dog-walking-review.png'
|
||||
},
|
||||
Nina: {
|
||||
reviewer: 'Nina',
|
||||
detail: "Wallace's mum",
|
||||
quote:
|
||||
'Alessandra has been walking and spending time with my pup since she was 10 weeks old, coming over and doing puppy visits through to transitioning her to pack walks with her little doggo friends. I know Alassandra loves and cares for my dog as much as I do and my dog has a great time! Cant recommend enough',
|
||||
imageUrl: '/images/wallace-auckland-dog-walking-review.png'
|
||||
}
|
||||
};
|
||||
|
||||
let activeIndex = 0;
|
||||
let paused = false;
|
||||
|
||||
$: slides = testimonials
|
||||
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
|
||||
.filter((testimonial): testimonial is TestimonialSlide => Boolean(testimonial.imageUrl));
|
||||
|
||||
$: if (activeIndex >= slides.length) {
|
||||
activeIndex = 0;
|
||||
}
|
||||
|
||||
function showPrevious() {
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex + 1) % slides.length;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
if (!paused && slides.length > 1) {
|
||||
showNext();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="testimonials" use:reveal={{ delay: 40 }} class="reveal-block">
|
||||
<div class="testimonials-inner">
|
||||
<h2 class="section-heading">{heading}</h2>
|
||||
<div class="testimonials-intro">
|
||||
<p>{blurb}</p>
|
||||
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>{instagramLabel}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if slides.length}
|
||||
<div
|
||||
class="testimonials-carousel"
|
||||
role="region"
|
||||
aria-label="Customer testimonials"
|
||||
on:mouseenter={() => (paused = true)}
|
||||
on:mouseleave={() => (paused = false)}
|
||||
>
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-left"
|
||||
type="button"
|
||||
aria-label="Previous testimonial"
|
||||
on:click={showPrevious}
|
||||
>
|
||||
<Icon name="fas fa-chevron-left" />
|
||||
</button>
|
||||
|
||||
<div class="testimonial-stage">
|
||||
<div class="testimonial-woof" aria-hidden="true">
|
||||
<span class="testimonial-woof-text">WOOF</span>
|
||||
<span class="testimonial-ray testimonial-ray-1"></span>
|
||||
<span class="testimonial-ray testimonial-ray-2"></span>
|
||||
<span class="testimonial-ray testimonial-ray-3"></span>
|
||||
</div>
|
||||
|
||||
{#each slides as testimonial, index}
|
||||
<article class:testimonial-slide-active={index === activeIndex} class="testimonial-slide">
|
||||
<div class="testimonial-photo-wrap">
|
||||
<div class="testimonial-photo-frame">
|
||||
{#if index === activeIndex}
|
||||
<img
|
||||
class="testimonial-photo"
|
||||
src={testimonial.imageUrl}
|
||||
alt={`${testimonial.reviewer}'s dog`}
|
||||
loading={activeIndex === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="testimonial-copy">
|
||||
<span class="testimonial-quote-mark">"</span>
|
||||
<h5>{testimonial.quote}</h5>
|
||||
<div class="testimonial-author">
|
||||
<span class="testimonial-author-name">{testimonial.reviewer}</span>
|
||||
<span class="testimonial-author-detail">{testimonial.detail}</span>
|
||||
</div>
|
||||
|
||||
<div class="testimonial-divider"></div>
|
||||
|
||||
<a
|
||||
class="testimonial-google"
|
||||
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon name="fab fa-google" />
|
||||
<span>All 5 star reviews on Google!</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-right"
|
||||
type="button"
|
||||
aria-label="Next testimonial"
|
||||
on:click={showNext}
|
||||
>
|
||||
<Icon name="fas fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.testimonials-intro {
|
||||
max-width: 760px;
|
||||
margin: 18px auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.testimonials-intro p {
|
||||
margin: 0;
|
||||
color: #4c5056;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.testimonials-instagram-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.06);
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.testimonials-instagram-link .icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonials-instagram-link:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(33, 48, 33, 0.09);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
|
||||
0 10px 22px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonials-instagram-link:active {
|
||||
transform: translateY(1px) scale(0.985);
|
||||
}
|
||||
|
||||
.testimonials-carousel {
|
||||
position: relative;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.testimonials-intro {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.testimonials-intro p {
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.testimonials-instagram-link {
|
||||
margin-top: 14px;
|
||||
padding: 9px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonial-arrow:hover {
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
box-shadow: 0 14px 28px rgba(17, 20, 24, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-arrow:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.testimonial-slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 45% 55%;
|
||||
align-items: stretch;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
transform 0.35s ease;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.testimonial-slide-active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 32px 24px 0 24px;
|
||||
}
|
||||
|
||||
.testimonial-photo-frame {
|
||||
width: min(100%, 340px);
|
||||
}
|
||||
|
||||
.testimonial-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
align-self: start;
|
||||
padding: 118px 112px 76px 10px;
|
||||
}
|
||||
|
||||
.testimonial-quote-mark {
|
||||
display: block;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 72px;
|
||||
line-height: 0.6;
|
||||
color: var(--yellow);
|
||||
margin-bottom: 20px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.testimonial-copy h5 {
|
||||
max-width: 500px;
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
color: #2e3031;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.testimonial-author-name {
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.testimonial-author-detail {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.testimonial-author-detail::before {
|
||||
content: '—';
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.testimonial-divider {
|
||||
width: 100%;
|
||||
max-width: 690px;
|
||||
height: 1px;
|
||||
margin: 44px 0 0;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
.testimonial-google {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
background: #f8f8f8;
|
||||
color: #0a304e;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
|
||||
}
|
||||
|
||||
.testimonial-google :global(.icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.testimonial-google:hover {
|
||||
background: #efe6d5;
|
||||
}
|
||||
|
||||
.testimonial-woof {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 60px;
|
||||
z-index: 2;
|
||||
color: #2e3031;
|
||||
transform: rotate(-6deg);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.testimonial-woof-text {
|
||||
display: inline-block;
|
||||
font-family: 'Fredoka One', var(--font-head), sans-serif;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.testimonial-ray {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
background: #ffd100;
|
||||
}
|
||||
|
||||
.testimonial-ray-1 {
|
||||
top: -12px;
|
||||
right: -48px;
|
||||
width: 32px;
|
||||
height: 11px;
|
||||
transform: rotate(-35deg);
|
||||
}
|
||||
|
||||
.testimonial-ray-2 {
|
||||
top: 6px;
|
||||
right: -60px;
|
||||
width: 46px;
|
||||
height: 13px;
|
||||
transform: rotate(-35deg);
|
||||
}
|
||||
|
||||
.testimonial-ray-3 {
|
||||
top: 24px;
|
||||
right: -50px;
|
||||
width: 36px;
|
||||
height: 11px;
|
||||
transform: rotate(-35deg);
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #111;
|
||||
font-size: 22px;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 12px 28px rgba(20, 24, 20, 0.07);
|
||||
}
|
||||
|
||||
.testimonial-arrow:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.testimonial-arrow-left {
|
||||
left: -38px;
|
||||
}
|
||||
|
||||
.testimonial-arrow-right {
|
||||
right: -38px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.testimonial-stage {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
padding: 88px 16px 64px 44px;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
padding: 96px 72px 64px 8px;
|
||||
}
|
||||
|
||||
.testimonial-copy h5 {
|
||||
max-width: 460px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.testimonial-woof {
|
||||
right: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.testimonials-carousel {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
min-height: unset;
|
||||
padding-bottom: 116px;
|
||||
}
|
||||
|
||||
.testimonial-slide {
|
||||
position: relative;
|
||||
display: none;
|
||||
grid-template-columns: 1fr;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.testimonial-slide-active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
justify-content: center;
|
||||
padding: 48px 22px 16px;
|
||||
}
|
||||
|
||||
.testimonial-photo-frame {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.testimonial-photo {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
padding: 8px 28px 32px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.testimonial-quote-mark {
|
||||
font-size: 44px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.testimonial-copy h5 {
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.testimonial-divider {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.testimonial-google {
|
||||
margin-top: 28px;
|
||||
font-size: 16px;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.testimonial-google :global(.icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.testimonial-woof {
|
||||
top: 24px;
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.testimonial-woof-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.testimonial-ray {
|
||||
right: -28px;
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
.testimonial-ray-1 {
|
||||
top: -7px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.testimonial-ray-2 {
|
||||
top: 10px;
|
||||
right: -38px;
|
||||
width: 34px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.testimonial-ray-3 {
|
||||
top: 35px;
|
||||
right: -28px;
|
||||
width: 27px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
top: auto;
|
||||
bottom: 24px;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
font-size: 20px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.testimonial-arrow-left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.testimonial-arrow-right {
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import TestimonialsSection from './TestimonialsSection.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
describe('TestimonialsSection', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses the mapped local image assets for known testimonials', () => {
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials
|
||||
});
|
||||
|
||||
const activeImage = container.querySelector('.testimonial-slide-active img') as HTMLImageElement;
|
||||
|
||||
expect(activeImage.getAttribute('src')).toBe('/images/archie-auckland-dog-walking-review.jpg');
|
||||
});
|
||||
|
||||
it('moves to the next testimonial on arrow click and auto-rotation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials
|
||||
});
|
||||
|
||||
const nextButton = container.querySelector('.testimonial-arrow-right') as HTMLButtonElement;
|
||||
const activeReviewer = () =>
|
||||
(container.querySelector('.testimonial-slide-active h6 strong') as HTMLElement).textContent;
|
||||
|
||||
expect(activeReviewer()).toBe('Kate');
|
||||
|
||||
await fireEvent.click(nextButton);
|
||||
expect(activeReviewer()).toBe('Estelle');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(activeReviewer()).toBe('Ross');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let values: IconCard[];
|
||||
</script>
|
||||
|
||||
<section id="values">
|
||||
<div class="values-inner">
|
||||
<h2 class="section-heading">Where dogs come first</h2>
|
||||
|
||||
<div class="values-grid">
|
||||
{#each values as value}
|
||||
<div class="value-card">
|
||||
<Icon name={value.icon} className="value-card-icon" />
|
||||
<h3>{value.title}</h3>
|
||||
<p>{value.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user