v4.1 - Admin/onboarding

This commit is contained in:
2026-05-18 22:25:43 +12:00
parent 6ff970015f
commit 541ae2eeec
79 changed files with 11544 additions and 1007 deletions
+3 -1
View File
@@ -164,11 +164,13 @@
<div class="page-inner">
<CtaCard
title={pageContent.contact.title}
description="Questions, pricing, or your first Meet &amp; Greet start here and we'll reply within 24 hours."
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>
+6 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import Icon from '$lib/components/Icon.svelte';
import InfoSection from '$lib/components/InfoSection.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,7 +7,10 @@
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';
@@ -18,9 +21,7 @@
<PageHeader
variant="green"
title="Contact Us"
subtitle={allowGeneralEnquiry
? "Book a Meet & Greet or send a general enquiry. We'll come back within 24 hours."
: "Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."}
subtitle="Tell us a little about your dog and we'll be in touch within 24 hours to arrange a free Meet & Greet."
>
<div class="booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link">
@@ -34,7 +35,7 @@
</div>
</PageHeader>
<BookingSection {booking} {allowGeneralEnquiry} variant="contact-modern" />
<BookingWizard {booking} pagePath="/contact-us" />
<InfoSection {info} />
</main>
+3 -3
View File
@@ -29,12 +29,12 @@
messagePlaceholder:
'For example: age, confidence around other dogs, recall, and anything else that would help us place them well.'
},
'1:1 Walks': {
'Solo Walks': {
intro:
'Tell us about your dog, your suburb, and what you want from one-to-one walks so we can plan the right routine.',
'Tell us about your dog, your suburb, and what you want from solo walks so we can plan the right routine.',
messageLabel: 'What your dog needs',
messagePlaceholder:
'For example: size, pace, lead manners, confidence, and anything else that would help us tailor a one-to-one walk.'
'For example: size, pace, lead manners, confidence, and anything else that would help us tailor a solo walk.'
},
'Puppy Visits': {
intro:
File diff suppressed because it is too large Load Diff
+11 -13
View File
@@ -14,7 +14,7 @@
const ownerEmail = 'info@goodwalk.co.nz';
const ownerPhone = '(022) 642 1011';
const services = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits'];
const services = ['Tiny Gang Pack Walks', 'Solo Walks', 'Puppy Visits'];
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const draftStorageKey = 'goodwalk_contract_draft';
@@ -173,14 +173,14 @@
} catch { /* storage unavailable */ }
}
function applyProfile(serverEmail: string, profile: Record<string, string> = {}) {
function applyProfile(serverEmail: string, profile: Record<string, unknown> = {}) {
if (!email) email = serverEmail;
if (!fullName) fullName = profile.fullName ?? '';
if (!phone) phone = profile.phone ?? '';
if (!address) address = profile.address ?? '';
if (!dogName) dogName = profile.dogName ?? '';
if (!dogBreed) dogBreed = profile.dogBreed ?? '';
if (!dogAge) dogAge = profile.dogAge ?? '';
if (!fullName && typeof profile.fullName === 'string') fullName = profile.fullName;
if (!phone && typeof profile.phone === 'string') phone = profile.phone;
if (!address && typeof profile.address === 'string') address = profile.address;
if (!dogName && typeof profile.dogName === 'string') dogName = profile.dogName;
if (!dogBreed && typeof profile.dogBreed === 'string') dogBreed = profile.dogBreed;
if (!dogAge && typeof profile.dogAge === 'string') dogAge = profile.dogAge;
onboardingCompleted = Boolean((profile as Record<string, unknown>).onboardingCompleted);
contractCompleted = Boolean((profile as Record<string, unknown>).contractCompleted);
}
@@ -206,7 +206,7 @@
authChecking = false;
}
function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, string>; draft?: Record<string, unknown> }>) {
function handleAuthenticated(e: CustomEvent<{ email: string; profile?: Record<string, unknown>; draft?: Record<string, unknown> }>) {
isAuthenticated = true;
userEmail = e.detail.email;
applyProfile(e.detail.email, e.detail.profile ?? {});
@@ -1396,8 +1396,7 @@
}
.field input,
.field textarea,
.field select {
.field textarea {
width: 100%;
padding: 15px 16px;
border: 1px solid rgba(33, 48, 33, 0.14);
@@ -1411,8 +1410,7 @@
}
.field input:focus,
.field textarea:focus,
.field select:focus {
.field textarea:focus {
border-color: rgba(255, 209, 0, 0.9);
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16);
}
+26 -1
View File
@@ -9,6 +9,9 @@
export let email: string | undefined = undefined;
export let phone: string | undefined = undefined;
export let phoneHref: string | undefined = undefined;
export let instagramHref: string | undefined = undefined;
export let instagramLabel = 'Instagram DM';
export let contactNote: string | undefined = undefined;
export let showIcons = false;
$: resolvedPhoneHref = phoneHref ?? (phone ? `tel:${phone.replace(/[^0-9+]/g, '')}` : undefined);
@@ -22,7 +25,7 @@
{ctaLabel}
<Icon name="fas fa-arrow-right" />
</a>
{#if email || phone}
{#if email || phone || instagramHref}
<div class="cta-card__links">
{#if email}
<a class="cta-card__link" href="mailto:{email}">
@@ -36,7 +39,16 @@
{phone}
</a>
{/if}
{#if instagramHref}
<a class="cta-card__link" href={instagramHref} target="_blank" rel="noopener">
{#if showIcons}<Icon name="fab fa-instagram" />{/if}
{instagramLabel}
</a>
{/if}
</div>
{#if contactNote}
<p class="cta-card__contact-note">{contactNote}</p>
{/if}
{/if}
</div>
@@ -96,6 +108,14 @@
margin-top: 22px;
}
.cta-card__contact-note {
margin: 14px auto 0;
max-width: 460px;
color: rgba(255, 255, 255, 0.68);
font-size: 14px;
line-height: 1.5;
}
.cta-card__link {
display: inline-flex;
align-items: center;
@@ -122,5 +142,10 @@
align-items: center;
gap: 14px;
}
.cta-card__contact-note {
margin-top: 12px;
font-size: 13px;
}
}
</style>
@@ -27,7 +27,7 @@
<article class="founder-note">
<div class="founder-intro">
<span class="eyebrow founder-kicker">A note from Aless</span>
<span class="founder-greeting">Hi, I'm Aless.</span>
<span class="founder-greeting">Hi, Aless from Goodwalk <span class="founder-greeting-wave" aria-hidden="true">👋</span></span>
</div>
<h2 class="founder-heading">
@@ -57,7 +57,7 @@
<div class="founder-actions">
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk">
<span class="founder-contact-wave" aria-hidden="true">👋</span>
<span>If you are unsure about anything, feel free to email me anytime.</span>
<span>If you are unsure about anything, feel free to email, call, or send me an Instagram DM anytime.</span>
</a>
<a href={founderStory.cta.href} class="btn btn-green btn-with-arrow btn-hide-arrow-mobile founder-cta">
@@ -157,6 +157,11 @@
line-height: 1.5;
}
.founder-greeting-wave {
display: inline-block;
margin-left: 4px;
}
.founder-heading {
display: grid;
gap: 8px;
+2 -2
View File
@@ -238,8 +238,8 @@
text-align: left;
}
.faq summary,
.faq details p {
:global(.faq summary),
:global(.faq details p) {
text-align: left;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
</div>
<div class="instagram-dog-wrap" aria-hidden="true">
<enhanced:img src="$lib/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" />
<img src="/images/goodwalk-instagram-dog-cutout.webp" alt="" class="instagram-dog" loading="lazy" decoding="async" />
</div>
</div>
</aside>
+5 -3
View File
@@ -41,7 +41,7 @@
icon: 'fas fa-paw',
label: 'Services',
value: '3 ways to help',
detail: 'Pack walks, 1:1 walks, and puppy visits'
detail: 'Pack walks, solo walks, and puppy visits'
},
{
icon: 'fas fa-van-shuttle',
@@ -164,7 +164,7 @@
<span class="loc-eyebrow">What we offer</span>
<h2>Goodwalk services in {location.suburb}</h2>
<p class="loc-section-intro">
We offer pack walks, 1:1 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.
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">
@@ -205,12 +205,14 @@
<div class="page-inner">
<CtaCard
title="Ready to get started in {location.suburb}?"
description="A free Meet & Greet is the first step — no commitment, no pressure. We meet your dog, answer your questions, and see if Goodwalk is the right fit."
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>
+9 -4
View File
@@ -2,10 +2,15 @@
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
export let context: 'onboarding' | 'contract' = 'onboarding';
$: flowLabel = context === 'contract' ? 'contract' : 'onboarding';
export let context: 'onboarding' | 'contract' | 'owner' = 'onboarding';
$: introText =
context === 'contract'
? "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your contract."
: context === 'owner'
? "Enter the Goodwalk owner email address. We'll send you a one-time code so you can manage onboarding welcome emails."
: "Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your onboarding.";
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, string>; draft: Record<string, unknown> } }>();
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, unknown>; draft: Record<string, unknown> } }>();
const ownerEmail = 'info@goodwalk.co.nz';
const ownerPhone = '(022) 642 1011';
@@ -94,7 +99,7 @@
{#if stage === 'email'}
<h2>Sign in to continue</h2>
<p>Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code to continue your {flowLabel}.</p>
<p>{introText}</p>
<div class="auth-field">
<label for="auth-email">Email address</label>
+26 -1
View File
@@ -108,7 +108,32 @@
@media (max-width: 768px) {
.ob-footer-inner {
padding: 0 18px;
height: auto;
padding: 12px 18px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
row-gap: 10px;
}
.ob-footer-email {
order: 3;
flex: 0 0 100%;
width: 100%;
margin-left: 0;
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.4;
}
.ob-footer-back,
.ob-footer-logout {
flex: 0 0 auto;
}
.ob-footer-logout {
margin-left: auto;
}
}
</style>
File diff suppressed because it is too large Load Diff
+89
View File
@@ -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('Surname'), { 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 address'), { 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('Breed'), { 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('Vet clinic or vet name')).toBeInTheDocument());
await fireEvent.input(screen.getByPlaceholderText('Vet clinic or vet name'), { target: { value: 'Grey Lynn Vets' } });
await fireEvent.input(screen.getByPlaceholderText('Vet phone number'), { target: { value: '099999999' } });
await fireEvent.input(screen.getByPlaceholderText('Vet address'), { target: { value: '2 Vet Street' } });
await fireEvent.input(screen.getByPlaceholderText('Emergency contact name'), { target: { value: 'Jamie Walker' } });
await fireEvent.input(screen.getByPlaceholderText('Emergency contact number'), { target: { value: '0211111111' } });
await chooseOption(/is your dog vaccinated/i, 'Yes');
await chooseOption(/does your dog have any food allergies/i, 'No');
await chooseOption(/does your dog have any environmental allergies/i, 'No');
await chooseOption(/is your dog on a special diet/i, 'No');
await chooseOption(/is your dog taking any medication/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 you'd like us to know/i)).toBeInTheDocument());
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import Icon from '$lib/components/Icon.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import PricingPlanCard from '$lib/components/PricingPlanCard.svelte';
@@ -189,7 +189,7 @@
testimonials={content.testimonials}
seedKey="/our-pricing"
/>
<BookingSection booking={pageContent.booking} variant="card-stepper" />
<BookingWizard booking={pageContent.booking} pagePath="/our-pricing" />
</main>
<style>
+42 -6
View File
@@ -319,25 +319,61 @@
@media (max-width: 768px) {
.plan-card {
order: var(--mobile-order, 0);
width: min(100%, 440px);
width: 100%;
margin-inline: auto;
padding: 28px 22px 24px;
padding: 22px 18px 20px;
border-radius: 22px;
}
.plan-card--featured {
padding-top: 44px;
padding-top: 36px;
}
.plan-card--pricing.plan-card--featured {
padding: 44px 22px 24px;
padding: 36px 18px 20px;
}
.plan-card__price {
font-size: 44px;
font-size: 36px;
}
.plan-card--supporting .plan-card__price {
font-size: 42px;
font-size: 34px;
}
.plan-card__title {
font-size: 17px;
}
.plan-card__price-block {
margin-top: 14px;
}
.plan-card__features {
margin-top: 18px;
padding-top: 16px;
}
.plan-card__features li {
gap: 8px;
font-size: 13px;
line-height: 1.45;
}
.plan-card__features li + li {
margin-top: 8px;
}
.plan-card__cta {
margin-top: 20px;
padding-inline: 16px;
}
.plan-card__ribbon {
top: 12px;
left: 16px;
padding: 4px 9px 4px 8px;
font-size: 10px;
}
.plan-card__cta {
+929
View File
@@ -0,0 +1,929 @@
<script lang="ts">
type LabelAnchor = 'top' | 'bottom' | 'left' | 'right';
type LabelTone = 'brand' | 'accent';
type Breakpoint = 'desktop' | 'tablet' | 'mobile';
type Placement = {
x: number;
y: number;
anchor: LabelAnchor;
dx?: number;
dy?: number;
visible?: boolean;
z?: number;
};
type Pin = {
slug: string;
suburb: string;
tone?: LabelTone;
desktop: Placement;
tablet?: Partial<Placement>;
mobile?: Partial<Placement>;
};
const mapWidth = 640;
const mapViewY = 100;
const mapHeight = 350;
const centre = { x: 320, y: 238 };
const city = { x: 344, y: 176 };
const pins: Pin[] = [
{
slug: 'pt-chevalier',
suburb: 'Pt Chevalier',
tone: 'accent',
desktop: { x: 124, y: 228, anchor: 'left', dy: -6, z: 4 },
tablet: { x: 120, y: 230, anchor: 'left', dy: -4 },
mobile: { x: 114, y: 230, anchor: 'right', dx: 8, dy: -2, z: 6 }
},
{
slug: 'herne-bay',
suburb: 'Herne Bay',
tone: 'accent',
desktop: { x: 226, y: 118, anchor: 'top', dx: -10, z: 5 },
tablet: { x: 224, y: 122, anchor: 'top', dx: -14, dy: -2 },
mobile: { x: 214, y: 116, anchor: 'bottom', dx: -14, dy: 6, z: 6 }
},
{
slug: 'freemans-bay',
suburb: 'Freemans Bay',
desktop: { x: 330, y: 124, anchor: 'top', dx: 14, dy: -4, z: 5 },
tablet: { x: 326, y: 130, anchor: 'top', dx: 10, dy: -2 },
mobile: { x: 322, y: 132, anchor: 'top', dx: 8, dy: -4, visible: false }
},
{
slug: 'ponsonby',
suburb: 'Ponsonby',
desktop: { x: 250, y: 178, anchor: 'left', dx: -4, dy: -10, z: 5 },
tablet: { x: 244, y: 184, anchor: 'left', dx: -6, dy: -10 },
mobile: { x: 238, y: 172, anchor: 'top', dx: -6, dy: -10, z: 6 }
},
{
slug: 'grey-lynn',
suburb: 'Grey Lynn',
desktop: { x: 214, y: 220, anchor: 'left', dx: -10, dy: -2, z: 5 },
tablet: { x: 208, y: 222, anchor: 'left', dx: -10, dy: 2 },
mobile: { x: 196, y: 222, anchor: 'left', dx: -6, dy: 0, z: 6 }
},
{
slug: 'kingsland',
suburb: 'Kingsland',
desktop: { x: 248, y: 258, anchor: 'left', dx: -6, dy: -2 },
tablet: { x: 236, y: 260, anchor: 'left', dx: -8, dy: -2 },
mobile: { x: 232, y: 260, anchor: 'left', dx: -2, visible: false }
},
{
slug: 'morningside',
suburb: 'Morningside',
desktop: { x: 196, y: 290, anchor: 'left', dx: -10, dy: 2 },
tablet: { x: 186, y: 290, anchor: 'left', dx: -8, dy: 4 },
mobile: { x: 184, y: 288, anchor: 'left', dx: -2, dy: 2, visible: false }
},
{
slug: 'mt-albert',
suburb: 'Mt Albert',
tone: 'accent',
desktop: { x: 148, y: 314, anchor: 'left', dx: -6, dy: 2, z: 4 },
tablet: { x: 148, y: 314, anchor: 'left', dx: -8, dy: 4 },
mobile: { x: 144, y: 312, anchor: 'left', dx: -2, dy: 0, z: 5 }
},
{
slug: 'sandringham',
suburb: 'Sandringham',
desktop: { x: 244, y: 344, anchor: 'bottom', dx: -10, dy: 0, z: 4 },
tablet: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2 },
mobile: { x: 238, y: 340, anchor: 'bottom', dx: -14, dy: 2, visible: false }
},
{
slug: 'mt-eden',
suburb: 'Mt Eden',
desktop: { x: 344, y: 280, anchor: 'right', dx: 6, dy: -8, z: 5 },
tablet: { x: 334, y: 282, anchor: 'right', dx: 6, dy: -6, z: 5 },
mobile: { x: 332, y: 280, anchor: 'right', dx: 4, dy: -8, z: 6 }
},
{
slug: 'balmoral',
suburb: 'Balmoral',
desktop: { x: 314, y: 334, anchor: 'right', dx: 8, dy: 4, z: 4 },
tablet: { x: 306, y: 338, anchor: 'bottom', dx: 0, dy: 6, z: 4 },
mobile: { x: 304, y: 338, anchor: 'bottom', dx: 0, dy: 6, visible: false }
},
{
slug: 'remuera',
suburb: 'Remuera',
tone: 'accent',
desktop: { x: 470, y: 272, anchor: 'right', dx: 10, dy: -10, z: 4 },
tablet: { x: 452, y: 280, anchor: 'right', dx: 8, dy: -8, z: 4 },
mobile: { x: 438, y: 276, anchor: 'right', dx: 6, dy: -6, z: 5 }
},
{
slug: 'greenlane',
suburb: 'Greenlane',
desktop: { x: 426, y: 348, anchor: 'right', dx: 10, dy: 6, z: 4 },
tablet: { x: 410, y: 346, anchor: 'right', dx: 8, dy: 6, z: 4 },
mobile: { x: 404, y: 340, anchor: 'right', dx: 4, dy: 6, z: 5 }
},
{
slug: 'mt-roskill',
suburb: 'Mt Roskill',
desktop: { x: 182, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 },
tablet: { x: 180, y: 384, anchor: 'left', dx: -2, dy: 4, z: 3 },
mobile: { x: 178, y: 382, anchor: 'left', dx: 4, dy: 2, z: 5 }
},
{
slug: 'three-kings',
suburb: 'Three Kings',
desktop: { x: 296, y: 390, anchor: 'bottom', dx: 8, dy: 0, z: 4 },
tablet: { x: 292, y: 388, anchor: 'bottom', dx: 8, dy: 0, z: 4 },
mobile: { x: 292, y: 390, anchor: 'bottom', dx: 6, dy: 0, z: 6 }
},
{
slug: 'hillsborough',
suburb: 'Hillsborough',
tone: 'accent',
desktop: { x: 216, y: 426, anchor: 'bottom', dx: -6, dy: 0, z: 4 },
tablet: { x: 214, y: 422, anchor: 'bottom', dx: -8, dy: 0, z: 4 },
mobile: { x: 218, y: 418, anchor: 'top', dx: -2, dy: -8, z: 6 }
},
{
slug: 'onehunga',
suburb: 'Onehunga',
desktop: { x: 364, y: 426, anchor: 'bottom', dx: 8, dy: 0, z: 3 },
tablet: { x: 354, y: 424, anchor: 'bottom', dx: 8, dy: 0, z: 3 },
mobile: { x: 352, y: 420, anchor: 'top', dx: 10, dy: -8, visible: false }
}
];
function percentX(x: number) {
return `${(x / mapWidth) * 100}%`;
}
function percentY(y: number) {
return `${((y - mapViewY) / mapHeight) * 100}%`;
}
function resolvePlacement(pin: Pin, breakpoint: Breakpoint): Placement {
const desktop = pin.desktop;
const override =
breakpoint === 'desktop' ? {} : breakpoint === 'tablet' ? pin.tablet ?? {} : pin.mobile ?? {};
return {
...desktop,
...override
};
}
function placementTokens(prefix: string, placement: Placement, gap: number, stem: number) {
const dx = placement.dx ?? 0;
const dy = placement.dy ?? 0;
if (placement.anchor === 'left') {
return [
`--${prefix}-direction: row-reverse`,
`--${prefix}-transform: translate(calc(-100% - ${gap}px + ${dx}px), calc(-50% + ${dy}px))`,
`--${prefix}-stem-w: ${stem}px`,
`--${prefix}-stem-h: 1.5px`
];
}
if (placement.anchor === 'right') {
return [
`--${prefix}-direction: row`,
`--${prefix}-transform: translate(calc(${gap}px + ${dx}px), calc(-50% + ${dy}px))`,
`--${prefix}-stem-w: ${stem}px`,
`--${prefix}-stem-h: 1.5px`
];
}
if (placement.anchor === 'top') {
return [
`--${prefix}-direction: column-reverse`,
`--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(-100% - ${gap}px + ${dy}px))`,
`--${prefix}-stem-w: 1.5px`,
`--${prefix}-stem-h: ${stem}px`
];
}
return [
`--${prefix}-direction: column`,
`--${prefix}-transform: translate(calc(-50% + ${dx}px), calc(${gap}px + ${dy}px))`,
`--${prefix}-stem-w: 1.5px`,
`--${prefix}-stem-h: ${stem}px`
];
}
function displayToken(placement: Placement | undefined) {
return placement?.visible === false ? 'none' : 'inline-flex';
}
function pinStyle(pin: Pin, index: number) {
const desktop = resolvePlacement(pin, 'desktop');
const tablet = resolvePlacement(pin, 'tablet');
const mobile = resolvePlacement(pin, 'mobile');
return [
`--desktop-left: ${percentX(desktop.x)}`,
`--desktop-top: ${percentY(desktop.y)}`,
`--desktop-z: ${desktop.z ?? 3}`,
`--desktop-display: ${displayToken(desktop)}`,
...placementTokens('desktop', desktop, 14, 16),
`--tablet-left: ${percentX(tablet.x)}`,
`--tablet-top: ${percentY(tablet.y)}`,
`--tablet-z: ${tablet.z ?? desktop.z ?? 3}`,
`--tablet-display: ${displayToken(tablet)}`,
...placementTokens('tablet', tablet, 12, 14),
`--mobile-left: ${percentX(mobile.x)}`,
`--mobile-top: ${percentY(mobile.y)}`,
`--mobile-z: ${mobile.z ?? tablet.z ?? desktop.z ?? 3}`,
`--mobile-display: ${displayToken(mobile)}`,
...placementTokens('mobile', mobile, 10, 12),
`--pin-delay: ${((index * 0.11) % 1.6).toFixed(2)}s`
].join('; ');
}
function routePath(pin: Pin) {
const target = pin.desktop;
const controlX = (centre.x + target.x) / 2;
const controlY = (centre.y + target.y) / 2 + (target.y >= centre.y ? 14 : -14);
return `M ${centre.x} ${centre.y} Q ${controlX} ${controlY} ${target.x} ${target.y}`;
}
</script>
<figure class="area-map" aria-labelledby="area-map-caption">
<div class="area-map-shell">
<div class="area-map-stage">
<svg
class="area-map-svg"
viewBox={`0 ${mapViewY} ${mapWidth} ${mapHeight}`}
role="presentation"
aria-hidden="true"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="area-map-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(var(--white-rgb), 0.98)" />
<stop offset="58%" stop-color="rgba(var(--white-rgb), 0.94)" />
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.1)" />
</linearGradient>
<radialGradient id="area-map-glow" cx="50%" cy="46%" r="54%">
<stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.14)" />
<stop offset="48%" stop-color="rgba(var(--accent-rgb), 0.04)" />
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0)" />
</radialGradient>
<radialGradient id="area-map-core-glow" cx="50%" cy="50%" r="58%">
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.12)" />
<stop offset="70%" stop-color="rgba(var(--brand-rgb), 0.015)" />
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" />
</radialGradient>
<linearGradient id="area-map-district-main" x1="18%" y1="12%" x2="82%" y2="88%">
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0.16)" />
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0.05)" />
</linearGradient>
<linearGradient id="area-map-district-soft" x1="10%" y1="20%" x2="95%" y2="85%">
<stop offset="0%" stop-color="rgba(var(--accent-rgb), 0.1)" />
<stop offset="100%" stop-color="rgba(var(--accent-rgb), 0.02)" />
</linearGradient>
<linearGradient id="area-map-route-flow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="rgba(var(--brand-rgb), 0)" />
<stop offset="40%" stop-color="rgba(var(--brand-rgb), 0.04)" />
<stop offset="56%" stop-color="rgba(var(--accent-rgb), 0.44)" />
<stop offset="72%" stop-color="rgba(var(--brand-rgb), 0.08)" />
<stop offset="100%" stop-color="rgba(var(--brand-rgb), 0)" />
</linearGradient>
<pattern id="area-map-grid" width="52" height="52" patternUnits="userSpaceOnUse">
<path d="M 52 0 L 0 0 0 52" fill="none" stroke="rgba(var(--brand-rgb), 0.045)" stroke-width="1" />
</pattern>
<filter id="area-map-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="rgba(var(--brand-rgb), 0.1)" />
</filter>
</defs>
<rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-bg)" />
<rect x="0" y="0" width={mapWidth} height="500" rx="32" fill="url(#area-map-grid)" />
<ellipse cx="324" cy="234" rx="206" ry="154" fill="url(#area-map-glow)" />
<g class="area-map-waterways">
<path
d="M 26 108 C 96 74, 176 66, 244 84 C 302 100, 336 124, 396 122 C 470 120, 548 76, 620 90"
class="area-map-waterway"
/>
<path
d="M 78 454 C 140 430, 186 416, 240 420 C 292 424, 330 460, 392 462 C 472 466, 560 428, 616 384"
class="area-map-waterway area-map-waterway-soft"
/>
</g>
<g class="area-map-districts" filter="url(#area-map-shadow)">
<path
d="M 94 206 C 132 162, 198 132, 274 126 C 334 122, 396 134, 446 160 C 500 188, 532 240, 530 300 C 528 362, 492 412, 430 434 C 342 466, 224 458, 150 412 C 92 376, 70 282, 94 206 Z"
fill="url(#area-map-district-main)"
/>
<path
d="M 126 214 C 168 180, 222 164, 284 164 C 348 164, 402 180, 442 214 C 474 242, 490 280, 486 320 C 482 366, 448 402, 394 420 C 316 446, 214 438, 154 394 C 112 360, 98 274, 126 214 Z"
fill="rgba(var(--white-rgb), 0.32)"
stroke="rgba(var(--brand-rgb), 0.08)"
stroke-width="1"
/>
<path
d="M 300 158 C 360 158, 420 176, 462 210 C 500 240, 522 278, 522 322 C 522 370, 500 402, 462 414 C 420 426, 362 412, 332 378 C 304 346, 302 312, 338 282 C 366 256, 384 230, 378 202 C 372 178, 346 162, 300 158 Z"
fill="url(#area-map-district-soft)"
/>
<path
d="M 156 248 C 188 236, 224 244, 248 268 C 268 288, 278 320, 266 348 C 252 382, 218 402, 180 402 C 148 400, 120 384, 104 356 C 88 324, 92 286, 116 262 C 128 250, 142 244, 156 248 Z"
fill="rgba(var(--white-rgb), 0.16)"
/>
</g>
<g class="area-map-roads" aria-hidden="true">
<path d="M 148 248 C 204 250, 244 266, 286 292 C 322 316, 360 354, 406 390" class="area-map-road" />
<path d="M 240 126 C 270 160, 292 194, 316 238 C 336 278, 356 340, 364 430" class="area-map-road area-map-road-strong" />
<path d="M 122 300 C 182 304, 244 300, 306 290 C 356 282, 418 264, 468 238" class="area-map-road area-map-road-soft" />
</g>
<g class="area-map-routes">
{#each pins as pin}
<path d={routePath(pin)} class="area-map-route-base" pathLength="1" />
<path d={routePath(pin)} class="area-map-route-flow" pathLength="1" />
{/each}
</g>
<g class="area-map-core">
<circle cx={centre.x} cy={centre.y} r="64" class="area-map-core-aura" />
<circle cx={centre.x} cy={centre.y} r="48" class="area-map-core-halo" />
<circle cx={centre.x} cy={centre.y} r="24" class="area-map-core-pulse" />
<circle cx={centre.x} cy={centre.y} r="15" class="area-map-core-ring" />
<circle cx={centre.x} cy={centre.y} r="10" class="area-map-core-dot" />
</g>
<g class="area-map-skyline" transform={`translate(${city.x - 46} ${city.y - 54}) scale(0.94)`}>
<ellipse cx="42" cy="74" rx="34" ry="8" class="area-map-tower-shadow" />
<path d="M 16 72 C 24 58, 29 41, 32 21 C 34 10, 36 6, 38.5 2.5 C 39.4 1.2, 40.2 0.5, 40.8 0 C 41.5 0.5, 42.3 1.2, 43.2 2.5 C 45.8 6, 47.8 10, 49.8 21 C 52.8 41, 57.8 58, 65.5 72 L 59 72 C 54.8 63.5, 51.8 49.5, 48.4 31 L 47.2 31 L 47.2 20.5 C 47.2 17.6, 46.3 15.7, 44.8 13.8 L 43.9 12.4 L 45 12.4 L 44 8.6 L 42.5 8.6 L 42.8 4.8 L 41.4 4.8 L 40.2 4.8 L 38.8 4.8 L 39.1 8.6 L 37.6 8.6 L 36.6 12.4 L 37.7 12.4 L 36.8 13.8 C 35.3 15.7, 34.4 17.6, 34.4 20.5 L 34.4 31 L 33.2 31 C 29.8 49.5, 26.8 63.5, 22.6 72 Z" class="area-map-tower-body" />
<path d="M 34 21.5 C 34 16.5, 36.8 13.6, 40.8 13.6 C 44.8 13.6, 47.6 16.5, 47.6 21.5 C 47.6 25.3, 45.8 28.2, 43 29.5 L 43 34.2 C 45.8 34.9, 48 36.5, 49.6 39.3 L 31.8 39.3 C 33.5 36.5, 35.6 34.9, 38.6 34.2 L 38.6 29.5 C 35.8 28.2, 34 25.3, 34 21.5 Z" class="area-map-tower-observation" />
<path d="M 30.8 39.3 L 50.6 39.3 L 52 42.9 L 29.4 42.9 Z" class="area-map-tower-band" />
<path d="M 29.4 42.9 C 32.8 46.4, 36.1 47.8, 40.8 47.8 C 45.5 47.8, 48.8 46.4, 52.2 42.9 L 51.1 49.4 C 47.8 51.5, 45 52.2, 40.8 52.2 C 36.6 52.2, 33.8 51.5, 30.5 49.4 Z" class="area-map-tower-ring" />
<path d="M 33.2 52.2 L 48.4 52.2 L 50 72 L 31.6 72 Z" class="area-map-tower-stem" />
<path d="M 37.4 55.6 L 39.4 55.6 L 39.4 68.8 L 37.4 68.8 Z M 42.2 55.6 L 44.2 55.6 L 44.2 68.8 L 42.2 68.8 Z" class="area-map-tower-slit" />
<circle cx="40.8" cy="10.4" r="1.9" class="area-map-tower-beacon" />
</g>
</svg>
<div class="area-map-overlay">
<span
class="area-map-label area-map-label-static area-map-label-city"
style={`left:${percentX(city.x)}; top:${percentY(city.y)};`}
aria-hidden="true"
>
<span class="area-map-label-stem"></span>
<span class="area-map-label-dot-wrap">
<span class="area-map-label-dot area-map-label-dot-city"></span>
</span>
<span class="area-map-label-pill area-map-label-pill-city">City</span>
</span>
{#each pins as pin, index}
<a
href={`/locations/${pin.slug}`}
class={`area-map-label ${pin.tone === 'accent' ? 'area-map-label-accent' : ''}`}
style={pinStyle(pin, index)}
aria-label={`View ${pin.suburb} location page`}
>
<span class="area-map-label-stem" aria-hidden="true"></span>
<span class="area-map-label-dot-wrap" aria-hidden="true">
<span class="area-map-label-pulse"></span>
<span class="area-map-label-dot"></span>
</span>
<span class="area-map-label-pill">
<span class="area-map-label-text-full">{pin.suburb}</span>
</span>
</a>
{/each}
</div>
</div>
</div>
<figcaption id="area-map-caption" class="area-map-caption">
Tap a suburb to open its local page with parks, routes, and service details.
</figcaption>
</figure>
<style>
.area-map {
margin: 0;
}
@media (max-width: 768px) {
.area-map {
display: none;
}
}
.area-map-shell {
display: grid;
place-items: center;
padding: 0 clamp(12px, 1.8vw, 18px) clamp(14px, 2vw, 20px);
overflow: visible;
}
.area-map-stage {
position: relative;
width: min(100%, 54rem);
overflow: hidden;
border-radius: clamp(26px, 2.8vw, 34px);
background:
radial-gradient(circle at 16% 12%, rgba(var(--accent-rgb), 0.1), transparent 30%),
linear-gradient(180deg, rgba(var(--white-rgb), 0.24), rgba(var(--white-rgb), 0));
box-shadow:
inset 0 0 0 1px rgba(var(--brand-rgb), 0.08),
0 18px 42px rgba(var(--ink-rgb), 0.08);
}
.area-map-svg {
display: block;
width: 100%;
height: auto;
aspect-ratio: 640 / 340;
}
.area-map-overlay {
position: absolute;
inset: clamp(14px, 2vw, 20px) clamp(28px, 4vw, 48px);
overflow: visible;
pointer-events: none;
}
.area-map-waterway {
fill: none;
stroke: rgba(var(--white-rgb), 0.56);
stroke-width: 10;
stroke-linecap: round;
opacity: 0.56;
}
.area-map-waterway-soft {
stroke-width: 7;
opacity: 0.28;
}
.area-map-road {
fill: none;
stroke: rgba(var(--brand-rgb), 0.1);
stroke-width: 1.8;
stroke-linecap: round;
stroke-dasharray: 1 9;
opacity: 0.66;
}
.area-map-road-soft {
opacity: 0.38;
stroke-dasharray: 1 11;
}
.area-map-road-strong {
stroke: rgba(var(--brand-rgb), 0.15);
stroke-width: 2.2;
opacity: 0.72;
}
.area-map-route-base {
fill: none;
stroke: rgba(var(--brand-rgb), 0.08);
stroke-width: 1.2;
stroke-linecap: round;
opacity: 0.72;
}
.area-map-route-flow {
fill: none;
stroke: url(#area-map-route-flow);
stroke-width: 2.4;
stroke-linecap: round;
stroke-dasharray: 0.18 0.82;
animation: areaRouteFlow 7.2s linear infinite;
opacity: 0.76;
}
.area-map-core-aura {
fill: url(#area-map-core-glow);
}
.area-map-core-halo {
fill: rgba(var(--accent-rgb), 0.12);
}
.area-map-core-pulse {
fill: rgba(var(--brand-rgb), 0.1);
transform-origin: 320px 238px;
animation: areaCorePulse 4.8s ease-out infinite;
}
.area-map-core-ring {
fill: rgba(var(--white-rgb), 0.86);
stroke: rgba(var(--brand-rgb), 0.16);
stroke-width: 1.25;
}
.area-map-core-dot {
fill: var(--gw-green);
stroke: rgba(var(--accent-rgb), 0.9);
stroke-width: 2.2;
}
.area-map-skyline {
pointer-events: none;
opacity: 0.62;
filter: drop-shadow(0 6px 12px rgba(var(--ink-rgb), 0.12));
}
.area-map-tower-shadow {
fill: rgba(var(--ink-rgb), 0.08);
}
.area-map-tower-body,
.area-map-tower-observation,
.area-map-tower-band,
.area-map-tower-ring,
.area-map-tower-stem,
.area-map-tower-slit {
animation: areaTowerFloat 8s ease-in-out infinite;
transform-origin: 40.8px 74px;
}
.area-map-tower-body {
fill: rgba(var(--white-rgb), 0.92);
stroke: rgba(var(--ink-rgb), 0.24);
stroke-width: 1.05;
}
.area-map-tower-observation {
fill: rgba(var(--white-rgb), 0.96);
stroke: rgba(var(--ink-rgb), 0.3);
stroke-width: 1;
}
.area-map-tower-band {
fill: rgba(var(--ink-rgb), 0.72);
}
.area-map-tower-ring {
fill: rgba(var(--white-rgb), 0.98);
stroke: rgba(var(--ink-rgb), 0.26);
stroke-width: 0.9;
}
.area-map-tower-stem {
fill: rgba(var(--white-rgb), 0.84);
stroke: rgba(var(--ink-rgb), 0.2);
stroke-width: 0.85;
}
.area-map-tower-slit {
fill: rgba(var(--ink-rgb), 0.76);
}
.area-map-tower-beacon {
fill: var(--yellow);
opacity: 0.7;
animation: areaBeaconBlink 4.2s ease-in-out infinite;
}
.area-map-label {
position: absolute;
left: var(--desktop-left);
top: var(--desktop-top);
z-index: var(--desktop-z);
display: var(--desktop-display);
flex-direction: var(--desktop-direction);
align-items: center;
gap: clamp(6px, 0.85vw, 9px);
transform: var(--desktop-transform);
color: inherit;
text-decoration: none;
pointer-events: auto;
outline: none;
}
.area-map-label-static {
left: 53.75%;
top: 32.38%;
z-index: 2;
transform: translate(-50%, calc(-100% - 12px));
pointer-events: none;
}
.area-map-label-stem {
display: block;
flex: 0 0 auto;
width: var(--desktop-stem-w);
height: var(--desktop-stem-h);
border-radius: 999px;
background: rgba(var(--brand-rgb), 0.18);
transition: background var(--motion-fast);
}
.area-map-label-dot-wrap {
position: relative;
flex: 0 0 auto;
width: clamp(11px, 1.4vw, 14px);
height: clamp(11px, 1.4vw, 14px);
}
.area-map-label-dot {
position: absolute;
inset: 0;
margin: auto;
width: clamp(7px, 1vw, 9px);
height: clamp(7px, 1vw, 9px);
border-radius: 50%;
background: var(--gw-green);
border: 2px solid rgba(var(--white-rgb), 0.96);
box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.11);
transition:
transform var(--motion-fast),
background var(--motion-fast),
box-shadow var(--motion-fast);
}
.area-map-label-pulse {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(var(--brand-rgb), 0.14);
transform: scale(0.5);
opacity: 0;
animation: areaPinPulse 4.6s ease-out infinite;
animation-delay: var(--pin-delay);
}
.area-map-label-pill {
display: inline-flex;
align-items: center;
min-height: clamp(30px, 3vw, 34px);
padding: clamp(6px, 0.95vw, 8px) clamp(10px, 1.35vw, 14px);
border: 1px solid rgba(var(--brand-rgb), 0.12);
border-radius: 999px;
background: rgba(var(--white-rgb), 0.95);
color: var(--text-brand);
font-family: var(--font-head);
font-size: clamp(0.61rem, 0.52rem + 0.24vw, 0.78rem);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1;
white-space: nowrap;
box-shadow:
inset 0 1px 0 rgba(var(--white-rgb), 0.82),
0 8px 18px rgba(var(--ink-rgb), 0.06);
transition:
transform var(--motion-fast),
background var(--motion-fast),
border-color var(--motion-fast),
box-shadow var(--motion-fast),
color var(--motion-fast);
backdrop-filter: blur(8px);
}
.area-map-label-accent .area-map-label-pill {
background: rgba(var(--accent-rgb), 0.12);
border-color: rgba(var(--accent-rgb), 0.2);
}
.area-map-label-pill-city {
min-height: 28px;
padding: 6px 10px;
background: rgba(var(--brand-rgb), 0.1);
border-color: rgba(var(--brand-rgb), 0.16);
font-size: 0.66rem;
box-shadow: 0 6px 14px rgba(var(--ink-rgb), 0.05);
}
.area-map-label-dot-city {
background: var(--yellow);
box-shadow: 0 0 0 5px rgba(var(--brand-rgb), 0.1);
}
.area-map-label:hover .area-map-label-pill,
.area-map-label:focus-visible .area-map-label-pill {
background: var(--gw-green);
border-color: rgba(var(--brand-rgb), 0.18);
color: var(--text-inverse);
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(var(--brand-rgb), 0.14);
}
.area-map-label:hover .area-map-label-stem,
.area-map-label:focus-visible .area-map-label-stem {
background: rgba(var(--brand-rgb), 0.32);
}
.area-map-label:hover .area-map-label-dot,
.area-map-label:focus-visible .area-map-label-dot {
background: var(--yellow);
transform: scale(1.12);
box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.16);
}
.area-map-caption {
margin: 12px auto 0;
max-width: 54rem;
color: var(--text-subtle);
font-size: 13px;
line-height: 1.55;
text-align: center;
}
@keyframes areaCorePulse {
0% {
transform: scale(0.8);
opacity: 0.42;
}
72% {
transform: scale(1.2);
opacity: 0;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
@keyframes areaPinPulse {
0% {
transform: scale(0.52);
opacity: 0.32;
}
78% {
transform: scale(2.25);
opacity: 0;
}
100% {
transform: scale(2.25);
opacity: 0;
}
}
@keyframes areaRouteFlow {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -1.3;
}
}
@keyframes areaTowerFloat {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1.5px);
}
}
@keyframes areaBeaconBlink {
0%,
100% {
opacity: 0.3;
transform: scale(0.92);
}
50% {
opacity: 0.74;
transform: scale(1.04);
}
}
@media (max-width: 1120px) {
.area-map-shell {
padding-block: clamp(14px, 2vw, 20px);
}
.area-map-stage {
width: min(100%, 48rem);
}
.area-map-overlay {
inset: 16px 18px 18px;
}
.area-map-label {
left: var(--tablet-left);
top: var(--tablet-top);
z-index: var(--tablet-z);
display: var(--tablet-display);
flex-direction: var(--tablet-direction);
transform: var(--tablet-transform);
}
.area-map-label-stem {
width: var(--tablet-stem-w);
height: var(--tablet-stem-h);
}
}
@media (max-width: 768px) {
.area-map-stage {
width: 100%;
border-radius: 26px;
}
.area-map-overlay {
inset: 14px 16px 18px;
}
.area-map-label-pill {
box-shadow:
inset 0 1px 0 rgba(var(--white-rgb), 0.84),
0 6px 14px rgba(var(--ink-rgb), 0.06);
}
}
@media (max-width: 640px) {
.area-map-shell {
padding-inline: 4px;
padding-bottom: 14px;
}
.area-map-stage {
border-radius: 22px;
}
.area-map-overlay {
inset: 16px 14px 22px;
}
.area-map-label {
left: var(--mobile-left);
top: var(--mobile-top);
z-index: var(--mobile-z);
display: var(--mobile-display);
flex-direction: var(--mobile-direction);
gap: 5px;
transform: var(--mobile-transform);
}
.area-map-label-stem {
width: var(--mobile-stem-w);
height: var(--mobile-stem-h);
}
.area-map-label-pill {
min-height: 28px;
padding: 6px 9px;
font-size: clamp(0.56rem, 0.52rem + 0.18vw, 0.64rem);
}
.area-map-label-pill-city {
min-height: 26px;
padding-inline: 8px;
font-size: 0.6rem;
}
.area-map-label-dot-wrap {
width: 11px;
height: 11px;
}
.area-map-label-dot {
width: 7px;
height: 7px;
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1);
}
}
@media (max-width: 430px) {
.area-map-shell {
padding-inline: 0;
}
.area-map-overlay {
inset: 18px 12px 24px;
}
.area-map-label-pill {
min-height: 27px;
padding: 5px 8px;
font-size: 0.55rem;
letter-spacing: -0.012em;
}
.area-map-caption {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.area-map-route-flow,
.area-map-core-pulse,
.area-map-label-pulse,
.area-map-tower-body,
.area-map-tower-observation,
.area-map-tower-band,
.area-map-tower-ring,
.area-map-tower-stem,
.area-map-tower-slit,
.area-map-tower-beacon {
animation: none;
}
}
</style>
+13
View File
@@ -52,6 +52,10 @@
30+ five-star Google reviews
</a>
</div>
<p class="sh-credentials">
Walked by Alessandra · Pet first aid certified · Public liability insured
</p>
</div>
<!-- Right: full-height photo, no card, no shadow, bleeds to viewport edge -->
@@ -200,6 +204,15 @@
flex: 0 0 auto;
}
.sh-credentials {
margin: 18px 0 0;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
}
/* ── Photo column — fills full height, bleeds to right viewport edge ── */
.sh-media {
position: relative;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -36,7 +36,7 @@
lead: 'The Tiny Gang is built for dogs who love company, big adventures, and coming home happily worn out!',
cues: ['4-8 dogs', 'Pickup & drop-off', 'Tiny Gang matching']
},
'1:1 Walks': {
'Solo Walks': {
eyebrow: 'Tailored support',
imageUrl: '/images/goodwalk-brown-curly-dog-one-on-one-walk-auckland.webp',
imageAlt: 'Dog enjoying a one-on-one walk',
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import BookingSection from '$lib/components/BookingSection.svelte';
import BookingWizard from '$lib/components/BookingWizard.svelte';
import Icon from '$lib/components/Icon.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
@@ -147,7 +147,7 @@
</div>
</section>
<BookingSection booking={content.booking} variant="card-stepper" />
<BookingWizard booking={content.booking} pagePath="/testimonials" />
</main>
<style>
+2 -2
View File
@@ -53,8 +53,8 @@
{
imageUrl: '/images/goodwalk-dogs-group-outing-auckland.webp',
alt: 'Otis enjoying his Goodwalk routine in Auckland',
name: 'Otis',
detail: 'regular weekly walks'
name: 'Digby teaching tricks!',
detail: ''
},
{
imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
File diff suppressed because it is too large Load Diff
@@ -109,7 +109,7 @@
</label>
<label class="vf-choice">
<input type="radio" name={`${idPrefix}-service-fit`} value="one-to-one" />
<span>1:1 Walks</span>
<span>Solo Walks</span>
</label>
<label class="vf-choice">
<input type="radio" name={`${idPrefix}-service-fit`} value="puppy-visits" />