v4
This commit is contained in:
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getSharedPageContent } from '$lib/server/content';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
content: await getSharedPageContent()
|
||||
};
|
||||
};
|
||||
@@ -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 & 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>
|
||||
@@ -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 & 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>
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user