Component architecture
- Reorganise src/lib/components into pages/, sections/, ui/ subdirectories and
  update all imports across routes and tests.

Owner admin dashboard (+2k lines)
- Scheduled welcome-pack emails: durable queue with cancel/reschedule and a
  background sender loop (SCHEDULED_CHECK_INTERVAL_SECONDS, default 60s).
- Custom welcome-pack subject line, preview recipients, and client BCC.
- Add-client, edit client profile, and reset-onboarding flows.
- "View as client" onboarding preview (owner impersonation, dry-run submit).
- Tabbed owner welcome route (/owner/welcome/[[tab]]).

MYOB integration
- New mail_api/myob.py: create new clients as MYOB AccountRight customer
  contacts. Disabled until all MYOB_* env vars are set (no-op otherwise).

Onboarding
- New "Does your dog resource guard?" Yes/No question in the Behaviour step.
- Persist vetAddress, flea/tick, and pet-insurance fields server-side.

Misc
- vite config, new hooks.ts, responsive CSS tweaks, deploy/docker config,
  mail-api README and start-dev.ps1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:51:29 +12:00
co-authored by Claude Opus 4.8
parent 7d5b72e1d3
commit 0e88e7bdac
82 changed files with 4543 additions and 914 deletions
+585
View File
@@ -0,0 +1,585 @@
<script lang="ts">
import { accordion } from '$lib/actions/accordion';
import { reveal } from '$lib/actions/reveal';
import CtaCard from '$lib/components/ui/CtaCard.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { AboutPageContent } from '$lib/types';
export let pageContent: AboutPageContent;
$: standardSections = pageContent.sections.filter((s) => s.accent !== 'founder');
$: founderSection = pageContent.sections.find((s) => s.accent === 'founder') ?? null;
const heroChips = [
{ icon: 'fas fa-star', label: '30+ five-star Google reviews' },
{ icon: 'fas fa-location-dot', label: 'Auckland Central' },
{ icon: 'fas fa-paw', label: 'Small dog specialists' }
];
const founderHeadingLead = 'Meet Aless,';
const founderHeadingHighlight = 'the heart of Goodwalk';
</script>
<main class="about-page">
<ServiceHero
eyebrow="About Goodwalk"
title={pageContent.title}
subtitle="Small dog specialists serving Auckland Central. A team your dog knows by name."
imageUrl="/images/about-good-walk.webp"
imageAlt="Goodwalk dogs gathered together in the back of the car before a walk"
chips={heroChips}
cta={{ label: 'Book a Meet & Greet', href: '/contact-us', variant: 'yellow' }}
/>
<!-- ── Standard sections (Who we are, Our impact) ── -->
{#each standardSections as section}
{@const enhanced = getEnhancedImage(section.imageUrl)}
<section
use:reveal={{ distance: 0 }}
class="about-section reveal-block"
class:about-section-gradient={section.accent === 'gradient'}
>
<div class="page-inner about-section-grid" class:about-section-reverse={section.reverse}>
<div class="about-copy fade-up">
{#if section.eyebrow}
<span class="eyebrow about-eyebrow">{section.eyebrow}</span>
{/if}
<h2>{section.title}</h2>
{#each section.body as paragraph}
<p>{paragraph}</p>
{/each}
</div>
<div class="about-media scale-soft">
{#if enhanced}
<enhanced:img src={enhanced} alt={section.imageAlt} loading="lazy" decoding="async" />
{:else}
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" decoding="async" />
{/if}
</div>
</div>
</section>
{/each}
<!-- ── Founder section ── -->
{#if founderSection}
{@const founderEnhanced = getEnhancedImage(founderSection.imageUrl)}
<section use:reveal={{ delay: 50, distance: 0 }} class="about-founder reveal-block">
<div class="page-inner about-founder-grid">
<div class="about-founder-media scale-soft">
{#if founderEnhanced}
<enhanced:img
src={founderEnhanced}
alt={founderSection.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img
src={founderSection.imageUrl}
alt={founderSection.imageAlt}
loading="lazy"
decoding="async"
/>
{/if}
</div>
<div class="about-founder-copy fade-up">
<article class="about-founder-note">
{#if founderSection.eyebrow}
<span class="eyebrow about-eyebrow about-founder-kicker">{founderSection.eyebrow}</span>
{/if}
<h2 class="about-founder-heading">
<span class="about-founder-heading-desktop">
<span class="about-founder-title-main">{founderHeadingLead}</span>
<br />
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
</span>
<span class="about-founder-heading-mobile">
<span class="about-founder-title-main">{founderHeadingLead}</span>
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
</span>
</h2>
<div class="about-founder-body">
{#each founderSection.body as paragraph}
<p>{paragraph}</p>
{/each}
</div>
<div class="about-founder-signoff">
<div class="about-founder-signoff-text">
<span class="about-founder-name">Aless</span>
<span class="about-founder-role">Goodwalk founder</span>
</div>
</div>
<a
class="about-founder-contact-note"
href="mailto:info@goodwalk.co.nz"
aria-label="Email Aless at Goodwalk"
>
<span class="about-founder-contact-icon">
<Icon name="fas fa-envelope" />
</span>
<span>If you are unsure about anything, feel free to email me anytime.</span>
</a>
<a href="/contact-us" class="btn btn-green btn-mobile-center about-founder-cta cta-shimmer">
Book a free Meet &amp; Greet
</a>
</article>
</div>
</div>
</section>
{/if}
<!-- ── FAQs ── -->
{#if pageContent.faqs && pageContent.faqs.length}
<section use:reveal={{ delay: 30, distance: 0 }} class="about-faq reveal-block">
<div class="page-inner">
<div class="about-faq-header fade-up">
<span class="eyebrow about-eyebrow">FAQ</span>
<h2>{pageContent.faqTitle ?? 'Common questions'}</h2>
</div>
<div use:accordion class="faq about-faq-list fade-up">
{#each pageContent.faqs as item}
<details>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
{/each}
</div>
</div>
</section>
{/if}
<!-- ── Contact CTA ── -->
<section use:reveal={{ delay: 50 }} class="about-contact reveal-block">
<div class="page-inner">
<CtaCard
title={pageContent.contact.title}
description="Questions, pricing, or a first Meet &amp; Greet. Email, call, or send an Instagram DM. We&apos;ll reply within 24 hours."
ctaHref={pageContent.contact.cta.href}
ctaLabel={pageContent.contact.cta.label}
email={pageContent.contact.email}
phone={pageContent.contact.phone}
instagramHref="https://www.instagram.com/goodwalk.nz/"
contactNote="Email, call, or send an Instagram DM. We want to be easy to reach in the way that suits you best."
showIcons={true}
/>
</div>
</section>
</main>
<style>
.about-page {
background: var(--off-white);
}
/* ── Eyebrow ── */
.about-eyebrow {
display: inline-block;
margin-bottom: 14px;
}
/* ── Standard sections ── */
.about-section {
padding: 88px 0;
}
.about-section-gradient {
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
}
.about-section-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 60px;
align-items: center;
}
.about-section-reverse .about-copy {
order: 2;
}
.about-section-reverse .about-media {
order: 1;
}
.about-copy h2 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #0d1a0d;
}
.about-copy p {
margin: 12px 0 0;
color: #34363a;
font-size: 17px;
line-height: 1.75;
}
.about-media {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 28px;
box-shadow: 0 16px 48px rgba(17, 20, 24, 0.1);
}
.about-media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
}
/* ── Founder section ── */
.about-founder {
padding: 88px 0;
background: #fff;
}
.about-founder-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 64px;
align-items: center;
}
.about-founder-media img {
display: block;
width: 100%;
max-width: 400px;
height: auto;
border-radius: 28px;
object-fit: cover;
box-shadow: 0 24px 56px rgba(17, 20, 24, 0.12);
margin: 0 auto;
}
.about-founder-copy h2 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(30px, 3.5vw, 44px);
font-weight: 800;
line-height: 1.06;
letter-spacing: -0.03em;
text-wrap: balance;
color: #0d1a0d;
}
.about-founder-note {
padding: 40px 42px 34px;
background: linear-gradient(180deg, rgba(251, 251, 251, 0.98) 0%, rgba(247, 248, 246, 1) 100%);
border: 1px solid rgba(17, 20, 24, 0.08);
border-radius: 28px;
box-shadow: var(--shadow-panel-elevated);
}
.about-founder-kicker {
display: block;
width: fit-content;
margin-bottom: 18px;
}
.about-founder-heading-desktop {
display: block;
}
.about-founder-heading-mobile {
display: none;
}
.about-founder-heading-mobile .about-founder-title-main,
.about-founder-heading-mobile .about-founder-title-highlight {
display: block;
}
.about-founder-title-main {
color: #0d1a0d;
}
.about-founder-title-highlight {
position: relative;
display: inline-block;
color: #0d1a0d;
}
.about-founder-title-highlight::after {
content: '';
position: absolute;
left: 0;
right: -6px;
bottom: -16px;
height: 24px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
center/contain no-repeat;
transform-origin: left center;
animation: about-founder-underline-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both;
}
@keyframes about-founder-underline-draw {
0% {
opacity: 0;
transform: scaleX(0.2) translateY(6px) rotate(-1.5deg);
}
65% {
opacity: 1;
transform: scaleX(1.04) translateY(0) rotate(0deg);
}
100% {
opacity: 1;
transform: scaleX(1) translateY(0) rotate(0deg);
}
}
.about-founder-copy p {
margin: 0;
color: #34363a;
font-size: 17px;
line-height: 1.75;
}
.about-founder-body {
display: grid;
gap: 14px;
}
.about-founder-signoff {
display: flex;
align-items: center;
gap: 14px;
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(17, 20, 24, 0.08);
}
.about-founder-signoff-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.about-founder-name {
color: #0d1a0d;
font-family: var(--font-head);
font-size: 16px;
font-weight: 700;
line-height: 1.2;
}
.about-founder-role {
color: var(--gray);
font-size: 13px;
line-height: 1.3;
}
.about-founder-contact-note {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 20px;
padding: 12px 16px;
border-radius: 18px;
background: rgba(33, 48, 33, 0.05);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
color: var(--gw-green);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
text-decoration: none;
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.about-founder-contact-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #fff;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.07);
color: var(--gw-green);
font-size: 13px;
flex: 0 0 auto;
}
.about-founder-cta {
display: flex;
width: fit-content;
margin: 28px 0 0;
}
@media (hover: hover) {
.about-founder-contact-note:hover {
background: rgba(33, 48, 33, 0.08);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.08),
0 10px 22px rgba(17, 20, 24, 0.05);
transform: translateY(-1px);
}
}
/* ── FAQs ── */
.about-faq {
padding: 80px 0;
background: var(--off-white);
}
.about-faq-header {
text-align: center;
margin-bottom: 40px;
}
.about-faq-header h2 {
margin: 0;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #0d1a0d;
}
.about-faq-list {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Contact CTA ── */
.about-contact {
padding: 0 0 88px;
}
/* ── Tablet ── */
@media (max-width: 1024px) {
.about-section-grid,
.about-founder-grid {
grid-template-columns: 1fr;
gap: 36px;
}
.about-section-reverse .about-copy,
.about-section-reverse .about-media {
order: initial;
}
.about-founder-media img {
max-width: 420px;
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.about-section {
padding: 60px 0;
}
.about-eyebrow {
display: block;
width: fit-content;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.about-section-grid {
gap: 28px;
}
.about-copy h2 {
font-size: 28px;
text-align: center;
}
.about-copy p {
font-size: 16px;
line-height: 1.7;
}
.about-founder {
padding: 60px 0;
}
.about-founder-grid {
gap: 28px;
}
.about-founder-note {
padding: 26px 24px 24px;
border-radius: 24px;
}
.about-founder-copy h2 {
font-size: 26px;
line-height: 1.02;
text-align: center;
}
.about-founder-heading-desktop {
display: none;
}
.about-founder-heading-mobile {
display: block;
}
.about-founder-copy p {
font-size: 16px;
line-height: 1.7;
}
.about-founder-kicker {
margin-left: auto;
margin-right: auto;
}
.about-founder-contact-note {
display: flex;
width: 100%;
font-size: 13px;
padding: 11px 14px;
text-align: center;
}
.about-founder-cta {
width: 100%;
justify-content: center;
}
.about-faq {
padding: 60px 0;
}
.about-contact {
padding-bottom: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.about-founder-title-highlight::after {
animation: none;
}
}
/* Per-section choreography — copy leads, then the media settles after a
short beat. Repeated across each about-section instance. */
@media (prefers-reduced-motion: no-preference) {
:global(.reveal-visible) .about-copy { transition-delay: 40ms; }
:global(.reveal-visible) .about-media { transition-delay: 200ms; }
:global(.reveal-visible) .about-founder-media { transition-delay: 60ms; }
:global(.reveal-visible) .about-founder-copy { transition-delay: 220ms; }
:global(.reveal-visible) .about-faq-header { transition-delay: 40ms; }
:global(.reveal-visible) .about-faq-list { transition-delay: 180ms; }
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import InfoSection from '$lib/components/sections/InfoSection.svelte';
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
import type { BookingContent, InfoContent } from '$lib/types';
export let booking: BookingContent;
export let info: InfoContent;
// General-enquiry mode disabled site-wide for now. Accepted as a prop so
// existing callers keep type-checking, but the value is intentionally ignored.
export let allowGeneralEnquiry = false;
void allowGeneralEnquiry;
const email = 'info@goodwalk.co.nz';
const phone = '(022) 642 1011';
const phoneHref = `tel:${phone.replace(/[^0-9+]/g, '')}`;
</script>
<main class="booking-page">
<ServiceHero
eyebrow="Contact Goodwalk"
title="Contact Us"
subtitle="Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."
imageUrl="/images/happy-customer-anna.webp"
imageAlt="Happy Goodwalk customer Anna with her dog in Auckland"
chips={[
{ icon: 'fas fa-bolt', label: 'Reply within 24 hours' },
{ icon: 'fas fa-handshake', label: 'Free Meet & Greet' },
{ icon: 'fas fa-location-dot', label: 'Auckland Central' }
]}
cta={{ label: 'Start your enquiry', href: '#newlead', variant: 'yellow' }}
/>
<div class="booking-page-contact-strip">
<div class="page-inner booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link">
<Icon name="fas fa-envelope" />
{email}
</a>
<a href={phoneHref} class="booking-contact-link">
<Icon name="fas fa-phone" />
{phone}
</a>
</div>
</div>
<BookingWizard {booking} pagePath="/contact-us" />
<InfoSection {info} />
</main>
<style>
.booking-page {
background: var(--off-white);
}
.booking-page-contact-strip {
padding: 18px 0 0;
}
.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.82);
border: 1px solid rgba(17, 20, 24, 0.08);
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
color: var(--text-heading);
transition: background 0.2s, transform 0.2s;
}
.booking-contact-link:hover {
background: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
@media (max-width: 768px) {
.booking-page-contact {
gap: 12px;
}
.booking-contact-link {
font-size: 13px;
padding: 9px 16px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
<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: 28px;
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(--gw-green);
font-family: var(--font-head);
font-size: clamp(14px, 1.4vw, 17px);
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.02em;
text-wrap: balance;
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(--gw-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: 28px;
}
.legal-section + .legal-section {
margin-top: 26px;
padding-top: 26px;
}
.legal-section h2 {
font-size: 14px;
}
}
</style>
@@ -0,0 +1,773 @@
<script lang="ts">
import { sharedServices } from '$lib/content/services';
import { reveal } from '$lib/actions/reveal';
import CtaCard from '$lib/components/ui/CtaCard.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import { getSeededTestimonialIndex } from '$lib/testimonials';
import type { LocationPageContent, TestimonialContent } from '$lib/types';
export let location: LocationPageContent;
export let testimonials: TestimonialContent[];
type ParkWithImage = LocationPageContent['parks'][number] & {
image: NonNullable<LocationPageContent['parks'][number]['image']>;
enhanced: ReturnType<typeof getEnhancedImage>;
};
$: featuredTestimonial = testimonials[getSeededTestimonialIndex(testimonials, location.slug)];
$: parksWithImages = location.parks
.filter((park): park is LocationPageContent['parks'][number] & { image: NonNullable<LocationPageContent['parks'][number]['image']> } => Boolean(park.image))
.map(
(park): ParkWithImage => ({
...park,
enhanced: getEnhancedImage(park.image.src)
})
);
$: serviceLinks = sharedServices.map((service) => ({
label: service.title,
href: service.href,
desc: service.locationDescription,
icon: service.icon
}));
$: locationHighlights = [
{
icon: 'fas fa-map-location-dot',
label: 'Local routes',
value: `${location.parks.length}+ parks`,
detail: `Regular walking options in and around ${location.suburb}`
},
{
icon: 'fas fa-paw',
label: 'Services',
value: '3 ways to help',
detail: 'Pack walks, solo walks, and puppy visits'
},
{
icon: 'fas fa-van-shuttle',
label: 'Included',
value: 'Free pickup',
detail: 'Pickup and drop-off across the central suburbs'
}
];
</script>
<main class="loc-page">
<!-- ── Hero ── -->
<section class="loc-hero">
<div class="page-inner">
<span class="eyebrow eyebrow--accent loc-hero-eyebrow">Auckland Central Dog Walking</span>
<h1>Dog walkers in {location.suburb}</h1>
<p class="loc-hero-desc">{location.intro}</p>
<div class="loc-hero-actions">
<a href="/contact-us" class="btn btn-yellow btn-mobile-center">Book a free Meet &amp; Greet</a>
<a href="tel:+64226421011" class="loc-hero-phone">or call (022) 642 1011</a>
</div>
<div class="loc-hero-chips">
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="loc-chip loc-chip-link"
>
<span class="loc-chip-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
<span class="loc-chip">Small dog specialists</span>
<span class="loc-chip">Free pickup &amp; drop-off</span>
</div>
</div>
</section>
<section class="loc-highlights" aria-label={`Goodwalk highlights in ${location.suburb}`}>
<div class="page-inner">
<div class="loc-highlights-grid">
{#each locationHighlights as highlight}
<div class="loc-highlight-card">
<div class="loc-highlight-top">
<div class="loc-highlight-icon-wrap">
<Icon name={highlight.icon} className="loc-highlight-icon" />
</div>
<span class="loc-highlight-label">{highlight.label}</span>
</div>
<strong>{highlight.value}</strong>
<p>{highlight.detail}</p>
</div>
{/each}
</div>
</div>
</section>
<!-- ── Parks ── -->
<section use:reveal={{ delay: 30 }} class="loc-parks reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="eyebrow loc-eyebrow">Where we walk</span>
<h2>Parks &amp; walks in {location.suburb}</h2>
<p class="loc-section-intro">
These are the parks and routes we know well in {location.suburb}. Every walk is planned around your dog's pace, size, and temperament — not just the nearest green space.
</p>
</div>
<div class="loc-parks-grid">
{#each location.parks as park}
<div class="loc-park-card">
<div class="loc-park-icon" aria-hidden="true">🐾</div>
<h3>{park.name}</h3>
<p>{park.description}</p>
{#if park.leashNote}
<span class="loc-park-leash">{park.leashNote}</span>
{/if}
</div>
{/each}
</div>
</div>
</section>
{#if parksWithImages.length > 0}
<section use:reveal={{ delay: 30 }} class="loc-gallery reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="eyebrow loc-eyebrow">Local parks</span>
<h2>Park photos from {location.suburb}</h2>
<p class="loc-section-intro">
Real images from the parks we mention help each suburb page feel more specific and give search engines clearer local context.
</p>
</div>
<div class="loc-gallery-grid">
{#each parksWithImages as park}
<figure class="loc-gallery-card">
{#if park.enhanced}
<picture>
<img src={park.enhanced.img.src} alt={park.image.alt} loading="lazy" decoding="async" />
</picture>
{:else}
<img src={park.image.src} alt={park.image.alt} loading="lazy" decoding="async" />
{/if}
<figcaption>
<strong>{park.name}</strong>
{#if park.image.caption}
<span>{park.image.caption}</span>
{/if}
</figcaption>
</figure>
{/each}
</div>
</div>
</section>
{/if}
<!-- ── Services ── -->
<section use:reveal={{ delay: 30 }} class="loc-services reveal-block">
<div class="page-inner">
<div class="loc-section-header">
<span class="eyebrow loc-eyebrow">What we offer</span>
<h2>Goodwalk services in {location.suburb}</h2>
<p class="loc-section-intro">
We offer pack walks, solo walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet &amp; Greet so we can understand your dog and recommend the right fit.
</p>
</div>
<div class="loc-services-grid">
{#each serviceLinks as svc}
<a href={svc.href} class="loc-service-card">
<div class="loc-service-icon-bubble">
<Icon name={svc.icon} className="loc-service-icon" />
</div>
<h3>{svc.label}</h3>
<p>{svc.desc}</p>
<span class="loc-service-link">Learn more →</span>
</a>
{/each}
</div>
</div>
</section>
<!-- ── Testimonial ── -->
{#if featuredTestimonial}
<section use:reveal={{ delay: 30 }} class="loc-review reveal-block">
<div class="page-inner">
<div class="loc-review-card">
<span class="loc-review-stars" aria-hidden="true">★★★★★</span>
<blockquote class="loc-review-quote">"{featuredTestimonial.quote}"</blockquote>
<cite class="loc-review-cite">
{featuredTestimonial.reviewer}
{#if featuredTestimonial.detail}
<span class="loc-review-detail">{featuredTestimonial.detail}</span>
{/if}
</cite>
</div>
</div>
</section>
{/if}
<!-- ── CTA ── -->
<section use:reveal={{ delay: 30 }} class="loc-cta reveal-block">
<div class="page-inner">
<CtaCard
title="Ready to get started in {location.suburb}?"
description="A free Meet &amp; Greet is the first step. No commitment, no pressure. Email, call, or send an Instagram DM to talk through what your dog needs."
ctaHref="/contact-us"
ctaLabel="Book a free Meet & Greet"
email="info@goodwalk.co.nz"
phone="(022) 642 1011"
phoneHref="tel:+64226421011"
instagramHref="https://www.instagram.com/goodwalk.nz/"
contactNote="Email, call, or send an Instagram DM. We want to be easy to reach wherever you already are."
/>
</div>
</section>
</main>
<style>
.loc-page {
background: var(--off-white);
}
/* ── Eyebrow ── */
.loc-eyebrow,
/* Layout-only overrides; typography lives on the shared .eyebrow utility,
colour comes from .eyebrow / .eyebrow--accent. */
.loc-hero-eyebrow,
.loc-eyebrow {
display: inline-block;
margin-bottom: 14px;
}
/* ── Hero ── */
.loc-hero {
background: var(--gw-green);
color: #fff;
padding: 80px 0 112px;
text-align: center;
}
.loc-hero h1 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(36px, 5vw, 64px);
font-weight: 800;
line-height: 1.02;
letter-spacing: -0.04em;
color: #fff;
}
.loc-hero-desc {
max-width: 640px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.65;
}
.loc-hero-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.loc-hero-phone {
color: rgba(255, 255, 255, 0.75);
font-size: 15px;
text-decoration: none;
transition: color 0.18s ease;
}
.loc-hero-phone:hover {
color: #fff;
}
.loc-hero-chips {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.loc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
.loc-chip-link {
text-decoration: none;
transition: background 0.18s ease;
}
.loc-chip-link:hover {
background: rgba(255, 255, 255, 0.18);
}
.loc-chip-stars {
color: var(--yellow);
letter-spacing: 1px;
font-size: 13px;
}
/* ── Highlights ── */
.loc-highlights {
margin-top: -56px;
padding: 0 0 88px;
position: relative;
z-index: 2;
}
.loc-highlights-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.loc-highlight-card {
position: relative;
overflow: hidden;
padding: 24px 24px 22px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(255, 209, 71, 0.22), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, #f7f4ec 100%);
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 18px 44px rgba(13, 26, 13, 0.09);
text-align: left;
}
.loc-highlight-card::after {
content: '';
position: absolute;
right: -18px;
bottom: -18px;
width: 96px;
height: 96px;
border-radius: 50%;
background: rgba(33, 48, 33, 0.05);
pointer-events: none;
}
.loc-highlight-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.loc-highlight-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 18px rgba(255, 209, 71, 0.24);
flex: 0 0 auto;
}
:global(.loc-highlight-icon-wrap .loc-highlight-icon) {
color: var(--gw-green);
font-size: 18px;
}
.loc-highlight-label {
display: inline-block;
color: var(--gw-green);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loc-highlight-card strong {
display: block;
margin: 0 0 8px;
color: #0d1a0d;
font-family: var(--font-head);
font-size: clamp(22px, 2.5vw, 28px);
line-height: 1.05;
letter-spacing: -0.03em;
}
.loc-highlight-card p {
margin: 0;
color: #4c5056;
font-size: 14px;
line-height: 1.6;
}
/* ── Section headers ── */
.loc-section-header {
text-align: center;
margin-bottom: 48px;
}
.loc-section-header h2 {
margin: 0 0 12px;
font-family: var(--font-head);
font-size: clamp(26px, 3vw, 38px);
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
color: #0d1a0d;
}
.loc-section-intro {
max-width: 560px;
margin: 0 auto;
color: #4c5056;
font-size: 16px;
line-height: 1.65;
}
/* ── Parks ── */
.loc-parks {
padding: 0 0 88px;
}
.loc-parks-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.loc-park-card {
background: #fff;
border-radius: 20px;
padding: 32px 28px;
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
}
.loc-park-icon {
font-size: 28px;
margin-bottom: 16px;
}
.loc-park-card h3 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #0d1a0d;
}
.loc-park-card p {
margin: 0 0 14px;
color: #4c5056;
font-size: 15px;
line-height: 1.65;
}
.loc-park-leash {
display: inline-block;
padding: 5px 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
color: var(--gw-green);
font-size: 12px;
font-weight: 600;
}
/* ── Gallery ── */
.loc-gallery {
padding: 0 0 88px;
}
.loc-gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.loc-gallery-card {
margin: 0;
overflow: hidden;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 8px 28px rgba(17, 20, 24, 0.06);
}
.loc-gallery-card picture,
.loc-gallery-card img {
display: block;
width: 100%;
}
.loc-gallery-card img {
aspect-ratio: 4 / 3;
object-fit: cover;
}
.loc-gallery-card figcaption {
display: grid;
gap: 6px;
padding: 18px 20px 20px;
}
.loc-gallery-card strong {
color: #0d1a0d;
font-size: 16px;
line-height: 1.35;
}
.loc-gallery-card span {
color: #4c5056;
font-size: 14px;
line-height: 1.6;
}
/* ── Services ── */
.loc-services {
padding: 0 0 88px;
}
.loc-services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.loc-service-card {
display: flex;
flex-direction: column;
padding: 28px 24px;
background: var(--gw-green);
border-radius: 20px;
text-decoration: none;
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
}
@media (hover: hover) {
.loc-highlight-card:hover {
transform: translateY(-3px);
box-shadow: 0 20px 40px rgba(13, 26, 13, 0.12);
}
.loc-park-card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 32px rgba(17, 20, 24, 0.08);
}
.loc-service-card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 36px rgba(33, 48, 33, 0.2);
}
}
.loc-park-card,
.loc-highlight-card,
.loc-gallery-card,
.loc-service-card {
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
}
.loc-service-icon-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 0 20px;
border-radius: 50%;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 24px rgba(17, 20, 24, 0.16);
}
:global(.loc-service-icon-bubble .loc-service-icon) {
color: var(--gw-green);
font-size: 28px;
}
.loc-service-card h3 {
margin: 0 0 8px;
font-family: var(--font-head);
font-size: 20px;
font-weight: 700;
color: #fff;
}
.loc-service-card p {
margin: 0;
flex: 1;
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
line-height: 1.55;
}
.loc-service-link {
display: inline-block;
margin-top: 18px;
color: var(--yellow);
font-size: 14px;
font-weight: 700;
}
/* ── Review ── */
.loc-review {
padding: 0 0 88px;
}
.loc-review-card {
background: #fff;
border-radius: 24px;
padding: 48px 56px;
text-align: center;
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 8px 32px rgba(17, 20, 24, 0.05);
}
.loc-review-stars {
display: block;
color: var(--yellow);
font-size: 20px;
letter-spacing: 3px;
margin-bottom: 20px;
}
.loc-review-quote {
margin: 0 0 20px;
font-family: var(--font-head);
font-size: clamp(18px, 2.2vw, 24px);
font-weight: 600;
line-height: 1.45;
color: #0d1a0d;
font-style: normal;
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
.loc-review-cite {
font-style: normal;
color: var(--gw-green);
font-weight: 700;
font-size: 15px;
}
.loc-review-detail {
font-weight: 400;
color: #888;
margin-left: 4px;
}
/* ── CTA ── */
.loc-cta {
padding: 0 0 88px;
}
/* ── Tablet ── */
@media (max-width: 1024px) {
.loc-highlights-grid,
.loc-parks-grid,
.loc-gallery-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.loc-hero {
padding: 56px 0 48px;
}
.loc-hero h1 {
font-size: 34px;
}
.loc-hero-desc {
font-size: 15px;
}
.loc-hero-actions {
flex-direction: column;
gap: 12px;
}
.loc-eyebrow,
.loc-hero-eyebrow {
display: block;
width: fit-content;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.loc-section-header h2,
.loc-section-intro {
text-align: center;
}
.loc-highlights {
margin-top: -24px;
padding-bottom: 60px;
}
.loc-highlights-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.loc-highlight-card {
padding: 20px 18px 18px;
}
.loc-parks {
padding: 60px 0;
}
.loc-parks-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.loc-gallery {
padding-bottom: 60px;
}
.loc-gallery-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.loc-services {
padding-bottom: 60px;
}
.loc-services-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.loc-review {
padding-bottom: 60px;
}
.loc-review-card {
padding: 32px 24px;
}
.loc-cta {
padding-bottom: 60px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import OnboardingPage from './OnboardingPage.svelte';
function clickService(label: string) {
const chip = screen.getByText(label).closest('label');
if (!chip) throw new Error(`Could not find service chip: ${label}`);
return fireEvent.click(chip);
}
async function chooseOption(groupName: RegExp | string, option: 'Yes' | 'No') {
const group = screen.getByRole('radiogroup', { name: groupName });
await fireEvent.click(within(group).getByRole('radio', { name: option }));
}
describe('OnboardingPage', () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.setItem('gw_onboarding_session', 'test-token');
window.scrollTo = vi.fn();
});
it('progresses from behaviour to sign in the 5-step flow', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/verify') {
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue({
email: 'alex@example.com',
profile: {},
draft: {}
})
});
}
if (url === '/api/save-draft') {
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue({})
});
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
})
);
render(OnboardingPage);
await waitFor(() => expect(screen.getByPlaceholderText('First name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('First name'), { target: { value: 'Alex' } });
await fireEvent.input(screen.getByPlaceholderText('Last name'), { target: { value: 'Walker' } });
await fireEvent.input(screen.getByPlaceholderText('you@example.com'), { target: { value: 'alex@example.com' } });
await fireEvent.input(screen.getByPlaceholderText('021 234 5678'), { target: { value: '0212345678' } });
await fireEvent.input(screen.getByPlaceholderText('Street, suburb, city'), { target: { value: '1 Test Street' } });
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByPlaceholderText('Dog name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Dog name'), { target: { value: 'Milo' } });
await fireEvent.input(screen.getByPlaceholderText('e.g. Labrador, Cavoodle, mixed'), { target: { value: 'Spoodle' } });
await fireEvent.input(screen.getByLabelText(/date of birth/i), { target: { value: '2020-01-01' } });
await clickService('Tiny Gang Pack Walks');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByPlaceholderText('Clinic name or your usual vet')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Clinic name or your usual vet'), { target: { value: 'Grey Lynn Vets' } });
await fireEvent.input(screen.getByPlaceholderText('Clinic phone number'), { target: { value: '099999999' } });
await fireEvent.input(screen.getByPlaceholderText('Clinic address'), { target: { value: '2 Vet Street' } });
await fireEvent.input(screen.getByPlaceholderText('Full name'), { target: { value: 'Jamie Walker' } });
await fireEvent.input(screen.getByPlaceholderText('Phone number'), { target: { value: '0211111111' } });
await chooseOption(/vaccinations up to date/i, 'Yes');
await chooseOption(/^Any food allergies\?$/i, 'No');
await chooseOption(/^Any environmental allergies\?$/i, 'No');
await chooseOption(/^On a special diet\?$/i, 'No');
await chooseOption(/medication that could affect walks/i, 'No');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByText(/registered with council/i)).toBeInTheDocument());
await chooseOption(/registered with council/i, 'Yes');
await fireEvent.click(screen.getByRole('button', { name: /save and continue/i }));
await waitFor(() => expect(screen.getByText(/anything else we should know/i)).toBeInTheDocument());
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,556 @@
<script lang="ts">
import BookingWizard from '$lib/components/sections/BookingWizard.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import { reveal } from '$lib/actions/reveal';
import ServiceHero from '$lib/components/sections/ServiceHero.svelte';
import type { SiteSharedContent, TestimonialContent } from '$lib/types';
export let content: SiteSharedContent;
$: testimonials = content.testimonials;
$: testimonialCards = testimonials.map((testimonial) => ({
...testimonial,
enhanced: testimonial.imageUrl ? getEnhancedImage(testimonial.imageUrl) : undefined
}));
const heroImageUrl = '/images/our-client-testimonials.webp';
const heroImageAlt = 'Goodwalk client dogs featured in testimonials';
const heroChips = [
{ icon: 'fas fa-star', label: '30+ five-star Google reviews' },
{ icon: 'fas fa-paw', label: 'Tiny Gang regulars' },
{ icon: 'fas fa-camera', label: 'Real client dogs' }
];
function dogNameFromDetail(detail: string) {
const match = detail.match(/^([^']+)/);
return match ? match[1].trim() : '';
}
function reviewerRole(testimonial: TestimonialContent) {
const detail = testimonial.detail.trim();
const reviewer = testimonial.reviewer.trim();
const detailHasRelation = /(mum|dad|father|owner)/i.test(detail);
const reviewerHasRelation = /(mum|dad|father|owner)/i.test(reviewer);
if (detailHasRelation) return detail;
if (reviewerHasRelation) return reviewer;
return 'Goodwalk client';
}
function authorLine(testimonial: TestimonialContent) {
const reviewer = testimonial.reviewer.trim();
const role = reviewerRole(testimonial);
return role === reviewer ? reviewer : `${reviewer} - ${role}`;
}
function reviewAlt(testimonial: TestimonialContent) {
const detailLead = dogNameFromDetail(testimonial.detail);
return detailLead
? `${detailLead}, a Goodwalk dog in Auckland`
: `${testimonial.reviewer}'s dog with Goodwalk in Auckland`;
}
</script>
<main class="testimonials-page">
<ServiceHero
eyebrow="Goodwalk reviews"
title="Client Testimonials"
subtitle="Calmer dogs. Easier days. The kind of care owners stop worrying about once they have it."
imageUrl={heroImageUrl}
imageAlt={heroImageAlt}
chips={heroChips}
cta={{ label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' }}
/>
<section use:reveal={{ threshold: 0.02 }} class="testimonials-page-grid-section reveal-block">
<div class="page-inner">
<div class="testimonials-page-grid">
{#each testimonialCards as testimonial}
<article class="testimonials-page-card">
<div class="testimonials-page-card-media">
{#if testimonial.imageUrl && testimonial.enhanced}
<enhanced:img
src={testimonial.enhanced}
alt={reviewAlt(testimonial)}
loading="lazy"
decoding="async"
/>
{:else if testimonial.imageUrl}
<img
src={testimonial.imageUrl}
alt={reviewAlt(testimonial)}
loading="lazy"
decoding="async"
/>
{:else}
<div class="testimonials-page-card-fallback" aria-hidden="true">
<span class="testimonials-page-card-fallback-mark">
<span class="testimonials-page-card-fallback-mark-ring">
<Icon name="fas fa-paw" />
</span>
</span>
</div>
{/if}
<div class="testimonials-page-card-meta">
<span class="testimonials-page-card-dog">
{dogNameFromDetail(testimonial.detail) || testimonial.reviewer}
</span>
</div>
</div>
<div class="testimonials-page-card-copy">
<div class="testimonials-page-card-top">
<span class="testimonials-page-card-quote" aria-hidden="true"></span>
<div class="testimonials-page-card-review-meta">
{#if testimonial.type === 'Google'}
<img
class="testimonials-page-card-source-icon"
src="/images/google-g-logo.svg"
alt="Google review"
width="14"
height="15"
/>
{/if}
<span class="testimonials-page-card-stars" aria-hidden="true">
{#each Array(5) as _}
<Icon name="fas fa-star" />
{/each}
</span>
</div>
</div>
<blockquote>{testimonial.quote}</blockquote>
<div class="testimonials-page-author">
<span class="testimonials-page-author-name">{authorLine(testimonial)}</span>
</div>
</div>
</article>
{/each}
</div>
<div class="testimonials-page-google-cta-wrap">
<a
class="testimonials-page-google-cta"
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
>
<img
class="testimonials-page-trust-logo"
src="/images/google-g-logo.svg"
alt=""
width="18"
height="19"
/>
<span>Read all our Google reviews</span>
<Icon name="fas fa-arrow-right" />
</a>
</div>
</div>
</section>
<BookingWizard booking={content.booking} pagePath="/testimonials" />
</main>
<style>
.testimonials-page {
background: var(--off-white);
}
.testimonials-page-trust-logo {
flex: 0 0 auto;
}
.testimonials-page-grid-section {
padding: 28px 0 72px;
}
.testimonials-page-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
align-items: stretch;
max-width: 1240px;
margin: 0 auto;
}
.testimonials-page-card {
position: relative;
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(33, 48, 33, 0.09);
border-radius: 28px;
background: #fff;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 16px 34px rgba(33, 48, 33, 0.08);
transition:
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.28s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.24s ease;
}
.testimonials-page-card-media {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 22px;
background: rgba(33, 48, 33, 0.12);
}
.testimonials-page-card-media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
.testimonials-page-card-fallback {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 24px;
text-align: center;
background: var(--gw-green);
overflow: hidden;
}
.testimonials-page-card-fallback::before,
.testimonials-page-card-fallback::after {
content: '';
position: absolute;
border-radius: 999px;
background: rgba(255, 209, 0, 0.08);
pointer-events: none;
}
.testimonials-page-card-fallback::before {
top: 18px;
right: 18px;
width: 72px;
height: 72px;
}
.testimonials-page-card-fallback::after {
bottom: -20px;
left: -10px;
width: 120px;
height: 120px;
}
.testimonials-page-card-fallback-mark {
position: relative;
z-index: 1;
}
.testimonials-page-card-fallback-mark {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.testimonials-page-card-fallback-mark-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 244, 204, 0.96), rgba(255, 209, 0, 0.82));
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 12px 28px rgba(17, 20, 24, 0.18);
}
.testimonials-page-card-fallback-mark-ring :global(.icon) {
font-size: 28px;
color: var(--gw-green);
}
.testimonials-page-card-meta {
position: absolute;
right: 14px;
bottom: 14px;
left: 14px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(18, 26, 18, 0.1) 0%, rgba(18, 26, 18, 0.78) 100%);
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.testimonials-page-card-dog {
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
font-family: var(--font-head);
font-size: 22px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
}
.testimonials-page-card-copy {
display: grid;
align-content: start;
grid-template-rows: auto 1fr auto;
gap: 14px;
padding: 18px 8px 10px;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.testimonials-page-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.testimonials-page-card-quote {
color: rgba(33, 48, 33, 0.22);
font-family: Georgia, 'Times New Roman', serif;
font-size: 40px;
line-height: 0.8;
}
.testimonials-page-card-review-meta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.testimonials-page-card-source-icon {
flex: 0 0 auto;
}
.testimonials-page-card-stars {
display: inline-flex;
gap: 4px;
color: #f0b72f;
font-size: 13px;
}
.testimonials-page-card blockquote {
margin: 0;
align-self: start;
color: #243024;
font-size: 17px;
line-height: 1.7;
}
.testimonials-page-author {
display: flex;
align-items: center;
margin-top: auto;
}
.testimonials-page-author-name {
color: var(--gw-green);
font-family: var(--font-head);
font-size: 15px;
font-weight: 700;
}
.testimonials-page-google-cta-wrap {
display: flex;
justify-content: center;
margin-top: 36px;
}
.testimonials-page-google-cta {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 50px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 16px 30px rgba(67, 49, 21, 0.08);
color: #1d281e;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
background 0.22s ease;
}
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-card,
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
opacity: 0;
transform: translateY(28px);
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(1) {
transition-delay: 0ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(2) {
transition-delay: 50ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(3) {
transition-delay: 100ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(4) {
transition-delay: 150ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(5) {
transition-delay: 200ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-card:nth-child(6) {
transition-delay: 250ms;
}
:global(.reveal-visible.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: 140ms;
}
@media (hover: hover) {
.testimonials-page-card:hover {
transform: translateY(-6px);
border-color: rgba(33, 48, 33, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 24px 48px rgba(33, 48, 33, 0.12);
}
.testimonials-page-card:hover .testimonials-page-card-media img {
transform: scale(1.035);
}
.testimonials-page-card:hover .testimonials-page-card-copy {
transform: translateY(-2px);
}
.testimonials-page-card:hover .testimonials-page-card-meta {
transform: translateY(-2px);
}
.testimonials-page-google-cta:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.92);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 20px 34px rgba(67, 49, 21, 0.11);
}
}
.testimonials-page-card:focus-within {
transform: translateY(-4px);
border-color: rgba(33, 48, 33, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 22px 44px rgba(33, 48, 33, 0.12);
}
@media (max-width: 1024px) {
.testimonials-page-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 1180px) {
.testimonials-page-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
max-width: 1500px;
}
}
@media (min-width: 1800px) {
.testimonials-page-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
max-width: 1760px;
gap: 20px;
}
}
@media (max-width: 768px) {
.testimonials-page-grid-section {
padding: 20px 0 56px;
}
.testimonials-page-card-copy {
gap: 14px;
padding: 18px 6px 8px;
}
.testimonials-page-grid {
gap: 18px;
}
.testimonials-page-card {
border-radius: 26px;
padding: 12px;
}
.testimonials-page-card-media {
border-radius: 18px;
}
.testimonials-page-card-meta {
right: 12px;
bottom: 12px;
left: 12px;
padding: 12px 14px;
border-radius: 16px;
}
.testimonials-page-card-dog {
font-size: 20px;
}
.testimonials-page-card blockquote {
font-size: 16px;
line-height: 1.64;
}
.testimonials-page-author {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.testimonials-page-card,
.testimonials-page-card-media img,
.testimonials-page-card-meta,
.testimonials-page-card-copy,
.testimonials-page-google-cta,
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-card,
:global(.reveal-ready.reveal-block).testimonials-page-grid-section .testimonials-page-google-cta-wrap {
transition: none;
transform: none;
opacity: 1;
}
}
</style>