This commit is contained in:
2026-05-18 09:43:29 +12:00
parent b950229003
commit 6ff970015f
189 changed files with 18603 additions and 2727 deletions
-7
View File
@@ -12,19 +12,12 @@
import '@fontsource/readex-pro/latin-500.css';
import '@fontsource/readex-pro/latin-600.css';
import '@fontsource/readex-pro/latin-700.css';
import '@fontsource/roboto/latin-400.css';
import '@fontsource/roboto/latin-500.css';
import '@fontsource/roboto/latin-700.css';
import '@fontsource/noto-sans/latin-400.css';
import '@fontsource/noto-sans/latin-500.css';
import '@fontsource/noto-sans/latin-700.css';
import unbounded700Woff2 from '@fontsource/unbounded/files/unbounded-latin-700-normal.woff2?url';
import unbounded800Woff2 from '@fontsource/unbounded/files/unbounded-latin-800-normal.woff2?url';
import '@fontsource/unbounded/latin-400.css';
import '@fontsource/unbounded/latin-600.css';
import '@fontsource/unbounded/latin-700.css';
import '@fontsource/unbounded/latin-800.css';
import '@fontsource/fredoka/latin-600.css';
import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
import '@fortawesome/fontawesome-free/css/solid.min.css';
import '@fortawesome/fontawesome-free/css/brands.min.css';
+9 -15
View File
@@ -6,7 +6,6 @@
import HowItWorksSection from '$lib/components/HowItWorksSection.svelte';
import InfoSection from '$lib/components/InfoSection.svelte';
import InstagramSection from '$lib/components/InstagramSection.svelte';
import IntroStrip from '$lib/components/IntroStrip.svelte';
import BookingSection from '$lib/components/BookingSection.svelte';
import FounderStorySection from '$lib/components/FounderStorySection.svelte';
import ServicesSection from '$lib/components/ServicesSection.svelte';
@@ -46,7 +45,7 @@
description:
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
url: siteUrl,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.webp`,
image: absoluteUrl(data.content.hero.imageUrl),
email: 'info@goodwalk.co.nz',
telephone: '+64226421011',
@@ -98,18 +97,6 @@
},
reviewBody: testimonial.quote
}))
},
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: data.content.info.faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer
}
}))
}
]
: [];
@@ -127,11 +114,18 @@
imageAlt={content.hero.imageAlt}
structuredData={homepageStructuredData}
preloadImage={true}
preloadImageUrl={content.hero.imageWebpUrl ?? content.hero.imageUrl}
preloadImageType={content.hero.imageWebpUrl ? 'image/webp' : ''}
preloadImageSrcset={content.hero.imageWebpUrl && content.hero.desktopImageWebpUrl
? `${content.hero.imageWebpUrl} 900w, ${content.hero.desktopImageWebpUrl} 1536w`
: ''}
preloadImageSizes={content.hero.imageWebpUrl && content.hero.desktopImageWebpUrl
? '(min-width: 769px) 1536px, 100vw'
: ''}
/>
<Header navigation={content.navigation} />
<HeroSection hero={content.hero} reviewCta={content.intro.reviewCta} />
<IntroStrip intro={content.intro} />
<ValuesSection values={content.values} />
<ServicesSection services={content.services} />
<HowItWorksSection content={content.howItWorks} />
+2 -2
View File
@@ -22,7 +22,7 @@
export let data: PageData;
const siteUrl = 'https://www.goodwalk.co.nz';
const defaultSeoImage = '/images/auckland-dog-walking-happy-dog-hero.png';
const defaultSeoImage = '/images/goodwalk-auckland-happy-dog-hero.webp';
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
function aggregateOfferSchema(plans: { price: string }[]) {
@@ -209,7 +209,7 @@
imageAlt={seoImageAlt}
structuredData={pageStructuredData}
preloadImage={preloadHeroImage}
noindex={data.page.noindex ?? false}
noindex={'noindex' in data.page ? data.page.noindex === true : false}
/>
<Header navigation={data.content.navigation} />
+3 -3
View File
@@ -16,7 +16,7 @@ describe('static slug route page', () => {
['puppy-visits', puppyVisitsContent.hero.title],
['our-pricing', ourPricingContent.subtitle],
['about', aboutPageContent.sections[0].title],
['testimonials', 'What our clients say'],
['testimonials', 'Client Testimonials'],
['contact-us', "Let's meet!"],
['terms-and-conditions', '1. Application of Terms'],
['privacy-policy', 'How we collect your information']
@@ -62,13 +62,13 @@ describe('static slug route page', () => {
}
});
expect(screen.getByLabelText(/General enquiry/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /General enquiry/i })).toBeInTheDocument();
rerender({
data: createStaticRouteData('pack-walks')
});
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /General enquiry/i })).not.toBeInTheDocument();
});
it('renders the shared FAQ section on the contact page', () => {
+2 -3
View File
@@ -1,9 +1,8 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']);
export const load: PageServerLoad = async ({ url }) => {
export const load = async ({ url }) => {
const hostname = url.hostname.toLowerCase();
const isOnboardingHost =
onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'contract';
@@ -13,6 +12,6 @@ export const load: PageServerLoad = async ({ url }) => {
}
return {
isPreview: url.searchParams.get('preview') === 'contract',
isPreview: url.searchParams.get('preview') === 'contract'
};
};
+7 -16
View File
@@ -1,19 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { homepageContent } from '$lib/content/homepage';
const { getHomepageContent, isHomepageHowItWorksEnabled } = vi.hoisted(() => ({
getHomepageContent: vi.fn(),
isHomepageHowItWorksEnabled: vi.fn()
const { getHomepageContent } = vi.hoisted(() => ({
getHomepageContent: vi.fn()
}));
vi.mock('$lib/server/content', () => ({
getHomepageContent
}));
vi.mock('$lib/server/feature-flags', () => ({
isHomepageHowItWorksEnabled
}));
import { load } from './+page.server';
function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
@@ -25,23 +20,19 @@ function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
describe('home page server load', () => {
it('returns homepage content', async () => {
getHomepageContent.mockResolvedValue(homepageContent);
isHomepageHowItWorksEnabled.mockReturnValue(false);
await expect(load(createLoadEvent())).resolves.toEqual({
siteVariant: 'marketing',
content: homepageContent,
howItWorksEnabled: false
content: homepageContent
});
});
it('returns the how it works flag when enabled', async () => {
it('returns the onboarding variant on the onboarding host', async () => {
getHomepageContent.mockResolvedValue(homepageContent);
isHomepageHowItWorksEnabled.mockReturnValue(true);
await expect(load(createLoadEvent())).resolves.toEqual({
siteVariant: 'marketing',
content: homepageContent,
howItWorksEnabled: true
await expect(load(createLoadEvent('https://onboarding.goodwalk.co.nz/'))).resolves.toEqual({
siteVariant: 'onboarding',
isPreview: false
});
});
});
+2 -2
View File
@@ -20,7 +20,7 @@ describe('home page route', () => {
const info = document.getElementById('info');
const booking = document.getElementById('newlead');
expect(screen.getByText(homepageContent.hero.highlight)).toBeInTheDocument();
expect(screen.getAllByText(homepageContent.hero.highlight).length).toBeGreaterThan(0);
expect(screen.getByText(homepageContent.intro.text)).toBeInTheDocument();
expect(screen.getByText('Calmer dogs. Clearer routines. Less worry.')).toBeInTheDocument();
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
@@ -36,6 +36,6 @@ describe('home page route', () => {
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
expect(document.title).toBe(homepageContent.seo.title);
expect(document.head.innerHTML).toContain('FAQPage');
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png');
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/goodwalk-auckland-happy-dog-hero.webp');
});
});
+8
View File
@@ -0,0 +1,8 @@
import { getSharedPageContent } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
content: await getSharedPageContent()
};
};
+179
View File
@@ -0,0 +1,179 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
import SeoHead from '$lib/components/SeoHead.svelte';
import { locationPages } from '$lib/content/locations';
import { buildBreadcrumb } from '$lib/seo';
import type { PageData } from './$types';
export let data: PageData;
const title = 'Auckland Dog Walking Locations | Goodwalk Service Areas';
const description =
'Goodwalk provides pack walks, 1:1 walks, and puppy visits across 17 Auckland Central suburbs. Find your suburb and see local parks, walking routes, and how we serve your area.';
const canonicalPath = '/locations';
const structuredData = [
buildBreadcrumb([
{ name: 'Home', path: '/' },
{ name: 'Locations', path: canonicalPath }
])
];
</script>
<SeoHead {title} {description} {canonicalPath} {structuredData} />
<Header navigation={data.content.navigation} />
<main id="locations-hub">
<section class="hub-hero">
<div class="hub-inner">
<p class="hub-kicker">Service Areas</p>
<h1>Where Goodwalk walks dogs in Auckland</h1>
<p class="hub-lead">
We cover 17 suburbs across Auckland Central with pack walks, 1:1 walks, and
puppy visits — including free pickup and drop-off. Choose your suburb below
to see local parks and walking routes.
</p>
</div>
</section>
<section class="hub-grid-section">
<div class="hub-inner">
<ul class="hub-grid">
{#each locationPages as loc}
<li>
<a class="hub-card" href="/locations/{loc.slug}">
<h2>{loc.suburb}</h2>
<p>
{loc.parks
.slice(0, 3)
.map((p) => p.name)
.join(' · ')}
</p>
<span class="hub-card-cta">View {loc.suburb}</span>
</a>
</li>
{/each}
</ul>
</div>
</section>
<section class="hub-cta">
<div class="hub-inner">
<h2>Live in a nearby suburb?</h2>
<p>There's a good chance we can still help — get in touch.</p>
<a class="btn btn-yellow" href="/contact-us">Book a Meet &amp; Greet</a>
</div>
</section>
</main>
<Footer footer={data.content.footer} />
<style>
#locations-hub {
background: #f7f5f0;
}
.hub-inner {
max-width: 1180px;
margin: 0 auto;
padding: 0 24px;
}
.hub-hero {
padding: 72px 0 32px;
text-align: center;
}
.hub-kicker {
margin: 0 0 12px;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hub-hero h1 {
margin: 0 0 18px;
}
.hub-lead {
max-width: 720px;
margin: 0 auto;
color: #4c5056;
}
.hub-grid-section {
padding: 32px 0 64px;
}
.hub-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 18px;
list-style: none;
margin: 0;
padding: 0;
}
.hub-card {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
padding: 22px 22px 20px;
border-radius: 20px;
background: #fff;
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 12px 28px rgba(17, 20, 24, 0.05);
color: var(--gw-green);
text-decoration: none;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.hub-card:hover {
transform: translateY(-2px);
box-shadow:
inset 0 0 0 1px rgba(33, 48, 33, 0.18),
0 18px 36px rgba(17, 20, 24, 0.09);
}
.hub-card h2 {
margin: 0;
font-size: 20px;
}
.hub-card p {
margin: 0;
color: #5f6369;
font-size: 14px;
line-height: 1.4;
}
.hub-card-cta {
margin-top: auto;
padding-top: 6px;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
}
.hub-cta {
padding: 48px 0 80px;
text-align: center;
}
.hub-cta h2 {
margin: 0 0 10px;
}
.hub-cta p {
margin: 0 0 20px;
color: #4c5056;
}
</style>
+960
View File
@@ -0,0 +1,960 @@
<!--
/meet-greet-v2 — A/B test variant of the Meet & Greet booking flow.
Standalone "sticker stack" multi-step wizard. NOT linked from the main
nav. Submits to the same /api/submit backend as the live contact form
(BookingSection.svelte) using an identical payload shape, so the
downstream handler treats it the same. Marked noindex so the variant
does not appear in search results during the test.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import Icon from '$lib/components/Icon.svelte';
import { homepageContent } from '$lib/content/homepage';
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default;
const navigation = homepageContent.navigation;
const footerContent = homepageContent.footer;
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const maxJourneyEntries = 8;
const serviceOptions = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'];
const dogShortcuts = ['puppy', 'senior', 'rescue', 'shy'];
let step = 1;
const totalSteps = 4;
let dogDetails = '';
let selectedServices: string[] = [];
let message = '';
let fullName = '';
let email = '';
let phone = '';
let location = '';
let website = ''; // honeypot
let formStartedAt = 0;
let visitStartedAt = 0;
let pageEnteredAt = 0;
let firstInteractionAt = 0;
let sendClickedAt = 0;
let stepChanges = 0;
let journey: string[] = [];
let errors: Record<string, string> = {};
let submitting = false;
let submitted = false;
let showErrorModal = false;
let submitErrorDetail = '';
let SuccessModalComponent: SuccessModalComponentType | null = null;
let ErrorModalComponent: ErrorModalComponentType | null = null;
$: dogFirstWord = dogDetails.trim().split(/[,\s]/)[0] || 'your dog';
$: dogNameDisplay = dogFirstWord
? dogFirstWord.charAt(0).toLocaleUpperCase() + dogFirstWord.slice(1)
: 'your dog';
$: stepCopy = [
{
eyebrow: 'Question one',
heading: "Who's the star?",
helper: 'Tell us your dog in one line — name, age, breed. We will use this to point you toward the right walk.'
},
{
eyebrow: 'Question two',
heading: `What's ${dogNameDisplay} after?`,
helper: 'Pick everything you are open to. We will recommend the best fit when we reply.'
},
{
eyebrow: 'Question three',
heading: 'Anything we should know?',
helper: 'Health quirks, anxiety triggers, weekly schedule, dream outcome — anything that helps us prepare.'
},
{
eyebrow: 'Question four',
heading: 'How do we reach you?',
helper: 'A real person replies within 24 hours, usually sooner.'
}
];
$: successPetName = dogNameDisplay;
$: if (submitted) {
ensureSuccessModal();
}
$: if (showErrorModal) {
ensureErrorModal();
}
onMount(() => {
const now = Date.now();
formStartedAt = now;
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
});
function readOrCreateVisitStartedAt(fallback: number) {
try {
const raw = window.sessionStorage.getItem(visitStartedStorageKey);
const parsed = raw ? Number(raw) : NaN;
if (Number.isFinite(parsed) && parsed > 0) return parsed;
window.sessionStorage.setItem(visitStartedStorageKey, String(fallback));
} catch {
return fallback;
}
return fallback;
}
function updateJourneySnapshot(pathname: string, search: string) {
const nextEntry = `${pathname}${search}`;
try {
const raw = window.sessionStorage.getItem(journeyStorageKey);
const previous = raw ? (JSON.parse(raw) as string[]) : [];
const cleaned = previous.filter((value) => typeof value === 'string' && value.trim());
const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry];
const nextJourney = deduped.slice(-maxJourneyEntries);
window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney));
return nextJourney;
} catch {
return [nextEntry];
}
}
async function ensureSuccessModal() {
if (SuccessModalComponent) return;
SuccessModalComponent = (await import('$lib/components/SuccessModal.svelte')).default;
}
async function ensureErrorModal() {
if (ErrorModalComponent) return;
ErrorModalComponent = (await import('$lib/components/ErrorModal.svelte')).default;
}
function noteInteraction() {
if (!firstInteractionAt) firstInteractionAt = Date.now();
}
function clearError(field: string) {
if (errors[field]) errors = { ...errors, [field]: '' };
}
function appendShortcut(token: string) {
noteInteraction();
const current = dogDetails.trim();
if (current.toLowerCase().includes(token.toLowerCase())) return;
dogDetails = current ? `${current.replace(/[,\s]+$/, '')}, ${token}` : token;
clearError('dogDetails');
}
function toggleService(service: string) {
noteInteraction();
if (selectedServices.includes(service)) {
selectedServices = selectedServices.filter((s) => s !== service);
} else {
selectedServices = [...selectedServices, service];
}
clearError('services');
}
function validateEmail(raw: string): string {
const value = raw.trim();
if (!value) return 'Please enter your email';
if (!value.includes('@')) return 'Email is missing the @ sign';
const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
if (!re.test(value)) return "That email doesn't look quite right";
return '';
}
function validateStep(target: number): boolean {
const next: Record<string, string> = {};
if (target === 1 && !dogDetails.trim()) {
next.dogDetails = "Tell us a little about your dog so we can match the right walk.";
}
if (target === 2 && selectedServices.length === 0) {
next.services = 'Pick at least one service to continue.';
}
if (target === 4) {
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
if (!location.trim()) next.location = 'Please enter your suburb';
}
errors = next;
return Object.keys(next).length === 0;
}
function goNext() {
noteInteraction();
if (!validateStep(step)) return;
if (step < totalSteps) {
step += 1;
stepChanges += 1;
}
}
function goBack() {
noteInteraction();
if (step > 1) {
step -= 1;
stepChanges += 1;
errors = {};
}
}
async function handleSubmit() {
noteInteraction();
if (!validateStep(4)) return;
submitting = true;
sendClickedAt = Date.now();
submitErrorDetail = '';
showErrorModal = false;
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enquiryType: 'booking',
fullName,
email,
phone,
petName: dogDetails,
location,
message,
services: selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
referrer: typeof document !== 'undefined' ? document.referrer : '',
page: typeof window !== 'undefined' ? window.location.href : '',
variant: 'meet-greet-v2'
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const detail =
typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
throw new Error(detail);
}
submitted = true;
} catch (err: unknown) {
submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true;
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Book your free Meet & Greet | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="description" content="Book a free Goodwalk Meet & Greet — short, guided application for Auckland dog owners." />
</svelte:head>
<Header {navigation} />
<main class="mgv2-page">
{#if submitted && SuccessModalComponent}
<svelte:component
this={SuccessModalComponent}
firstName={fullName.split(' ')[0]}
petName={successPetName}
{email}
enquiryType="booking"
onClose={() => (submitted = false)}
/>
{/if}
{#if showErrorModal && ErrorModalComponent}
<svelte:component
this={ErrorModalComponent}
detail={submitErrorDetail}
enquiryType="booking"
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
<section class="mgv2-shell">
<header class="mgv2-intro">
<span class="mgv2-kicker">Free Meet &amp; Greet</span>
<h1>Start with the right fit for your dog.</h1>
<p>Four short questions. A real reply within 24 hours.</p>
</header>
<div
class="mgv2-progress"
role="progressbar"
aria-valuemin="1"
aria-valuemax={totalSteps}
aria-valuenow={step}
>
{#each Array.from({ length: totalSteps }) as _, index}
<span class="mgv2-progress-dot" class:active={index + 1 === step}></span>
{/each}
</div>
<div class="mgv2-stack">
<span class="mgv2-decoy mgv2-decoy-left" aria-hidden="true"></span>
<span class="mgv2-decoy mgv2-decoy-right" aria-hidden="true"></span>
<article class="mgv2-card">
<span class="mgv2-badge" aria-hidden="true">
<Icon name="fas fa-paw" />
</span>
<input
bind:value={website}
type="text"
name="website"
class="mgv2-honeypot"
tabindex="-1"
autocomplete="new-password"
aria-hidden="true"
/>
{#key step}
<div class="mgv2-step" in:fade={{ duration: 200 }} out:fade={{ duration: 120 }}>
<span class="mgv2-eyebrow">{stepCopy[step - 1].eyebrow}</span>
<h2 class="mgv2-heading">{stepCopy[step - 1].heading}</h2>
<p class="mgv2-helper">{stepCopy[step - 1].helper}</p>
{#if step === 1}
<label class="mgv2-field" for="mgv2-dog">
<span class="mgv2-label">Your dog, in a line</span>
<input
bind:value={dogDetails}
on:input={() => clearError('dogDetails')}
id="mgv2-dog"
type="text"
placeholder="Teddy, 3, schnoodle"
class:mgv2-input-invalid={errors.dogDetails}
autocomplete="off"
/>
{#if errors.dogDetails}
<span class="mgv2-error">{errors.dogDetails}</span>
{/if}
</label>
<div class="mgv2-chips" aria-label="Quick add">
{#each dogShortcuts as token}
<button
type="button"
class="mgv2-chip"
on:click={() => appendShortcut(token)}
>+ {token}</button>
{/each}
</div>
{:else if step === 2}
<div
class="mgv2-service-grid"
class:mgv2-service-grid-invalid={errors.services}
role="group"
aria-label="Service interest"
>
{#each serviceOptions as service}
{@const checked = selectedServices.includes(service)}
<button
type="button"
class="mgv2-service"
class:active={checked}
aria-pressed={checked}
on:click={() => toggleService(service)}
>
<span class="mgv2-service-check" aria-hidden="true">
{#if checked}<Icon name="fas fa-check" />{/if}
</span>
<span class="mgv2-service-label">{service}</span>
</button>
{/each}
</div>
{#if errors.services}
<span class="mgv2-error">{errors.services}</span>
{/if}
{:else if step === 3}
<label class="mgv2-field" for="mgv2-message">
<span class="mgv2-label">Notes for us</span>
<textarea
bind:value={message}
id="mgv2-message"
rows="5"
placeholder="For example: nervous around bigger dogs, prefers shorter walks, recently rescued."
></textarea>
</label>
{:else if step === 4}
<div class="mgv2-grid-two">
<label class="mgv2-field" for="mgv2-name">
<span class="mgv2-label">Full name</span>
<input
bind:value={fullName}
on:input={() => clearError('fullName')}
id="mgv2-name"
type="text"
placeholder="Your full name"
class:mgv2-input-invalid={errors.fullName}
autocomplete="name"
/>
{#if errors.fullName}<span class="mgv2-error">{errors.fullName}</span>{/if}
</label>
<label class="mgv2-field" for="mgv2-email">
<span class="mgv2-label">Email</span>
<input
bind:value={email}
on:input={() => clearError('email')}
id="mgv2-email"
type="email"
placeholder="you@example.com"
class:mgv2-input-invalid={errors.email}
autocomplete="email"
/>
{#if errors.email}<span class="mgv2-error">{errors.email}</span>{/if}
</label>
<label class="mgv2-field" for="mgv2-phone">
<span class="mgv2-label">Phone</span>
<input
bind:value={phone}
on:input={() => clearError('phone')}
id="mgv2-phone"
type="tel"
placeholder="021 123 4567"
class:mgv2-input-invalid={errors.phone}
autocomplete="tel"
/>
{#if errors.phone}<span class="mgv2-error">{errors.phone}</span>{/if}
</label>
<label class="mgv2-field" for="mgv2-location">
<span class="mgv2-label">Suburb</span>
<input
bind:value={location}
on:input={() => clearError('location')}
id="mgv2-location"
type="text"
placeholder="For example, Grey Lynn"
class:mgv2-input-invalid={errors.location}
autocomplete="address-level2"
/>
{#if errors.location}<span class="mgv2-error">{errors.location}</span>{/if}
</label>
</div>
{/if}
<div class="mgv2-actions">
{#if step > 1}
<button type="button" class="mgv2-back" on:click={goBack}>
<Icon name="fas fa-arrow-left" />
Back
</button>
{:else}
<span class="mgv2-back-spacer" aria-hidden="true"></span>
{/if}
{#if step < totalSteps}
<button type="button" class="mgv2-next" on:click={goNext}>
Next
<Icon name="fas fa-arrow-right" />
</button>
{:else}
<button
type="button"
class="mgv2-next"
on:click={handleSubmit}
disabled={submitting}
>
{submitting ? 'Sending…' : 'Send Meet & Greet request'}
{#if !submitting}<Icon name="fas fa-arrow-right" />{/if}
</button>
{/if}
</div>
</div>
{/key}
</article>
</div>
<p class="mgv2-footnote">
No payment, no pressure. Reply from a real person within 24 hours.
</p>
</section>
</main>
<Footer footer={footerContent} />
<style>
:root {
--mgv2-yellow: #FFC700;
--mgv2-cream: #FAF8F1;
--mgv2-ink: #0b0b0b;
--mgv2-line: rgba(11, 11, 11, 0.08);
--mgv2-muted: rgba(11, 11, 11, 0.55);
}
.mgv2-page {
background: var(--mgv2-cream);
min-height: 100vh;
padding: 56px 24px 88px;
overflow-x: clip;
}
.mgv2-shell {
width: 100%;
max-width: 540px;
margin: 0 auto;
text-align: center;
}
.mgv2-intro {
margin: 0 auto 28px;
max-width: 480px;
}
.mgv2-kicker {
display: inline-block;
margin-bottom: 12px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(11, 11, 11, 0.05);
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mgv2-intro h1 {
margin: 0 0 8px;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: clamp(28px, 4vw, 38px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
}
.mgv2-intro p {
margin: 0;
color: var(--mgv2-muted);
font-size: 15px;
line-height: 1.55;
}
.mgv2-progress {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 28px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(11, 11, 11, 0.04);
}
.mgv2-progress-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(11, 11, 11, 0.18);
transition:
width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
background 0.18s ease;
}
.mgv2-progress-dot.active {
width: 32px;
background: var(--mgv2-yellow);
}
.mgv2-stack {
position: relative;
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.mgv2-decoy {
position: absolute;
inset: 14px -8px auto -8px;
height: 100%;
border-radius: 16px;
background: #fff;
border: 1px solid var(--mgv2-line);
box-shadow: 0 18px 36px rgba(11, 11, 11, 0.08);
z-index: 0;
pointer-events: none;
}
.mgv2-decoy-left {
transform: rotate(-3deg) translateY(-6px);
}
.mgv2-decoy-right {
transform: rotate(2deg) translateY(2px);
}
.mgv2-card {
position: relative;
z-index: 2;
width: 100%;
padding: 36px 28px 28px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--mgv2-line);
box-shadow:
0 24px 60px rgba(11, 11, 11, 0.12),
0 4px 14px rgba(11, 11, 11, 0.04);
text-align: left;
}
.mgv2-badge {
position: absolute;
top: -20px;
right: 18px;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 999px;
background: var(--mgv2-yellow);
color: var(--mgv2-ink);
box-shadow:
0 12px 24px rgba(255, 199, 0, 0.36),
inset 0 0 0 2px rgba(11, 11, 11, 0.06);
transform: rotate(8deg);
}
.mgv2-badge :global(.icon) {
font-size: 22px;
}
.mgv2-honeypot {
position: absolute;
left: -10000px;
top: -10000px;
width: 1px;
height: 1px;
opacity: 0;
}
.mgv2-step {
display: grid;
gap: 14px;
}
.mgv2-eyebrow {
color: var(--mgv2-muted);
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.mgv2-heading {
margin: -4px 0 0;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 24px;
font-weight: 800;
line-height: 1.16;
letter-spacing: -0.02em;
text-wrap: balance;
}
.mgv2-helper {
margin: -4px 0 6px;
color: var(--mgv2-muted);
font-size: 14px;
line-height: 1.55;
}
.mgv2-field {
display: grid;
gap: 6px;
}
.mgv2-label {
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mgv2-field input,
.mgv2-field textarea {
width: 100%;
padding: 13px 14px;
border: 1px solid var(--mgv2-line);
border-radius: 12px;
background: #fff;
color: var(--mgv2-ink);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.35;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.mgv2-field textarea {
resize: vertical;
min-height: 120px;
}
.mgv2-field input:focus,
.mgv2-field textarea:focus {
outline: none;
border-color: rgba(11, 11, 11, 0.45);
box-shadow: 0 0 0 4px rgba(255, 199, 0, 0.22);
}
.mgv2-input-invalid,
.mgv2-input-invalid:focus {
border-color: rgba(192, 32, 38, 0.6);
box-shadow: 0 0 0 4px rgba(192, 32, 38, 0.08);
}
.mgv2-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.mgv2-chip {
appearance: none;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--mgv2-line);
background: #fff;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
}
.mgv2-chip:hover {
background: rgba(255, 199, 0, 0.16);
border-color: rgba(255, 199, 0, 0.5);
transform: translateY(-1px);
}
.mgv2-service-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mgv2-service {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px;
border-radius: 14px;
border: 1px solid var(--mgv2-line);
background: #fff;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease;
}
.mgv2-service-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 7px;
border: 1.5px solid var(--mgv2-line);
background: #fff;
color: var(--mgv2-ink);
font-size: 11px;
transition: background 0.18s ease, border-color 0.18s ease;
}
.mgv2-service.active {
border-color: var(--mgv2-ink);
background: rgba(255, 199, 0, 0.14);
}
.mgv2-service.active .mgv2-service-check {
background: var(--mgv2-yellow);
border-color: var(--mgv2-yellow);
}
.mgv2-service:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(255, 199, 0, 0.22);
}
.mgv2-grid-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mgv2-grid-two .mgv2-field:nth-child(3),
.mgv2-grid-two .mgv2-field:nth-child(4) {
grid-column: span 1;
}
.mgv2-error {
color: #b1262d;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.mgv2-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.mgv2-back,
.mgv2-back-spacer {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
cursor: pointer;
border-radius: 999px;
transition: background 0.18s ease;
}
.mgv2-back-spacer {
visibility: hidden;
cursor: default;
}
.mgv2-back:hover {
background: rgba(11, 11, 11, 0.06);
}
.mgv2-next {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 10px 22px;
border: none;
border-radius: 999px;
background: var(--mgv2-yellow);
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.01em;
cursor: pointer;
box-shadow:
inset 0 -2px 0 rgba(11, 11, 11, 0.08),
0 12px 24px rgba(255, 199, 0, 0.28);
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.18s ease,
filter 0.18s ease;
}
.mgv2-next:hover {
transform: translateY(-1px);
filter: brightness(1.02);
}
.mgv2-next:disabled {
opacity: 0.7;
cursor: progress;
}
.mgv2-footnote {
margin: 32px auto 0;
max-width: 360px;
color: var(--mgv2-muted);
font-size: 13px;
line-height: 1.55;
}
@media (max-width: 600px) {
.mgv2-page {
padding: 36px 16px 64px;
}
.mgv2-stack {
max-width: 100%;
}
.mgv2-decoy {
display: none;
}
.mgv2-card {
padding: 30px 20px 22px;
}
.mgv2-badge {
width: 48px;
height: 48px;
top: -16px;
right: 14px;
}
.mgv2-badge :global(.icon) {
font-size: 18px;
}
.mgv2-heading {
font-size: 22px;
}
.mgv2-service-grid,
.mgv2-grid-two {
grid-template-columns: 1fr;
}
.mgv2-next {
padding: 10px 18px;
font-size: 13px;
}
}
</style>
+1 -2
View File
@@ -18,8 +18,7 @@ const routes: SitemapRoute[] = [
{ path: '/our-pricing', priority: '0.8', changefreq: 'monthly', lastmod: '2026-05-12' },
{ path: '/about', priority: '0.7', changefreq: 'monthly', lastmod: '2026-05-12' },
{ path: '/contact-us', priority: '0.7', changefreq: 'monthly', lastmod: '2026-05-12' },
{ path: '/terms-and-conditions', priority: '0.3', changefreq: 'yearly', lastmod: '2026-05-12' },
{ path: '/privacy-policy', priority: '0.3', changefreq: 'yearly', lastmod: '2026-05-12' }
{ path: '/locations', priority: '0.8', changefreq: 'monthly', lastmod: '2026-05-12' }
];
const locationRoutes: SitemapRoute[] = locationPages.map((loc) => ({
+3 -3
View File
@@ -9,11 +9,11 @@ describe('sitemap endpoint', () => {
expect(response.headers.get('content-type')).toBe('application/xml; charset=utf-8');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/</loc>');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/contact-us</loc>');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/privacy-policy</loc>');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/locations</loc>');
expect(body).toContain('<lastmod>2026-05-12</lastmod>');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/locations/mt-eden</loc>');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/locations/kingsland</loc>');
// 9 core pages + 17 location pages
expect(body.match(/<url>/g)).toHaveLength(26);
// 8 core pages + 17 location pages
expect(body.match(/<url>/g)).toHaveLength(25);
});
});
@@ -155,7 +155,7 @@
<div class="variant-split-layout">
<article class="variant-split-story">
<div class="variant-split-photo">
<img src="/images/founder-image-aless-goodwalk.jpg" alt="Aless from Goodwalk" />
<img src="/images/alessandra-goodwalk-founder-auckland.webp" alt="Aless from Goodwalk" />
</div>
<div class="variant-split-copy">
<span class="variant-story-kicker">Book with confidence</span>