v4.1 - Admin/onboarding
This commit is contained in:
@@ -164,11 +164,13 @@
|
||||
<div class="page-inner">
|
||||
<CtaCard
|
||||
title={pageContent.contact.title}
|
||||
description="Questions, pricing, or your first Meet & Greet — start here and we'll reply within 24 hours."
|
||||
description="Questions, pricing, or a first Meet & Greet. Email, call, or send an Instagram DM. We'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -238,8 +238,8 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.faq summary,
|
||||
.faq details p {
|
||||
:global(.faq summary),
|
||||
:global(.faq details p) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user