v4.0.5
Component architecture - Reorganise src/lib/components into pages/, sections/, ui/ subdirectories and update all imports across routes and tests. Owner admin dashboard (+2k lines) - Scheduled welcome-pack emails: durable queue with cancel/reschedule and a background sender loop (SCHEDULED_CHECK_INTERVAL_SECONDS, default 60s). - Custom welcome-pack subject line, preview recipients, and client BCC. - Add-client, edit client profile, and reset-onboarding flows. - "View as client" onboarding preview (owner impersonation, dry-run submit). - Tabbed owner welcome route (/owner/welcome/[[tab]]). MYOB integration - New mail_api/myob.py: create new clients as MYOB AccountRight customer contacts. Disabled until all MYOB_* env vars are set (no-op otherwise). Onboarding - New "Does your dog resource guard?" Yes/No question in the Behaviour step. - Persist vetAddress, flea/tick, and pet-insurance fields server-side. Misc - vite config, new hooks.ts, responsive CSS tweaks, deploy/docker config, mail-api README and start-dev.ps1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import BookingSection from './BookingSection.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
async function fillOwnerStep() {
|
||||
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
|
||||
target: { value: 'Alex Walker' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/^Email/i), {
|
||||
target: { value: 'alex@example.com' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Phone number/i), {
|
||||
target: { value: '021 123 4567' }
|
||||
});
|
||||
}
|
||||
|
||||
async function fillDogStep() {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Your suburb/i), {
|
||||
target: { value: 'Kingsland' }
|
||||
});
|
||||
await fireEvent.click(screen.getByLabelText('Tiny Gang Pack Walks'));
|
||||
await fireEvent.input(screen.getByLabelText(/A bit about your dog/i), {
|
||||
target: { value: 'Loves small group walks.' }
|
||||
});
|
||||
}
|
||||
|
||||
async function moveToOwnerStep(container: HTMLElement) {
|
||||
await fillDogStep();
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
}
|
||||
|
||||
describe('BookingSection', () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
Object.defineProperty(document, 'referrer', {
|
||||
configurable: true,
|
||||
value: 'https://www.google.com/'
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the dog details step before progressing', async () => {
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your location')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates the owner details step before submitting', async () => {
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await moveToOwnerStep(container);
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
expect(screen.getByText('Please enter your full name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your contact number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the completed booking flow and shows the success modal', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await moveToOwnerStep(container);
|
||||
await fillOwnerStep();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/submit',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
enquiryType: 'booking',
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
petName: 'Maya',
|
||||
location: 'Kingsland',
|
||||
message: 'Loves small group walks.',
|
||||
services: ['Tiny Gang Pack Walks'],
|
||||
website: '',
|
||||
referrer: 'https://www.google.com/',
|
||||
stepChanges: 1,
|
||||
journey: [window.location.pathname]
|
||||
});
|
||||
expect(payload.formStartedAt).toEqual(expect.any(Number));
|
||||
expect(payload.visitStartedAt).toEqual(expect.any(Number));
|
||||
expect(payload.pageEnteredAt).toEqual(expect.any(Number));
|
||||
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
||||
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: /Booking confirmed/i })).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('allows general enquiries without dog or service details', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking,
|
||||
allowGeneralEnquiry: true
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText(/General enquiry/i));
|
||||
expect(screen.queryByLabelText(/Dog's Name/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Tiny Gang Pack Walks')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
expect(screen.getByText('Please tell us how we can help')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/How can we help/i), {
|
||||
target: { value: 'I would like to discuss a business partnership.' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
await fillOwnerStep();
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
enquiryType: 'general',
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
petName: '',
|
||||
location: '',
|
||||
message: 'I would like to discuss a business partnership.',
|
||||
services: [],
|
||||
stepChanges: 1,
|
||||
journey: [window.location.pathname]
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the API error message when submission fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: vi.fn().mockResolvedValue({ detail: 'Mail API unavailable' })
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await moveToOwnerStep(container);
|
||||
await fillOwnerStep();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
expect(await screen.findByText('Mail API unavailable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { accordion } from '$lib/actions/accordion';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { FaqItem } from '$lib/types';
|
||||
|
||||
export let title = 'FAQs';
|
||||
export let intro: string | undefined = undefined;
|
||||
export let faqs: FaqItem[];
|
||||
export let emitSchema = true;
|
||||
export let variant: 'panel' | 'plain' = 'panel';
|
||||
|
||||
$: schemaJson = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer
|
||||
}
|
||||
}))
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if emitSchema && faqs.length}
|
||||
{@html `<script type="application/ld+json">${schemaJson}<` + `/script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class:faq-section-plain={variant === 'plain'} class="faq-section">
|
||||
<h2 class="faq-section-heading">
|
||||
<span class="faq-section-icon"><Icon name="fas fa-circle-question" /></span>
|
||||
{title}
|
||||
</h2>
|
||||
{#if intro}
|
||||
<p class="faq-section-intro">{intro}</p>
|
||||
{/if}
|
||||
<div use:accordion class="faq">
|
||||
{#each faqs as faq}
|
||||
<details>
|
||||
<summary>{faq.question}</summary>
|
||||
<p>{faq.answer}</p>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.faq-section-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gw-green);
|
||||
box-shadow: 0 10px 22px rgba(33, 48, 33, 0.16);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.faq-section-icon :global(.icon) {
|
||||
color: var(--yellow);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.faq-section-heading {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.faq-section-intro {
|
||||
margin: 0 0 18px;
|
||||
color: #5b6067;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.faq-section-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-section-heading {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
font-size: clamp(22px, 5.6vw, 26px);
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.faq summary,
|
||||
.faq details p {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||
import type { FounderStoryContent } from '$lib/types';
|
||||
|
||||
export let founderStory: FounderStoryContent;
|
||||
|
||||
const founderTrustNotes = [
|
||||
'The same friendly face at the door',
|
||||
'Little groups, never a crowded van',
|
||||
'Updates that help you relax while you are out'
|
||||
];
|
||||
|
||||
const founderStoryParagraphs = [
|
||||
'Goodwalk started with my own little dog and the kind of relationship I have always had with animals. Growing up in Italy with a German Shepherd, I saw early on how much joy, comfort, personality, and companionship dogs bring into a home. They are not just pets. They become part of your family and your daily life.',
|
||||
'When I moved to Auckland, I noticed a lot of dog walking felt rushed, overcrowded, or impersonal, especially for smaller dogs. So I built Goodwalk around the kind of care I would want for my own dog: familiar faces, safe and social little groups, lots of fun, and genuine relationships with every dog we walk.',
|
||||
'The Tiny Gang is built around routine, trust, and dogs having the absolute best part of their day together. Older dogs help younger ones settle in, nervous dogs build confidence, and playful dogs get to burn energy with their friends.',
|
||||
'You know exactly who is caring for your dog. Your dog knows who is at the door. And you come home to a happy, fulfilled dog that has had a proper adventure. Ready to join the Tiny Gang?'
|
||||
];
|
||||
|
||||
$: founderStoryEnhanced = getEnhancedImage(founderStory.imageUrl);
|
||||
</script>
|
||||
|
||||
<section id="promise" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block" data-track-location="founder_story">
|
||||
<div class="founder-inner">
|
||||
<article class="founder-note">
|
||||
<div class="founder-intro fade-up">
|
||||
<span class="eyebrow founder-kicker">A note from 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 fade-up">
|
||||
<span class="founder-heading-main">{founderStory.title}</span>
|
||||
<span class="founder-heading-sub">Goodwalk is built around trust.</span>
|
||||
</h2>
|
||||
|
||||
<div class="founder-trust-strip fade-up" aria-label="What owners can expect from Goodwalk">
|
||||
<span class="founder-trust-label">What owners notice first</span>
|
||||
<ul class="founder-trust-list">
|
||||
{#each founderTrustNotes as note}
|
||||
<li>{note}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="founder-body fade-up">
|
||||
{#each founderStoryParagraphs as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="founder-closing fade-up">
|
||||
Ready to <strong>{founderStory.emphasis}</strong>
|
||||
</p>
|
||||
|
||||
<div class="founder-actions fade-up">
|
||||
<a class="founder-contact-note" href="mailto:info@goodwalk.co.nz" aria-label="Email Aless at Goodwalk" data-track-event="contact_click" data-track-type="founder_email" data-track-label="Founder email note">
|
||||
<span class="founder-contact-wave" aria-hidden="true">👋</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 cta-shimmer" data-track-event="cta_click" data-track-type="founder_story_primary" data-track-label={founderStory.cta.label}>
|
||||
{founderStory.cta.label}
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="founder-signoff fade-up">
|
||||
<div class="founder-signoff-copy">
|
||||
<p class="founder-signoff-name">Aless, founder of Goodwalk</p>
|
||||
<p class="founder-signoff-line">The same calm face at the door, the same trusted routine for your dog.</p>
|
||||
</div>
|
||||
|
||||
<div class="founder-media-card">
|
||||
{#if founderStoryEnhanced}
|
||||
<enhanced:img
|
||||
class="founder-portrait"
|
||||
src={founderStoryEnhanced}
|
||||
alt={founderStory.imageAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="founder-portrait"
|
||||
src={founderStory.imageUrl}
|
||||
alt={founderStory.imageAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#promise {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 980px;
|
||||
}
|
||||
|
||||
/* Narrative cascade — the article unfolds top-to-bottom in a steady,
|
||||
unhurried rhythm. Intentionally slow steps (~90ms) so the eye reads
|
||||
each block as it lands, not as a rush. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(.reveal-visible) .founder-intro { transition-delay: 40ms; }
|
||||
:global(.reveal-visible) .founder-heading { transition-delay: 130ms; }
|
||||
:global(.reveal-visible) .founder-trust-strip { transition-delay: 220ms; }
|
||||
:global(.reveal-visible) .founder-body { transition-delay: 310ms; }
|
||||
:global(.reveal-visible) .founder-closing { transition-delay: 400ms; }
|
||||
:global(.reveal-visible) .founder-actions { transition-delay: 490ms; }
|
||||
:global(.reveal-visible) .founder-signoff { transition-delay: 580ms; }
|
||||
}
|
||||
|
||||
.founder-inner {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.founder-media-card {
|
||||
overflow: hidden;
|
||||
width: min(100%, 168px);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, oklch(0.97 0.02 100) 0%, oklch(0.93 0.03 102) 100%);
|
||||
box-shadow: 0 16px 30px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.founder-portrait {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 0.86;
|
||||
object-fit: cover;
|
||||
object-position: center 24%;
|
||||
}
|
||||
|
||||
.founder-note {
|
||||
padding: clamp(32px, 4vw, 48px);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 34px;
|
||||
box-shadow: 0 24px 60px rgba(17, 20, 24, 0.06);
|
||||
}
|
||||
|
||||
.founder-intro {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* Layout-only override; typography and colour live on the shared
|
||||
.eyebrow utility. */
|
||||
.founder-kicker {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.founder-greeting {
|
||||
color: oklch(0.29 0.02 118);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(22px, 2.5vw, 28px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.founder-greeting-wave {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.founder-heading {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.founder-heading-main {
|
||||
display: block;
|
||||
color: oklch(0.23 0.02 136);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(28px, 3.6vw, 42px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.founder-heading-sub {
|
||||
display: block;
|
||||
max-width: 28ch;
|
||||
color: oklch(0.4 0.018 118);
|
||||
font-size: clamp(16px, 1.8vw, 20px);
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.founder-trust-strip {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0 0 28px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.founder-trust-label {
|
||||
color: var(--text-subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.founder-trust-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.founder-trust-list li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: oklch(0.35 0.017 118);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.founder-trust-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.65em;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--gw-green) 72%, white);
|
||||
}
|
||||
|
||||
.founder-body {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 67ch;
|
||||
}
|
||||
|
||||
.founder-body p {
|
||||
margin: 0;
|
||||
color: oklch(0.39 0.014 105);
|
||||
font-size: var(--body-copy-size);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.founder-closing {
|
||||
margin: 24px 0 0;
|
||||
color: oklch(0.24 0.018 136);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(18px, 2vw, 22px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.founder-closing strong {
|
||||
color: var(--gw-green);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.founder-actions {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
justify-items: start;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.founder-contact-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border-radius: 18px;
|
||||
color: oklch(0.33 0.018 118);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.founder-contact-wave {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-panel-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--border-soft);
|
||||
font-size: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.founder-cta {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.founder-signoff {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 22px;
|
||||
margin-top: 34px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.founder-signoff-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.founder-signoff-name {
|
||||
margin: 0;
|
||||
color: oklch(0.24 0.02 136);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(20px, 2vw, 24px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.founder-signoff-line {
|
||||
margin: 0;
|
||||
color: var(--text-subtle);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.founder-media-card:hover .founder-portrait {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.founder-portrait {
|
||||
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.founder-contact-note:hover {
|
||||
color: var(--gw-green);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.founder-inner {
|
||||
padding: 0 var(--space-container-x-mobile);
|
||||
}
|
||||
|
||||
.founder-note {
|
||||
padding: 26px 22px 24px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.founder-heading {
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.founder-body p {
|
||||
font-size: var(--body-copy-size-mobile);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.founder-media-card {
|
||||
border-radius: 24px;
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.founder-trust-strip {
|
||||
margin-bottom: 24px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.founder-trust-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.founder-trust-list li {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.founder-contact-note {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
padding: 11px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.founder-cta {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.founder-signoff {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.founder-signoff-copy {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { CallToAction, HeroContent } from '$lib/types';
|
||||
import { trackAb, type AbContext } from '$lib/ab';
|
||||
|
||||
export let hero: HeroContent;
|
||||
export let reviewCta: CallToAction | undefined = undefined;
|
||||
export let primaryCtaOverride: CallToAction | undefined = undefined;
|
||||
export let ab: AbContext | undefined = undefined;
|
||||
|
||||
$: primaryCta = primaryCtaOverride ?? hero.primaryCta;
|
||||
|
||||
onMount(() => {
|
||||
if (ab) trackAb({ ...ab, event_type: 'exposure', meta: { surface: 'hero' } });
|
||||
});
|
||||
|
||||
function handlePrimaryCtaClick() {
|
||||
if (ab) trackAb({ ...ab, event_type: 'cta_click', meta: { surface: 'hero_primary' } });
|
||||
}
|
||||
|
||||
$: titleParts = splitTitle(hero.title);
|
||||
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
|
||||
$: mobileLead = mobileTitle.includes(hero.highlight)
|
||||
? mobileTitle.slice(0, mobileTitle.lastIndexOf(hero.highlight))
|
||||
: mobileTitle;
|
||||
$: accessibleTitle = `${titleParts.lead}${titleParts.connector ? ` ${titleParts.connector}` : ''} ${hero.highlight}`.trim();
|
||||
$: proofItems = (hero.subtitleChips ?? []).slice(0, 3);
|
||||
|
||||
const trustStars = Array.from({ length: 5 });
|
||||
|
||||
function splitTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
|
||||
if (trimmed.toLowerCase().endsWith(' in')) {
|
||||
return {
|
||||
lead: trimmed.slice(0, -3),
|
||||
connector: 'in'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lead: trimmed,
|
||||
connector: ''
|
||||
};
|
||||
}
|
||||
|
||||
function linkTarget(external?: boolean) {
|
||||
return external ? '_blank' : undefined;
|
||||
}
|
||||
|
||||
function linkRel(external?: boolean) {
|
||||
return external ? 'noopener' : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="hero" data-track-location="hero">
|
||||
<!-- hero-img is a direct child of #hero so it can be absolutely
|
||||
positioned relative to the section on mobile without being
|
||||
constrained by hero-inner's stacking context -->
|
||||
<div class="hero-img">
|
||||
<picture>
|
||||
{#if hero.desktopImageWebpUrl}
|
||||
<source media="(min-width: 769px)" srcset={hero.desktopImageWebpUrl} type="image/webp" />
|
||||
{/if}
|
||||
{#if hero.desktopImageUrl}
|
||||
<source media="(min-width: 769px)" srcset={hero.desktopImageUrl} />
|
||||
{/if}
|
||||
{#if hero.imageWebpUrl}
|
||||
<source srcset={hero.imageWebpUrl} type="image/webp" />
|
||||
{/if}
|
||||
<img
|
||||
src={hero.imageUrl}
|
||||
alt={hero.imageAlt}
|
||||
width={hero.imageWidth ?? undefined}
|
||||
height={hero.imageHeight ?? undefined}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<div class="hero-inner">
|
||||
<div class="hero-text">
|
||||
{#if hero.kicker}
|
||||
<p class="hero-kicker">{hero.kicker}</p>
|
||||
{/if}
|
||||
|
||||
<h1 class="hero-heading">
|
||||
<span class="visually-hidden">{accessibleTitle}</span>
|
||||
<span class="hero-heading-desktop" aria-hidden="true">
|
||||
<span class="hero-title-main">{titleParts.lead}</span>
|
||||
{#if titleParts.connector}
|
||||
<span class="hero-title-connector"> {titleParts.connector}</span>
|
||||
{/if}
|
||||
<br />
|
||||
<span class="hero-title-highlight">{hero.highlight}</span>
|
||||
</span>
|
||||
<span class="hero-heading-mobile" aria-hidden="true">
|
||||
{mobileLead}<span class="hero-title-highlight">{hero.highlight}</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{#if hero.seoHeading}
|
||||
<h2 class="hero-seo-heading">{hero.seoHeading}</h2>
|
||||
{/if}
|
||||
|
||||
{#if hero.subtitle}
|
||||
<p class="hero-subtitle hero-subtitle-desktop">{hero.subtitle}</p>
|
||||
{/if}
|
||||
|
||||
{#if proofItems.length || reviewCta}
|
||||
<div class="hero-chips" aria-label="Why owners choose Goodwalk">
|
||||
{#each proofItems as chip}
|
||||
<span class="hero-chip">
|
||||
<Icon name={chip.icon} />
|
||||
{chip.label}
|
||||
</span>
|
||||
{/each}
|
||||
{#if reviewCta}
|
||||
<a
|
||||
class="hero-trust-chip"
|
||||
href={reviewCta.href}
|
||||
target={reviewCta.external ? '_blank' : undefined}
|
||||
rel={reviewCta.external ? 'noopener' : undefined}
|
||||
aria-label="Read our five-star Google reviews"
|
||||
data-track-event="social_proof_click"
|
||||
data-track-type="hero_reviews"
|
||||
data-track-label={reviewCta.label}
|
||||
>
|
||||
<span class="hero-trust-mark" aria-hidden="true">
|
||||
<img
|
||||
class="hero-trust-logo"
|
||||
src="/images/google-g-logo.svg"
|
||||
alt=""
|
||||
width="16"
|
||||
height="17"
|
||||
/>
|
||||
</span>
|
||||
<span class="hero-trust-stars" aria-hidden="true">
|
||||
{#each trustStars as _, index}
|
||||
<Icon name="fas fa-star" className={`hero-trust-star hero-trust-star-${index + 1}`} />
|
||||
{/each}
|
||||
</span>
|
||||
<span class="hero-trust-label">{reviewCta.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="hero-buttons">
|
||||
<a
|
||||
href={primaryCta.href}
|
||||
target={linkTarget(primaryCta.external)}
|
||||
rel={linkRel(primaryCta.external)}
|
||||
class="btn btn-yellow btn-with-arrow btn-hide-arrow-mobile"
|
||||
on:click={handlePrimaryCtaClick}
|
||||
data-track-event="cta_click"
|
||||
data-track-type="hero_primary"
|
||||
data-track-label={primaryCta.label}
|
||||
>
|
||||
{primaryCta.label}
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
<a
|
||||
href={hero.secondaryCta.href}
|
||||
target={linkTarget(hero.secondaryCta.external)}
|
||||
rel={linkRel(hero.secondaryCta.external)}
|
||||
class="hero-secondary-link"
|
||||
data-track-event="cta_click"
|
||||
data-track-type="hero_secondary"
|
||||
data-track-label={hero.secondaryCta.label}
|
||||
>
|
||||
{hero.secondaryCta.label}
|
||||
<Icon name="fas fa-arrow-down" className="hero-cta-arrow" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,391 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { HowItWorksContent } from '$lib/types';
|
||||
|
||||
export let content: HowItWorksContent;
|
||||
|
||||
const journeyChips = [
|
||||
{ icon: 'fas fa-handshake', label: 'Free Meet & Greet' },
|
||||
{ icon: 'fas fa-clipboard-check', label: 'Assessment walks' },
|
||||
{ icon: 'fas fa-calendar-check', label: 'Weekly rhythm' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section id="how-it-works" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block" data-track-location="how_it_works">
|
||||
<div class="hiw-inner">
|
||||
|
||||
<div class="section-header hiw-header fade-up">
|
||||
<span class="eyebrow hiw-eyebrow">Getting started</span>
|
||||
<h2 class="section-heading">{content.title}</h2>
|
||||
{#if content.intro}
|
||||
<p class="section-intro hiw-intro">{content.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="hiw-journey-bar stagger-children" aria-label="How getting started works">
|
||||
{#each journeyChips as chip}
|
||||
<span class="hiw-journey-pill fade-up">
|
||||
<Icon name={chip.icon} className="hiw-journey-icon" />
|
||||
{chip.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hiw-steps stagger-children">
|
||||
{#each content.steps as step, index}
|
||||
<div class="hiw-step fade-up">
|
||||
<div class="hiw-step-meta">
|
||||
<span class="hiw-phase">{step.phase}</span>
|
||||
<span class="hiw-num">0{index + 1}</span>
|
||||
</div>
|
||||
<div class="hiw-icon-wrap">
|
||||
<Icon name={step.icon ?? 'fas fa-paw'} className="hiw-step-icon" />
|
||||
</div>
|
||||
<h3 class="hiw-title">{step.title}</h3>
|
||||
<p class="hiw-body">{step.body}</p>
|
||||
{#if step.benefit}
|
||||
<span class="hiw-benefit">
|
||||
<Icon name="fas fa-check" className="hiw-check-icon" />
|
||||
{step.benefit}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hiw-cta fade-up">
|
||||
<a href="#newlead" class="btn btn-green btn-mobile-center btn-with-arrow cta-shimmer" data-track-event="cta_click" data-track-type="how_it_works_primary" data-track-label="Book your free Meet and Greet">
|
||||
Book your free Meet & Greet
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
<p class="hiw-cta-note">No obligation. We reply within 24 hours.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#how-it-works {
|
||||
background: var(--off-white);
|
||||
padding: var(--space-section-page-y) 0;
|
||||
}
|
||||
|
||||
.hiw-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-container-x);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.hiw-header {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
/* Layout-only override; typography lives on the shared .eyebrow utility. */
|
||||
.hiw-eyebrow {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.hiw-intro {
|
||||
max-width: 580px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Steps grid ── */
|
||||
.hiw-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
position: relative;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
/* Connector tracks through the vertical centre of the phase pills
|
||||
(card padding 40px + half pill height ~12px). Reads as a timeline
|
||||
running through the three steps, not a floating decorative rule. */
|
||||
.hiw-steps::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
left: 13%;
|
||||
right: 13%;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(var(--brand-rgb), 0.16),
|
||||
rgba(var(--accent-rgb), 0.4),
|
||||
rgba(var(--brand-rgb), 0.16)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hiw-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: clamp(28px, 2.6vw, 40px) clamp(24px, 2.6vw, 40px) clamp(26px, 2.4vw, 36px);
|
||||
background:
|
||||
radial-gradient(circle at top center, rgba(var(--accent-rgb), 0.12), transparent 34%),
|
||||
var(--surface-panel);
|
||||
border: 1px solid var(--border-soft-strong);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: box-shadow 0.22s ease, transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hiw-step:hover {
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-4px);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Step meta (phase + number) ── */
|
||||
.hiw-step-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.hiw-phase {
|
||||
display: inline-block;
|
||||
padding: 5px 13px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hiw-num {
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(var(--brand-rgb), 0.28);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Icon ── */
|
||||
.hiw-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 22px;
|
||||
border-radius: 20px;
|
||||
background: var(--surface-brand);
|
||||
box-shadow: var(--shadow-badge);
|
||||
}
|
||||
|
||||
.hiw-icon-wrap :global(.hiw-step-icon) {
|
||||
font-size: 26px;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.hiw-title {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: var(--heading-card-size);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.hiw-body {
|
||||
margin: 0 0 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hiw-benefit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-brand-muted);
|
||||
color: var(--gw-green);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hiw-benefit :global(.hiw-check-icon) {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── CTA ── */
|
||||
.hiw-cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 52px;
|
||||
}
|
||||
|
||||
.hiw-cta-note {
|
||||
margin: 0;
|
||||
color: var(--text-softest);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hiw-journey-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 14px auto 0;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.hiw-journey-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-brand);
|
||||
box-shadow:
|
||||
var(--shadow-inset-inverse),
|
||||
0 10px 22px rgba(var(--ink-rgb), 0.06);
|
||||
color: var(--text-inverse);
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hiw-journey-pill :global(.hiw-journey-icon) {
|
||||
color: var(--yellow);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.hiw-inner {
|
||||
padding: 0 var(--space-container-x-mobile);
|
||||
}
|
||||
|
||||
.hiw-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.hiw-intro {
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.hiw-journey-bar {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.hiw-journey-pill {
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hiw-steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.hiw-steps::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hiw-step {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: 28px 24px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-soft-strong);
|
||||
}
|
||||
|
||||
.hiw-step-meta {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hiw-icon-wrap {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hiw-icon-wrap :global(.hiw-step-icon) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.hiw-title {
|
||||
font-size: var(--heading-card-size-mobile);
|
||||
}
|
||||
|
||||
.hiw-body {
|
||||
font-size: var(--body-copy-size-mobile);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hiw-cta {
|
||||
margin-top: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reveal ── */
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 16px), 0);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
/* Choreography: when the section reveal fires, the inner tiers come in
|
||||
as a hierarchy — header settles first, journey pills follow, then
|
||||
step cards cascade, then the CTA tail. Each tier's own stagger still
|
||||
applies on top of these base offsets, so within a tier the children
|
||||
ripple as expected. Kept inside @media so reduced-motion users skip
|
||||
the cascade and see everything at rest immediately. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(.reveal-visible) .hiw-header {
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
:global(.reveal-visible) .hiw-journey-bar > :nth-child(1) { transition-delay: 160ms; }
|
||||
:global(.reveal-visible) .hiw-journey-bar > :nth-child(2) { transition-delay: 220ms; }
|
||||
:global(.reveal-visible) .hiw-journey-bar > :nth-child(3) { transition-delay: 280ms; }
|
||||
|
||||
:global(.reveal-visible) .hiw-steps > :nth-child(1) { transition-delay: 340ms; }
|
||||
:global(.reveal-visible) .hiw-steps > :nth-child(2) { transition-delay: 420ms; }
|
||||
:global(.reveal-visible) .hiw-steps > :nth-child(3) { transition-delay: 500ms; }
|
||||
|
||||
:global(.reveal-visible) .hiw-cta {
|
||||
transition-delay: 600ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import FaqSection from '$lib/components/sections/FaqSection.svelte';
|
||||
import { locationPages } from '$lib/content/locations';
|
||||
import type { InfoContent } from '$lib/types';
|
||||
|
||||
export let info: InfoContent;
|
||||
|
||||
const slugBySuburb = new Map(locationPages.map((loc) => [loc.suburb, loc.slug]));
|
||||
|
||||
$: suburbChips = info.suburbs
|
||||
.split(',')
|
||||
.map((suburb) => suburb.trim().replace(/\.$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((suburb) => ({ name: suburb, slug: slugBySuburb.get(suburb) ?? null }));
|
||||
</script>
|
||||
|
||||
<section id="info" data-track-location="info">
|
||||
<div class="info-inner">
|
||||
<div class="info-block">
|
||||
<h2>
|
||||
<span class="info-heading-icon"><Icon name="fas fa-location-dot" /></span>
|
||||
{info.title}
|
||||
</h2>
|
||||
<p class="info-lead">{info.intro}</p>
|
||||
<p class="info-support">Regular walks across the inner-west and nearby suburbs.</p>
|
||||
|
||||
<div class="info-suburb-chips" aria-label="Suburbs we cover">
|
||||
{#each suburbChips as { name, slug }}
|
||||
{#if slug}
|
||||
<a class="info-suburb-chip" href="/locations/{slug}">{name}</a>
|
||||
{:else}
|
||||
<span class="info-suburb-chip">{name}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="info-nearby-card">
|
||||
<div class="info-nearby-copy">
|
||||
<span class="info-nearby-kicker">Nearby but not listed?</span>
|
||||
<p>{info.nearbyText} There's a good chance we can still help.</p>
|
||||
</div>
|
||||
<a class="info-nearby-cta" href={info.nearbyCta.href} data-track-event="cta_click" data-track-type="info_nearby_suburb" data-track-label={info.nearbyCta.label}>{info.nearbyCta.label}</a>
|
||||
</div>
|
||||
|
||||
<div class="info-hours-card">
|
||||
<h3>{info.hoursLabel}</h3>
|
||||
<p>{info.hours}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<FaqSection title={info.faqTitle} faqs={info.faqs} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#info {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 1100px;
|
||||
}
|
||||
|
||||
.info-heading-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--gw-green);
|
||||
box-shadow: 0 10px 22px rgba(33, 48, 33, 0.16);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.info-heading-icon :global(.icon) {
|
||||
color: var(--yellow);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-lead {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-support {
|
||||
margin: 0 0 22px;
|
||||
color: #5f6369;
|
||||
}
|
||||
|
||||
.info-suburb-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.info-suburb-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
|
||||
0 10px 24px rgba(17, 20, 24, 0.04);
|
||||
color: var(--gw-green);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.info-suburb-chip:hover {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(33, 48, 33, 0.25),
|
||||
0 10px 24px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.info-nearby-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #fffaf0 0%, #f8f1e3 100%);
|
||||
box-shadow: 0 14px 30px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
|
||||
.info-nearby-copy p {
|
||||
margin: 6px 0 0;
|
||||
color: #4c5056;
|
||||
}
|
||||
|
||||
.info-nearby-kicker {
|
||||
display: inline-block;
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-nearby-cta {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--gw-green);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-hours-card {
|
||||
padding-top: 18px;
|
||||
border-top: 1px dashed rgba(33, 48, 33, 0.18);
|
||||
}
|
||||
|
||||
.info-hours-card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-heading-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info-hours-card p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-block {
|
||||
text-align: left;
|
||||
padding: 24px 20px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
|
||||
0 14px 30px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
|
||||
.info-lead,
|
||||
.info-support {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.info-block h2 {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
font-size: clamp(24px, 6.3vw, 28px);
|
||||
}
|
||||
|
||||
.info-heading-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 8px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.info-suburb-chips {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.info-suburb-chip {
|
||||
min-height: 44px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-nearby-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.info-nearby-cta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-hours-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(.faq summary),
|
||||
:global(.faq details p) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { HomePageContent } from '$lib/types';
|
||||
|
||||
export let instagram: HomePageContent['instagram'];
|
||||
|
||||
// Editorial layout — full-bleed photo with a dark scrim on the left.
|
||||
// Photo source is the existing tiny-gang pack walk image already used
|
||||
// elsewhere on the site, so no new assets are required.
|
||||
const editorialPhoto = '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp';
|
||||
const editorialPhotoAlt = 'Goodwalk Tiny Gang on a pack walk in Auckland';
|
||||
</script>
|
||||
|
||||
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
|
||||
<div class="ig-editorial">
|
||||
<div class="ig-editorial-photo">
|
||||
<img src={editorialPhoto} alt={editorialPhotoAlt} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
|
||||
<div class="ig-editorial-scrim">
|
||||
<div class="ig-editorial-copy">
|
||||
<span class="ig-editorial-source">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>{instagram.label}</span>
|
||||
</span>
|
||||
|
||||
<h2>{instagram.title}</h2>
|
||||
<p>Daily walks, real dogs, no filter. Park runs, pack moments, and the kind of updates you'd actually want to scroll.</p>
|
||||
|
||||
<a
|
||||
href={instagram.href}
|
||||
target={instagram.external ? '_blank' : undefined}
|
||||
rel={instagram.external ? 'noopener' : undefined}
|
||||
class="ig-editorial-cta"
|
||||
>
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>Follow {instagram.label}</span>
|
||||
<Icon name="fas fa-arrow-right" className="ig-editorial-arrow" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
#instagram {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 360px;
|
||||
padding: 32px 24px 56px;
|
||||
}
|
||||
|
||||
.ig-editorial {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
border-radius: 28px;
|
||||
aspect-ratio: 2.6 / 1;
|
||||
min-height: 280px;
|
||||
box-shadow: 0 30px 70px rgba(17, 20, 24, 0.16);
|
||||
}
|
||||
|
||||
.ig-editorial-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ig-editorial-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 60% 40%;
|
||||
transform: scale(1.02);
|
||||
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-editorial:hover .ig-editorial-photo img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.ig-editorial-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(15, 24, 15, 0.86) 0%,
|
||||
rgba(15, 24, 15, 0.74) 36%,
|
||||
rgba(15, 24, 15, 0.18) 70%,
|
||||
rgba(15, 24, 15, 0) 100%
|
||||
);
|
||||
padding: 48px clamp(28px, 4vw, 56px);
|
||||
}
|
||||
|
||||
.ig-editorial-copy {
|
||||
max-width: 480px;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ig-editorial-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 10px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ig-editorial-source :global(.icon) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Specificity bump: #instagram h2 (id+element) lives in the global
|
||||
typography.css and would otherwise win. Prefixing #instagram here
|
||||
matches that specificity so the editorial sizing applies. */
|
||||
#instagram .ig-editorial-copy h2 {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(26px, 3vw, 36px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.08;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ig-editorial-copy p {
|
||||
margin: 0 0 26px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.ig-editorial-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 13px 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 12px 24px rgba(255, 209, 0, 0.32);
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.ig-editorial-cta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 32px rgba(255, 209, 0, 0.38);
|
||||
}
|
||||
|
||||
.ig-editorial-cta :global(.ig-editorial-arrow) {
|
||||
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ig-editorial-cta:hover :global(.ig-editorial-arrow) {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* Mobile — stack the photo above the copy so neither gets crushed.
|
||||
Copy centers inside the green panel for a calmer vertical rhythm. */
|
||||
@media (max-width: 768px) {
|
||||
#instagram {
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
|
||||
.ig-editorial {
|
||||
aspect-ratio: auto;
|
||||
min-height: 0;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.ig-editorial-photo {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.ig-editorial-scrim {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
background: var(--gw-green);
|
||||
padding: 28px 22px 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ig-editorial-copy {
|
||||
max-width: none;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#instagram .ig-editorial-copy h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ig-editorial-copy p {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ig-editorial-photo img,
|
||||
.ig-editorial-cta {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ig-editorial-cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IntroContent } from '$lib/types';
|
||||
|
||||
export let intro: IntroContent;
|
||||
|
||||
const stars = Array.from({ length: 5 });
|
||||
|
||||
const statement = intro.text.replace(/\.$/, '');
|
||||
const statementWords = statement.split(/(\s+)/);
|
||||
</script>
|
||||
|
||||
<section id="intro" aria-label="Goodwalk at a glance">
|
||||
<div class="intro-inner">
|
||||
<div class="intro-statement">
|
||||
<span class="intro-kicker" aria-hidden="true">
|
||||
<span class="intro-kicker-rule"></span>
|
||||
Goodwalk · Auckland
|
||||
</span>
|
||||
<h2 class="intro-headline">
|
||||
{#each statementWords as token, index}
|
||||
{#if /\s+/.test(token)}
|
||||
{token}
|
||||
{:else}
|
||||
<span class="intro-word" style="--word-i: {index};">{token}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<aside class="intro-trust" aria-label="Reviews">
|
||||
<a
|
||||
class="intro-google"
|
||||
href={intro.reviewCta.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Read our Google reviews"
|
||||
>
|
||||
<span class="intro-google-mark" aria-hidden="true">
|
||||
<img
|
||||
class="intro-google-logo"
|
||||
src="/images/google-g-logo.svg"
|
||||
alt=""
|
||||
width="22"
|
||||
height="23"
|
||||
/>
|
||||
</span>
|
||||
<span class="intro-google-copy">
|
||||
<span class="intro-stars" aria-label="5 star rating">
|
||||
{#each stars as _, index}
|
||||
<Icon name="fas fa-star" className={`intro-star intro-star-${index + 1}`} />
|
||||
{/each}
|
||||
</span>
|
||||
<span class="intro-google-label">
|
||||
{intro.reviewCta.label}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<p class="intro-meta">
|
||||
<span class="intro-meta-dot" aria-hidden="true"></span>
|
||||
Auckland Central · Mon–Fri
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,377 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
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."
|
||||
: "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, unknown>; draft: Record<string, unknown>; cpAdmin?: boolean; ownerEmail?: string; previewEmails?: string[] } }>();
|
||||
|
||||
const ownerEmail = 'info@goodwalk.co.nz';
|
||||
const ownerPhone = '(022) 642 1011';
|
||||
|
||||
let stage: 'email' | 'code' = 'email';
|
||||
let emailValue = '';
|
||||
let codeValue = '';
|
||||
let loading = false;
|
||||
let error = '';
|
||||
|
||||
async function requestCode() {
|
||||
const trimmed = emailValue.trim();
|
||||
if (!trimmed) { error = 'Please enter your email address'; return; }
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/request-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: trimmed }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.detail ?? 'Failed to send code. Please try again.');
|
||||
stage = 'code';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode() {
|
||||
const trimmed = codeValue.trim();
|
||||
if (!trimmed) { error = 'Please enter the code'; return; }
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailValue.trim(), code: trimmed }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.detail ?? 'Incorrect code. Please try again.');
|
||||
try { window.localStorage.setItem('gw_onboarding_session', data.token); } catch { /* ignore */ }
|
||||
let profile: Record<string, string> = {};
|
||||
let draft: Record<string, unknown> = {};
|
||||
let cpAdmin = false;
|
||||
let verifiedOwnerEmail = '';
|
||||
let previewEmails: string[] = [];
|
||||
try {
|
||||
const verifyRes = await fetch('/api/auth/verify', {
|
||||
headers: { Authorization: `Bearer ${data.token}` },
|
||||
});
|
||||
if (verifyRes.ok) {
|
||||
const verifyData = await verifyRes.json();
|
||||
profile = verifyData.profile ?? {};
|
||||
draft = verifyData.draft ?? {};
|
||||
cpAdmin = Boolean(verifyData.cpAdmin);
|
||||
verifiedOwnerEmail = typeof verifyData.ownerEmail === 'string' ? verifyData.ownerEmail : '';
|
||||
previewEmails = Array.isArray(verifyData.previewEmails) ? verifyData.previewEmails.filter((value: unknown): value is string => typeof value === 'string') : [];
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
dispatch('authenticated', { email: data.email, profile, draft, cpAdmin, ownerEmail: verifiedOwnerEmail, previewEmails });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmailKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') requestCode();
|
||||
}
|
||||
|
||||
function handleCodeKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') verifyCode();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
stage = 'email';
|
||||
codeValue = '';
|
||||
error = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div class="auth-icon">
|
||||
<Icon name="fas fa-lock" />
|
||||
</div>
|
||||
|
||||
{#if stage === 'email'}
|
||||
<h2>Sign in to continue</h2>
|
||||
{#if context !== 'owner'}
|
||||
<p>{introText}</p>
|
||||
{/if}
|
||||
|
||||
<div class="auth-field">
|
||||
<label for="auth-email">Email address</label>
|
||||
<input
|
||||
id="auth-email"
|
||||
type="email"
|
||||
bind:value={emailValue}
|
||||
on:keydown={handleEmailKey}
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn btn-yellow auth-btn" on:click={requestCode} disabled={loading}>
|
||||
{#if loading}Sending…{:else}Send code <Icon name="fas fa-arrow-right" />{/if}
|
||||
</button>
|
||||
|
||||
{:else}
|
||||
<h2>Enter your code</h2>
|
||||
<p>We sent a 6-digit code to <strong>{emailValue}</strong>. It expires in 10 minutes.</p>
|
||||
|
||||
<div class="auth-field">
|
||||
<label for="auth-code">One-time code</label>
|
||||
<input
|
||||
id="auth-code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="6"
|
||||
bind:value={codeValue}
|
||||
on:keydown={handleCodeKey}
|
||||
placeholder="198604"
|
||||
autocomplete="one-time-code"
|
||||
disabled={loading}
|
||||
class="auth-code-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn btn-yellow auth-btn" on:click={verifyCode} disabled={loading}>
|
||||
{#if loading}Verifying…{:else}Verify code <Icon name="fas fa-arrow-right" />{/if}
|
||||
</button>
|
||||
|
||||
<button type="button" class="auth-back" on:click={goBack}>
|
||||
<Icon name="fas fa-arrow-left" /> Use a different email
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if context !== 'owner'}
|
||||
<div class="auth-help">
|
||||
<span>Need help?</span>
|
||||
<a href="mailto:{ownerEmail}">{ownerEmail}</a>
|
||||
<span>or</span>
|
||||
<a href="tel:{ownerPhone.replace(/[^0-9+]/g, '')}">{ownerPhone}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="auth-copyright">
|
||||
<a href="https://goodwalk.co.nz">goodwalk.co.nz</a>
|
||||
<span>·</span>
|
||||
<span>© {new Date().getFullYear()} Goodwalk. All rights reserved.</span>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.auth-wrap {
|
||||
padding: 32px 28px 64px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 36px 32px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(33, 48, 33, 0.08);
|
||||
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.09);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffe36b 0%, #ffd100 100%);
|
||||
color: #213021;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-card h2 {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(22px, 3vw, 30px);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #213021;
|
||||
}
|
||||
|
||||
.auth-card p {
|
||||
margin: 0 0 24px;
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
color: rgba(33, 48, 33, 0.72);
|
||||
}
|
||||
|
||||
.auth-card p strong {
|
||||
color: #213021;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-field label {
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: #213021;
|
||||
}
|
||||
|
||||
.auth-field input {
|
||||
width: 100%;
|
||||
padding: 15px 16px;
|
||||
border: 1px solid rgba(33, 48, 33, 0.14);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
color: #213021;
|
||||
outline: none;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.auth-field input:focus {
|
||||
border-color: rgba(255, 209, 0, 0.9);
|
||||
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16);
|
||||
}
|
||||
|
||||
.auth-code-input {
|
||||
font-size: 28px !important;
|
||||
font-family: var(--font-head) !important;
|
||||
font-weight: 800 !important;
|
||||
letter-spacing: 0.22em !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff3ef;
|
||||
color: #a43f2c;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(33, 48, 33, 0.12);
|
||||
background: transparent;
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(33, 48, 33, 0.65);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 20px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.auth-back:hover {
|
||||
background: rgba(33, 48, 33, 0.05);
|
||||
}
|
||||
|
||||
.auth-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(33, 48, 33, 0.07);
|
||||
font-size: 13px;
|
||||
color: rgba(33, 48, 33, 0.5);
|
||||
}
|
||||
|
||||
.auth-help a {
|
||||
color: rgba(33, 48, 33, 0.75);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-help a:hover {
|
||||
color: #213021;
|
||||
}
|
||||
|
||||
.auth-copyright {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 28px;
|
||||
background: #fff;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.07);
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.auth-copyright a {
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-copyright a:hover {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-wrap {
|
||||
padding: 20px 18px 32px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 26px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||
import type { CallToAction, HeroChip } from '$lib/types';
|
||||
|
||||
export let eyebrow: string;
|
||||
export let title: string;
|
||||
export let subtitle: string | undefined = undefined;
|
||||
export let imageUrl: string;
|
||||
export let imageAlt: string;
|
||||
export let chips: HeroChip[] = [];
|
||||
export let cta: CallToAction | undefined = undefined;
|
||||
|
||||
const reviewHref = 'https://g.page/r/CUsvrWPhkYrAEB0/';
|
||||
|
||||
$: enhanced = getEnhancedImage(imageUrl);
|
||||
</script>
|
||||
|
||||
<section class="sh">
|
||||
|
||||
<!-- Left: brand green copy column -->
|
||||
<div class="sh-copy">
|
||||
<p class="sh-eyebrow">{eyebrow}</p>
|
||||
<h1 class="sh-title">{title}</h1>
|
||||
{#if subtitle}
|
||||
<p class="sh-subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
|
||||
{#if chips.length}
|
||||
<div class="sh-chips">
|
||||
{#each chips as chip}
|
||||
<span class="sh-chip">
|
||||
<Icon name={chip.icon} />
|
||||
{chip.label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sh-actions">
|
||||
{#if cta}
|
||||
<a href={cta.href} class="btn btn-yellow sh-cta">{cta.label}</a>
|
||||
{/if}
|
||||
<a
|
||||
href={reviewHref}
|
||||
class="sh-trust"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Read our Google reviews"
|
||||
>
|
||||
<span class="sh-stars" aria-hidden="true">★★★★★</span>
|
||||
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 -->
|
||||
<div class="sh-media">
|
||||
{#if enhanced}
|
||||
<enhanced:img
|
||||
src={enhanced}
|
||||
alt={imageAlt}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ── Full-bleed split — green bleeds left, photo bleeds right, content stays centred ── */
|
||||
.sh {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Copy column ──
|
||||
Left padding uses --sh-copy-left-pad so ultrawide overrides can live
|
||||
entirely in responsive.css without touching this component. ── */
|
||||
.sh-copy {
|
||||
background: var(--gw-green);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 80px 56px 80px var(--sh-copy-left-pad, max(40px, calc(50vw - 596px)));
|
||||
}
|
||||
|
||||
/* Subtle yellow warmth on the copy side */
|
||||
.sh-copy::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 90% 80% at 5% 65%, rgba(255, 209, 0, 0.09) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Service name in Goodwalk Yellow */
|
||||
.sh-eyebrow {
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.sh-title {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(30px, 3.2vw, 50px);
|
||||
font-weight: 800;
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.sh-subtitle {
|
||||
margin: 0 0 26px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
/* ── Chips ── */
|
||||
.sh-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.sh-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── CTA row ── */
|
||||
.sh-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sh-cta {
|
||||
font-size: 15px;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.sh-trust {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.sh-trust:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.sh-stars {
|
||||
color: var(--yellow);
|
||||
letter-spacing: 2px;
|
||||
font-size: 12px;
|
||||
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;
|
||||
overflow: hidden;
|
||||
/* Minimum height so the section never collapses on short copy */
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.sh-media::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: clamp(28px, 4.5vw, 76px);
|
||||
background:
|
||||
radial-gradient(circle at left center, rgba(255, 209, 0, 0.1) 0%, rgba(255, 209, 0, 0.04) 26%, transparent 62%),
|
||||
linear-gradient(90deg, rgba(33, 48, 33, 0.76) 0%, rgba(33, 48, 33, 0.34) 46%, rgba(33, 48, 33, 0.08) 78%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sh-media :global(picture) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sh-media :global(img) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 25%;
|
||||
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sh-media:hover :global(img) {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* ── Tablet — formula already floors at 40px here, just reduce vertical padding ── */
|
||||
@media (max-width: 1024px) {
|
||||
.sh-copy {
|
||||
padding-top: 64px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile — stack vertically, photo above copy ── */
|
||||
@media (max-width: 768px) {
|
||||
.sh {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Photo goes first on mobile — visual hook before the pitch */
|
||||
.sh-media {
|
||||
order: 1;
|
||||
min-height: 0;
|
||||
aspect-ratio: 3 / 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sh-media::before {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.sh-copy {
|
||||
order: 2;
|
||||
padding: 44px 24px 48px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sh-title {
|
||||
font-size: clamp(28px, 7.5vw, 38px);
|
||||
}
|
||||
|
||||
.sh-subtitle {
|
||||
font-size: 15px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sh-chips {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sh-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sh-cta {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,469 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let services: IconCard[];
|
||||
export let heading = 'Find the walk that fits your dog.';
|
||||
export let intro =
|
||||
"The Tiny Gang is your dog's friendship group. Older dogs guide the youngsters; playful pairs burn energy together. The fun of doggy daycare, without the crowd or the price tag.";
|
||||
|
||||
const sharedPromises = [
|
||||
'Familiar walkers',
|
||||
'Small-scale care',
|
||||
'Reliable pickup & drop-off',
|
||||
'Updates you will actually want'
|
||||
];
|
||||
|
||||
// Lightweight presentation metadata — the card only needs to say *what*
|
||||
// each service is before the visitor opens the full service page.
|
||||
const serviceMeta: Record<
|
||||
string,
|
||||
{
|
||||
eyebrow: string;
|
||||
featured?: boolean;
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
lead: string;
|
||||
cues: string[];
|
||||
}
|
||||
> = {
|
||||
'Tiny Gang Pack Walks': {
|
||||
eyebrow: 'Good Walk Signature',
|
||||
featured: true,
|
||||
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-small-dogs-auckland.webp',
|
||||
imageAlt: 'Small dogs together on a Tiny Gang pack walk',
|
||||
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']
|
||||
},
|
||||
'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',
|
||||
lead: 'For nervous dogs, senior dogs, and little personalities who do better with extra attention.',
|
||||
cues: ['Solo focus', 'Custom pace', 'Confidence building']
|
||||
},
|
||||
'Puppy Visits': {
|
||||
eyebrow: 'Building Blocks For The Tiny Gang',
|
||||
imageUrl: '/images/goodwalk-puppy-visit-cavalier-king-charles-spaniel-auckland.webp',
|
||||
imageAlt: 'Puppy during a calm home visit',
|
||||
lead: 'Early puppy visits designed to build confidence, routine, and good habits before Tiny Gang adventures begin!',
|
||||
cues: ['Home visits', 'Routine support', 'Play & company']
|
||||
}
|
||||
};
|
||||
|
||||
$: orderedServices = services
|
||||
.map((service, index) => ({ service, index }))
|
||||
.sort((a, b) => {
|
||||
const aFeatured = serviceMeta[a.service.title]?.featured ? 0 : 1;
|
||||
const bFeatured = serviceMeta[b.service.title]?.featured ? 0 : 1;
|
||||
|
||||
if (aFeatured !== bFeatured) {
|
||||
return aFeatured - bFeatured;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ service }) => service);
|
||||
</script>
|
||||
|
||||
<section id="services" use:reveal={{ delay: 20, distance: 0 }} class="reveal-block" data-track-location="services">
|
||||
<div class="services-inner">
|
||||
<div class="section-header fade-up">
|
||||
<h2 class="section-heading">{heading}</h2>
|
||||
<p class="section-intro services-intro">{intro}</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="services-grid stagger-children">
|
||||
{#each orderedServices as service}
|
||||
{@const meta = serviceMeta[service.title]}
|
||||
<a
|
||||
href={service.href}
|
||||
class:service-card-featured={meta?.featured}
|
||||
class="service-card fade-up"
|
||||
aria-label={`${service.title} — view service page`}
|
||||
data-track-event="service_card_click"
|
||||
data-track-type={meta?.featured ? 'featured_service_card' : 'service_card'}
|
||||
data-track-label={service.title}
|
||||
>
|
||||
<div class="service-card-media">
|
||||
{#if meta}
|
||||
<img src={meta.imageUrl} alt={meta.imageAlt} loading="lazy" decoding="async" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="service-card-body">
|
||||
<span class="service-card-emblem">
|
||||
<Icon name={service.icon} className="service-card-emblem-glyph" />
|
||||
</span>
|
||||
|
||||
{#if meta?.eyebrow}
|
||||
<span class="service-card-eyebrow">{meta.eyebrow}</span>
|
||||
{/if}
|
||||
<h3>{service.title}</h3>
|
||||
<p>{meta?.lead ?? service.body}</p>
|
||||
|
||||
{#if meta?.cues?.length}
|
||||
<div class="service-card-cues">
|
||||
{#each meta.cues as cue}
|
||||
<span class="service-card-cue">{cue}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="service-card-cta">
|
||||
More info
|
||||
<Icon name="fas fa-arrow-right" className="service-card-cta-arrow" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.services-inner {
|
||||
max-width: min(1180px, calc(var(--max-w) - 40px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.85fr);
|
||||
align-items: start;
|
||||
column-gap: clamp(32px, 5vw, 72px);
|
||||
row-gap: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header .section-heading {
|
||||
max-width: 22ch;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header .section-intro {
|
||||
margin: 0;
|
||||
padding-top: 14px;
|
||||
max-width: 44ch;
|
||||
justify-self: end;
|
||||
text-align: left;
|
||||
line-height: 1.72;
|
||||
color: var(--text-heading-soft, var(--text-muted));
|
||||
}
|
||||
|
||||
/* ── Section intro ── */
|
||||
.services-intro {
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/* ── Service cards ── */
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(17, 20, 24, 0.07);
|
||||
box-shadow: 0 6px 20px rgba(17, 20, 24, 0.05);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition:
|
||||
box-shadow 0.28s ease,
|
||||
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 0.28s ease;
|
||||
}
|
||||
|
||||
/* Featured emphasis lives in the chrome (border + glow + emblem shine),
|
||||
not the column span — keeps all three cards visually balanced. */
|
||||
.service-card-featured {
|
||||
border-color: rgba(242, 191, 47, 0.45);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 209, 0, 0.22),
|
||||
0 10px 26px rgba(17, 20, 24, 0.07);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.service-card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(33, 48, 33, 0.16);
|
||||
box-shadow: 0 22px 46px rgba(17, 20, 24, 0.13);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.service-card:hover .service-card-media img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.service-card:hover :global(.service-card-cta-arrow) {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.service-card:hover .service-card-emblem::after,
|
||||
.service-card:focus-visible .service-card-emblem::after,
|
||||
.service-card:active .service-card-emblem::after {
|
||||
animation: serviceEmblemShine 0.9s cubic-bezier(0.22, 1, 0.36, 1) 1;
|
||||
}
|
||||
}
|
||||
|
||||
.service-card:active {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.service-card-media {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: #ede4d2;
|
||||
}
|
||||
|
||||
.service-card-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.service-card-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 42px 26px 26px;
|
||||
}
|
||||
|
||||
/* Brand emblem straddles the photo / body seam */
|
||||
.service-card-emblem {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 24px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: var(--gw-green);
|
||||
box-shadow: 0 10px 22px rgba(33, 48, 33, 0.26);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-card-emblem :global(.service-card-emblem-glyph) {
|
||||
font-size: 22px;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.service-card-emblem::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20%;
|
||||
left: -85%;
|
||||
width: 60%;
|
||||
height: 140%;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.18) 35%,
|
||||
rgba(255, 255, 255, 0.65) 50%,
|
||||
rgba(255, 255, 255, 0.18) 65%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
transform: rotate(14deg);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.service-card-eyebrow {
|
||||
margin-bottom: 8px;
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-card-body h3 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
color: #0d1a0d;
|
||||
}
|
||||
|
||||
.service-card-body p {
|
||||
margin: 0;
|
||||
color: #4c5056;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Push cues+CTA cluster to the bottom so it lines up across all cards
|
||||
regardless of how long each card's lead paragraph runs. */
|
||||
.service-card-cues {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: auto;
|
||||
padding-top: 18px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.service-card-cue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.06);
|
||||
box-shadow: inset 0 0 0 1px rgba(33, 48, 33, 0.07);
|
||||
color: var(--gw-green);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.service-card-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.service-card-cta-arrow) {
|
||||
font-size: 11px;
|
||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 1024px) {
|
||||
.section-header {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-header .section-heading,
|
||||
.section-header .section-intro {
|
||||
max-width: 32rem;
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-header .section-intro {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.services-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* Featured card takes the full width on tablet so it sits on its own
|
||||
row above the other two — keeps the emphasis without the asymmetric
|
||||
desktop grid. */
|
||||
.service-card-featured {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.services-intro {
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.services-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.service-card-featured {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.service-card-body {
|
||||
padding: 40px 22px 24px;
|
||||
}
|
||||
|
||||
.service-card-emblem {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.service-card-emblem :global(.service-card-emblem-glyph) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.service-card-body h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes serviceEmblemShine {
|
||||
0% {
|
||||
left: -85%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
82% {
|
||||
left: 130%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 130%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reveal ── */
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 16px), 0);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
/* Tier choreography — header settles, then the three cards cascade. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(.reveal-visible) .section-header {
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
:global(.reveal-visible) .services-grid > :nth-child(1) { transition-delay: 180ms; }
|
||||
:global(.reveal-visible) .services-grid > :nth-child(2) { transition-delay: 260ms; }
|
||||
:global(.reveal-visible) .services-grid > :nth-child(3) { transition-delay: 340ms; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,796 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||
import { getSeededTestimonialIndex } from '$lib/testimonials';
|
||||
import type { TestimonialContent } from '$lib/types';
|
||||
|
||||
export let testimonials: TestimonialContent[];
|
||||
export let eyebrow = '30+ five-star reviews';
|
||||
export let heading = 'What owners notice first';
|
||||
export let blurb = 'Happier dogs. Calmer evenings. A routine that feels easier to trust and easier to keep.';
|
||||
export let testimonialsHref = '/testimonials';
|
||||
export let googleReviewsHref = 'https://g.page/r/CUsvrWPhkYrAEB0/';
|
||||
export let seedKey = '';
|
||||
|
||||
type TestimonialSlide = TestimonialContent & { imageUrl: string };
|
||||
|
||||
const wordpressTestimonials: Record<string, TestimonialSlide> = {
|
||||
Kate: {
|
||||
reviewer: 'Kate',
|
||||
detail: "Archie's mum",
|
||||
type: 'Google',
|
||||
showInSlider: true,
|
||||
quote:
|
||||
'Love Aless! She is so amazing with my slightly hyper and anxious dog. She is great with communication if anything on either of our ends need to change. Archie love his walks, and I love the photos she posts of him.',
|
||||
imageUrl: '/images/archie-goodwalk-dog-walking-review-auckland.webp'
|
||||
},
|
||||
Estelle: {
|
||||
reviewer: 'Estelle',
|
||||
detail: "Monty's mum",
|
||||
type: 'Google',
|
||||
showInSlider: true,
|
||||
quote:
|
||||
'GoodWalk was the best dog walking service for my little pooch ! Aless was very helpful - basically doubled as a second mum to Monty. She always provided feedback on his outings and assisted where possible with any additional training that she felt he could work on and made recommendations where necessary which i feel is what every dog mum wants and needs!',
|
||||
imageUrl: '/images/monty-goodwalk-dog-walking-review-auckland.webp'
|
||||
},
|
||||
Ross: {
|
||||
reviewer: 'Ross',
|
||||
detail: "Otis's dad",
|
||||
type: 'Google',
|
||||
showInSlider: true,
|
||||
quote:
|
||||
'Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, there’s a reason she has 5 stars!',
|
||||
imageUrl: '/images/otis-goodwalk-dog-walking-review-auckland.webp'
|
||||
},
|
||||
Nina: {
|
||||
reviewer: 'Nina',
|
||||
detail: "Wallace's mum",
|
||||
type: 'Google',
|
||||
showInSlider: true,
|
||||
quote:
|
||||
'Alessandra has been walking and spending time with my pup since she was 10 weeks old, coming over and doing puppy visits through to transitioning her to pack walks with her little doggo friends. I know Alassandra loves and cares for my dog as much as I do and my dog has a great time! Cant recommend enough',
|
||||
imageUrl: '/images/wallace-goodwalk-dog-walking-review-auckland.webp'
|
||||
}
|
||||
};
|
||||
|
||||
let activeIndex = 0;
|
||||
let paused = false;
|
||||
let inView = false;
|
||||
let prefersReducedMotion = false;
|
||||
let carouselEl: HTMLDivElement | undefined;
|
||||
let stageEl: HTMLDivElement | undefined;
|
||||
let slideSignature = '';
|
||||
|
||||
$: slides = testimonials
|
||||
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
|
||||
.filter((testimonial) => testimonial.showInSlider)
|
||||
.filter((testimonial): testimonial is TestimonialSlide => Boolean(testimonial.imageUrl));
|
||||
|
||||
$: if (activeIndex >= slides.length) {
|
||||
activeIndex = 0;
|
||||
}
|
||||
|
||||
$: {
|
||||
const nextSignature = `${seedKey}:${slides.map((slide) => slide.reviewer).join('|')}`;
|
||||
|
||||
if (nextSignature !== slideSignature) {
|
||||
slideSignature = nextSignature;
|
||||
activeIndex = getSeededTestimonialIndex(slides, seedKey);
|
||||
}
|
||||
}
|
||||
|
||||
function dogNameFromDetail(detail: string) {
|
||||
const match = detail.match(/^([^'’]+)/);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
function testimonialAlt(testimonial: TestimonialSlide) {
|
||||
const dog = dogNameFromDetail(testimonial.detail);
|
||||
return dog
|
||||
? `${dog}, a happy Goodwalk dog walking client in Auckland`
|
||||
: `${testimonial.reviewer}'s dog after a Goodwalk Auckland dog walk`;
|
||||
}
|
||||
|
||||
function showPrevious() {
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
|
||||
syncMobileStage();
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex + 1) % slides.length;
|
||||
syncMobileStage();
|
||||
}
|
||||
|
||||
function isMobileViewport() {
|
||||
return typeof window !== 'undefined' && window.innerWidth <= 767;
|
||||
}
|
||||
|
||||
async function syncMobileStage(behavior: ScrollBehavior = 'smooth') {
|
||||
if (!stageEl || !isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tick();
|
||||
stageEl.scrollTo({
|
||||
left: stageEl.clientWidth * activeIndex,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
function handleStageScroll() {
|
||||
if (!stageEl || !isMobileViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = Math.round(stageEl.scrollLeft / Math.max(stageEl.clientWidth, 1));
|
||||
if (nextIndex !== activeIndex) {
|
||||
activeIndex = nextIndex;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
prefersReducedMotion = motionQuery.matches;
|
||||
const onMotionChange = (event: MediaQueryListEvent) => {
|
||||
prefersReducedMotion = event.matches;
|
||||
};
|
||||
motionQuery.addEventListener('change', onMotionChange);
|
||||
|
||||
const observer = carouselEl
|
||||
? new IntersectionObserver(
|
||||
([entry]) => {
|
||||
inView = entry.isIntersecting;
|
||||
},
|
||||
{ threshold: 0.25 }
|
||||
)
|
||||
: null;
|
||||
|
||||
if (observer && carouselEl) {
|
||||
observer.observe(carouselEl);
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
syncMobileStage('auto');
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
syncMobileStage('auto');
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
if (!isMobileViewport() && !paused && !prefersReducedMotion && inView && slides.length > 1) {
|
||||
showNext();
|
||||
}
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
motionQuery.removeEventListener('change', onMotionChange);
|
||||
observer?.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="testimonials" use:reveal={{ delay: 40, distance: 0 }} class="reveal-block">
|
||||
<div class="testimonials-inner">
|
||||
<div class="testimonials-header">
|
||||
<div class="testimonials-header-main fade-up">
|
||||
<span class="eyebrow testimonials-eyebrow">
|
||||
<Icon name="fas fa-star" className="testimonials-eyebrow-star" />
|
||||
{eyebrow}
|
||||
</span>
|
||||
<h2 class="section-heading">{heading}</h2>
|
||||
</div>
|
||||
|
||||
<div class="testimonials-header-side fade-up">
|
||||
<div class="testimonials-intro">
|
||||
<p>{blurb}</p>
|
||||
</div>
|
||||
|
||||
<div class="testimonials-cta-row">
|
||||
<a href={testimonialsHref} class="btn btn-yellow testimonials-cta testimonials-cta-primary cta-shimmer">
|
||||
<Icon name="fas fa-comment-dots" />
|
||||
<span class="testimonials-cta-label-desktop">All testimonials</span>
|
||||
<span class="testimonials-cta-label-mobile">Testimonials</span>
|
||||
</a>
|
||||
<a href={googleReviewsHref} target="_blank" rel="noopener" class="btn btn-outline-green testimonials-cta testimonials-cta-secondary">
|
||||
<img class="testimonials-cta-logo" src="/images/google-g-logo.svg" alt="" width="16" height="17" />
|
||||
<span class="testimonials-cta-label-desktop">Google reviews</span>
|
||||
<span class="testimonials-cta-label-mobile">Google</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if slides.length}
|
||||
<div
|
||||
bind:this={carouselEl}
|
||||
class="testimonials-carousel scale-soft"
|
||||
role="region"
|
||||
aria-label="Customer testimonials"
|
||||
on:mouseenter={() => (paused = true)}
|
||||
on:mouseleave={() => (paused = false)}
|
||||
on:focusin={() => (paused = true)}
|
||||
on:focusout={() => (paused = false)}
|
||||
>
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-left"
|
||||
type="button"
|
||||
aria-label="Previous testimonial"
|
||||
on:click={showPrevious}
|
||||
>
|
||||
<Icon name="fas fa-chevron-left" />
|
||||
</button>
|
||||
|
||||
<div bind:this={stageEl} class="testimonial-stage" on:scroll={handleStageScroll}>
|
||||
{#each slides as testimonial, index}
|
||||
<article class:testimonial-slide-active={index === activeIndex} class="testimonial-slide">
|
||||
<div class="testimonial-photo-wrap">
|
||||
<div class="testimonial-photo-frame">
|
||||
{#if index === activeIndex}
|
||||
{@const enhancedPhoto = getEnhancedImage(testimonial.imageUrl)}
|
||||
{#if enhancedPhoto}
|
||||
<enhanced:img
|
||||
class="testimonial-photo"
|
||||
src={enhancedPhoto}
|
||||
alt={testimonialAlt(testimonial)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="testimonial-photo"
|
||||
src={testimonial.imageUrl}
|
||||
alt={testimonialAlt(testimonial)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="testimonial-copy">
|
||||
<span class="testimonial-quote-mark">"</span>
|
||||
<blockquote class="testimonial-quote">{testimonial.quote}</blockquote>
|
||||
<div class="testimonial-author">
|
||||
<span class="testimonial-author-name">{testimonial.reviewer}</span>
|
||||
<span class="testimonial-author-detail">{testimonial.detail}</span>
|
||||
</div>
|
||||
|
||||
<div class="testimonial-divider"></div>
|
||||
|
||||
<div class="testimonial-mobile-controls" aria-label="Testimonial navigation">
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-inline"
|
||||
type="button"
|
||||
aria-label="Previous testimonial"
|
||||
on:click={showPrevious}
|
||||
>
|
||||
<Icon name="fas fa-chevron-left" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-inline"
|
||||
type="button"
|
||||
aria-label="Next testimonial"
|
||||
on:click={showNext}
|
||||
>
|
||||
<Icon name="fas fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-right"
|
||||
type="button"
|
||||
aria-label="Next testimonial"
|
||||
on:click={showNext}
|
||||
>
|
||||
<Icon name="fas fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#testimonials {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 980px;
|
||||
}
|
||||
|
||||
.testimonials-inner {
|
||||
max-width: min(1220px, calc(var(--max-w) - 24px));
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-container-x);
|
||||
}
|
||||
|
||||
.testimonials-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 0.85fr);
|
||||
align-items: end;
|
||||
gap: clamp(24px, 4vw, 56px);
|
||||
}
|
||||
|
||||
.testimonials-header-main,
|
||||
.testimonials-header-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.testimonials-header-main {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.testimonials-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
margin: 0 auto 14px;
|
||||
}
|
||||
|
||||
.testimonials-eyebrow :global(.testimonials-eyebrow-star) {
|
||||
color: var(--yellow);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.testimonials-header .section-heading {
|
||||
text-align: center;
|
||||
max-width: 11ch;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.testimonials-intro {
|
||||
max-width: 34ch;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.testimonials-intro p {
|
||||
margin: 0;
|
||||
color: #4c5056;
|
||||
font-size: var(--body-lead-size);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.testimonials-cta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.testimonials-cta {
|
||||
gap: 9px;
|
||||
min-height: 42px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.testimonials-cta-primary {
|
||||
box-shadow: 0 10px 24px rgba(33, 48, 33, 0.16);
|
||||
}
|
||||
|
||||
.testimonials-cta-secondary {
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
|
||||
}
|
||||
|
||||
.testimonials-cta-secondary:hover {
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.testimonials-cta-logo {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.testimonials-cta-label-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonials-cta-primary:hover {
|
||||
box-shadow: 0 14px 30px rgba(33, 48, 33, 0.2);
|
||||
}
|
||||
|
||||
.testimonials-cta-secondary:hover {
|
||||
background: rgba(33, 48, 33, 0.09);
|
||||
color: var(--gw-green);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
|
||||
0 10px 22px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.testimonials-carousel {
|
||||
position: relative;
|
||||
margin-top: 40px;
|
||||
padding: 0 38px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.testimonials-header {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.testimonials-header-main {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.testimonials-eyebrow {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.testimonials-header .section-heading,
|
||||
.testimonials-intro {
|
||||
max-width: 34rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.testimonials-cta-row {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.testimonials-eyebrow {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.testimonials-intro {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.testimonials-intro p {
|
||||
font-size: var(--body-lead-size-mobile);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.testimonials-cta-row {
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.testimonials-cta {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.testimonials-cta-label-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.testimonials-cta-label-mobile {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonial-arrow:hover {
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
box-shadow: 0 14px 28px rgba(17, 20, 24, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-arrow:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 16px), 0);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(.reveal-visible) .testimonials-header-main { transition-delay: 40ms; }
|
||||
:global(.reveal-visible) .testimonials-header-side { transition-delay: 140ms; }
|
||||
:global(.reveal-visible) .testimonials-carousel { transition-delay: 260ms; }
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.testimonial-slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 45% 55%;
|
||||
align-items: stretch;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
transform 0.35s ease;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.testimonial-slide-active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 32px 24px 0 24px;
|
||||
}
|
||||
|
||||
.testimonial-photo-frame {
|
||||
width: min(100%, 340px);
|
||||
}
|
||||
|
||||
.testimonial-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
align-self: start;
|
||||
padding: 118px 112px 76px 10px;
|
||||
}
|
||||
|
||||
.testimonial-quote-mark {
|
||||
display: block;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 72px;
|
||||
line-height: 0.6;
|
||||
color: var(--yellow);
|
||||
margin-bottom: 20px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.testimonial-copy .testimonial-quote {
|
||||
max-width: 500px;
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
color: #2e3031;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.testimonial-author-name {
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.testimonial-author-detail {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.testimonial-author-detail::before {
|
||||
content: '—';
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.testimonial-divider {
|
||||
width: 100%;
|
||||
max-width: 690px;
|
||||
height: 1px;
|
||||
margin: 44px 0 0;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
.testimonial-mobile-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #111;
|
||||
font-size: 22px;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 12px 28px rgba(20, 24, 20, 0.07);
|
||||
}
|
||||
|
||||
.testimonial-arrow:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.testimonial-arrow-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.testimonial-arrow-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.testimonial-stage {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
padding: 88px 16px 64px 44px;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
padding: 96px 72px 64px 8px;
|
||||
}
|
||||
|
||||
.testimonial-copy .testimonial-quote {
|
||||
max-width: 460px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.testimonials-carousel {
|
||||
margin-top: 26px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
min-height: unset;
|
||||
display: flex;
|
||||
padding-bottom: 0;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pinch-zoom;
|
||||
}
|
||||
|
||||
.testimonial-stage::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.testimonial-slide {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 100%;
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.testimonial-slide-active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
justify-content: center;
|
||||
padding: 34px 20px 12px;
|
||||
}
|
||||
|
||||
.testimonial-photo-frame {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.testimonial-photo {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
padding: 4px 24px 24px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.testimonial-quote-mark {
|
||||
font-size: 44px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.testimonial-copy .testimonial-quote {
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.testimonial-divider {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.testimonial-mobile-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.testimonial-arrow-inline {
|
||||
position: static;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: var(--gw-green);
|
||||
font-size: 18px;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.testimonial-arrow-inline:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.testimonial-arrow-left,
|
||||
.testimonial-arrow-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import TestimonialsSection from './TestimonialsSection.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import type { TestimonialContent } from '$lib/types';
|
||||
|
||||
const expectedMappedSlides = [
|
||||
{ reviewer: 'Kate' },
|
||||
{ reviewer: 'Estelle' },
|
||||
{ reviewer: 'Ross' },
|
||||
{ reviewer: 'Nina' }
|
||||
];
|
||||
|
||||
function getActiveSlide(container: HTMLElement) {
|
||||
return container.querySelector('.testimonial-slide-active') as HTMLElement;
|
||||
}
|
||||
|
||||
function getActiveReviewer(container: HTMLElement) {
|
||||
return getActiveSlide(container).querySelector('.testimonial-author-name')?.textContent;
|
||||
}
|
||||
|
||||
function getActiveImage(container: HTMLElement) {
|
||||
return getActiveSlide(container).querySelector('img') as HTMLImageElement;
|
||||
}
|
||||
|
||||
function getNextButton(container: HTMLElement) {
|
||||
return container.querySelector('.testimonial-arrow-right') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function getPreviousButton(container: HTMLElement) {
|
||||
return container.querySelector('.testimonial-arrow-left') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('TestimonialsSection', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('maps all known testimonial images to the local PNG assets', async () => {
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials
|
||||
});
|
||||
|
||||
const nextButton = getNextButton(container);
|
||||
|
||||
for (const [index, slide] of expectedMappedSlides.entries()) {
|
||||
expect(getActiveReviewer(container)).toBe(slide.reviewer);
|
||||
expect(getActiveImage(container)).toBeTruthy();
|
||||
|
||||
if (index < expectedMappedSlides.length - 1) {
|
||||
await fireEvent.click(nextButton);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('moves to the next testimonial on arrow click and auto-rotation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials
|
||||
});
|
||||
|
||||
const nextButton = getNextButton(container);
|
||||
|
||||
expect(getActiveReviewer(container)).toBe('Kate');
|
||||
|
||||
await fireEvent.click(nextButton);
|
||||
expect(getActiveReviewer(container)).toBe('Estelle');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9000);
|
||||
expect(getActiveReviewer(container)).toBe('Ross');
|
||||
});
|
||||
|
||||
it('wraps to the last testimonial when navigating backwards from the first slide', async () => {
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials
|
||||
});
|
||||
|
||||
const previousButton = getPreviousButton(container);
|
||||
|
||||
expect(getActiveReviewer(container)).toBe('Kate');
|
||||
|
||||
await fireEvent.click(previousButton);
|
||||
|
||||
expect(getActiveReviewer(container)).toBe('Nina');
|
||||
expect(getActiveImage(container)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps custom testimonial images and filters out testimonials with no image', async () => {
|
||||
const customTestimonials: TestimonialContent[] = [
|
||||
...homepageContent.testimonials,
|
||||
{
|
||||
reviewer: 'Casey',
|
||||
detail: "Poppy's mum",
|
||||
type: 'Client',
|
||||
quote: 'Thoughtful updates and a very happy dog after every walk.',
|
||||
imageUrl: '/images/custom-casey-review.webp',
|
||||
showInSlider: true
|
||||
},
|
||||
{
|
||||
reviewer: 'Jordan',
|
||||
detail: "Scout's dad",
|
||||
type: 'Client',
|
||||
quote: 'Should be hidden because there is no image.',
|
||||
showInSlider: true
|
||||
}
|
||||
];
|
||||
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: customTestimonials
|
||||
});
|
||||
|
||||
const nextButton = getNextButton(container);
|
||||
|
||||
expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5);
|
||||
|
||||
for (let step = 0; step < 4; step += 1) {
|
||||
await fireEvent.click(nextButton);
|
||||
}
|
||||
|
||||
expect(getActiveReviewer(container)).toBe('Casey');
|
||||
expect(getActiveImage(container)).toBeTruthy();
|
||||
expect(screen.queryByText('Jordan')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can start on a different testimonial for a different page seed', () => {
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials,
|
||||
seedKey: '/dog-walking'
|
||||
});
|
||||
|
||||
expect(getActiveReviewer(container)).not.toBe('Kate');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,651 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let values: IconCard[];
|
||||
const stakes = [
|
||||
{
|
||||
label: 'Without Goodwalk',
|
||||
title: 'The evening pays for the day.',
|
||||
body:
|
||||
"The dog is wired. You're tired. Home feels like the third shift.",
|
||||
points: [
|
||||
"A dog who can't settle",
|
||||
'A workday full of guilt',
|
||||
"A walker you're never quite sure about"
|
||||
],
|
||||
footer: 'The walk is not really the point. The evening is.'
|
||||
},
|
||||
{
|
||||
label: 'With Goodwalk',
|
||||
title: 'The evening pays you back.',
|
||||
body:
|
||||
"The dog is tired. The home is quiet. The workday isn't carrying guilt.",
|
||||
points: [
|
||||
'A walker your dog knows',
|
||||
'Small dogs walking with small dogs',
|
||||
'A workday with one less thing on it'
|
||||
],
|
||||
footer: 'Peace of mind, in dog form.'
|
||||
}
|
||||
];
|
||||
const clientPhotos = [
|
||||
{
|
||||
imageUrl: '/images/goodwalk-client-dogs-mt-cecila-auckland.webp',
|
||||
alt: 'Two happy Goodwalk client dogs out on a walk in Auckland',
|
||||
name: 'Happy clients at Mt Cecila',
|
||||
detail: ''
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/goodwalk-tiny-gang-pack-walk-auckland.webp',
|
||||
alt: 'Goodwalk Tiny Gang dogs together on a walk in Auckland',
|
||||
name: 'The Tiny Gang on a pack walk',
|
||||
detail: ''
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/goodwalk-tiny-gang-mt-albert-park-auckland.webp',
|
||||
alt: 'Goodwalk dogs together at Mt Albert Park in Auckland',
|
||||
name: 'Distinguished crew at Mt Albert park',
|
||||
detail: ''
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/goodwalk-dogs-group-outing-auckland.webp',
|
||||
alt: 'Otis enjoying his Goodwalk routine in Auckland',
|
||||
name: 'Digby teaching tricks!',
|
||||
detail: ''
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/goodwalk-tiny-gang-finishing-walk-suv-auckland.webp',
|
||||
alt: 'Tiny Gang Pack finishing up after a walk',
|
||||
name: 'Tiny Gang heading home',
|
||||
detail: ''
|
||||
}
|
||||
];
|
||||
|
||||
$: clientPhotoCards = clientPhotos.map((photo) => ({
|
||||
...photo,
|
||||
enhanced: getEnhancedImage(photo.imageUrl)
|
||||
}));
|
||||
|
||||
$: orderedValues = values
|
||||
.map((value, index) => ({ value, index }))
|
||||
.sort((a, b) => {
|
||||
const aOrder = a.value.order ?? Number.POSITIVE_INFINITY;
|
||||
const bOrder = b.value.order ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ value }) => value);
|
||||
</script>
|
||||
|
||||
<section id="values" use:reveal={{ delay: 30, distance: 0 }} class="reveal-block">
|
||||
<div class="values-inner">
|
||||
<div class="section-header fade-up">
|
||||
<h2 class="section-heading">Calmer dogs. Calmer evenings.</h2>
|
||||
</div>
|
||||
|
||||
<div class="values-photo-grid stagger-children stagger-tight" aria-label="Goodwalk client dogs">
|
||||
{#each clientPhotoCards as photo, index}
|
||||
<figure class:values-photo-card-featured={index === 0} class="values-photo-card fade-up">
|
||||
{#if photo.enhanced}
|
||||
<enhanced:img
|
||||
class="values-photo-image"
|
||||
src={photo.enhanced}
|
||||
alt={photo.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="values-photo-image"
|
||||
src={photo.imageUrl}
|
||||
alt={photo.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
<figcaption class:values-photo-caption-solo={!photo.detail} class="values-photo-caption">
|
||||
<span class="values-photo-name">{photo.name}</span>
|
||||
{#if photo.detail}
|
||||
<span class="values-photo-detail">{photo.detail}</span>
|
||||
{/if}
|
||||
</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="values-bento values-contrast stagger-children">
|
||||
{#each stakes as stake, index}
|
||||
<article class:values-contrast-cell-good={index === 1} class="values-contrast-cell fade-up">
|
||||
<div class="values-contrast-head">
|
||||
<span class:values-contrast-label-good={index === 1} class="values-contrast-label">
|
||||
{stake.label}
|
||||
</span>
|
||||
<span class="values-contrast-num">0{index + 1}</span>
|
||||
</div>
|
||||
<h3>{stake.title}</h3>
|
||||
<p class="values-contrast-body">{stake.body}</p>
|
||||
<ul class="values-contrast-list">
|
||||
{#each stake.points as point}
|
||||
<li>
|
||||
<span class="values-contrast-bullet">
|
||||
<Icon
|
||||
name={index === 1 ? 'fas fa-check' : 'fas fa-minus'}
|
||||
className="values-contrast-glyph"
|
||||
/>
|
||||
</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="values-contrast-footer">{stake.footer}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="values-points-header fade-up">
|
||||
<h3 class="values-points-title">Things we don't treat as extras</h3>
|
||||
</div>
|
||||
|
||||
<div class="values-bento values-points stagger-children">
|
||||
{#each orderedValues as value}
|
||||
<div class="values-point fade-up">
|
||||
<div class="values-point-icon">
|
||||
<Icon name={value.icon} className="values-point-glyph" />
|
||||
</div>
|
||||
<h3>{value.title}</h3>
|
||||
<p>{value.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Minimalist, grid-based layout (hairline "bento" cells) with Goodwalk
|
||||
brand colour carried through the icons and the "With Goodwalk" cell. */
|
||||
#values {
|
||||
position: relative;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.values-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-container-x);
|
||||
}
|
||||
|
||||
.values-inner .section-heading {
|
||||
color: var(--text-heading);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Client photo gallery ── */
|
||||
.values-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 0.9fr 0.9fr;
|
||||
grid-template-rows: repeat(2, clamp(170px, 18vw, 240px));
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
max-width: 1120px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.values-photo-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--beige);
|
||||
box-shadow:
|
||||
var(--shadow-inset-soft),
|
||||
var(--shadow-card);
|
||||
}
|
||||
|
||||
.values-photo-card-featured {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.values-photo-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.values-photo-card:hover .values-photo-image {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.values-photo-caption {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.92));
|
||||
box-shadow:
|
||||
var(--shadow-inset-soft),
|
||||
var(--shadow-card);
|
||||
}
|
||||
|
||||
.values-photo-caption-solo {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.values-photo-name,
|
||||
.values-photo-detail {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.values-photo-name {
|
||||
color: var(--text-heading);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.values-photo-detail {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Bento container: hairline grid via 1px gaps over a line-coloured base ── */
|
||||
.values-bento {
|
||||
max-width: 1120px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: rgba(var(--ink-rgb), 0.06);
|
||||
border: 1px solid rgba(var(--ink-rgb), 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-panel-strong);
|
||||
}
|
||||
|
||||
/* ── Before / after contrast ── */
|
||||
.values-contrast {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.values-contrast-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-8) var(--space-8);
|
||||
background: var(--surface-panel);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good {
|
||||
background: var(--gw-green);
|
||||
}
|
||||
|
||||
.values-contrast-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.values-contrast-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(var(--ink-rgb), 0.05);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.values-contrast-label-good {
|
||||
background: var(--yellow);
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.values-contrast-num {
|
||||
font-family: var(--font-head);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(var(--ink-rgb), 0.22);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.values-contrast-cell h3 {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(20px, 1.9vw, 25px);
|
||||
font-weight: 700;
|
||||
line-height: 1.22;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.values-contrast-body {
|
||||
margin: 0 0 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.values-contrast-list {
|
||||
display: grid;
|
||||
margin: 0 0 22px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.values-contrast-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 13px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.values-contrast-list li + li {
|
||||
border-top: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
.values-contrast-bullet {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.values-contrast-list :global(.values-contrast-glyph) {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-bullet {
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-list :global(.values-contrast-glyph) {
|
||||
font-size: 9px;
|
||||
color: var(--gw-green);
|
||||
}
|
||||
|
||||
.values-contrast-footer {
|
||||
margin: auto 0 0;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border-muted);
|
||||
color: var(--gw-green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.18);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Light text for the gw-green "With Goodwalk" cell */
|
||||
.values-contrast-cell-good h3 {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-num {
|
||||
color: rgba(var(--white-rgb), 0.4);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-body {
|
||||
color: var(--text-inverse-muted);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-list li {
|
||||
color: rgba(var(--white-rgb), 0.9);
|
||||
}
|
||||
|
||||
.values-contrast-cell-good .values-contrast-list li + li {
|
||||
border-top-color: var(--border-inverse-strong);
|
||||
}
|
||||
|
||||
/* ── Values points header ── */
|
||||
.values-points-header {
|
||||
margin-top: 52px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.values-points-title {
|
||||
max-width: 19ch;
|
||||
margin: 0 auto;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(24px, 2.4vw, 32px);
|
||||
font-weight: 700;
|
||||
line-height: 1.14;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
/* ── Values points ── */
|
||||
.values-points {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 26px;
|
||||
box-shadow: var(--shadow-panel-elevated);
|
||||
}
|
||||
|
||||
.values-point {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-7) var(--space-7);
|
||||
background: var(--surface-panel);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.values-point:hover {
|
||||
background: var(--surface-panel-warm);
|
||||
}
|
||||
}
|
||||
|
||||
.values-point-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--gw-green);
|
||||
box-shadow: var(--shadow-badge);
|
||||
}
|
||||
|
||||
.values-point-icon :global(.values-point-glyph) {
|
||||
font-size: 17px;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.values-point h3 {
|
||||
margin: 0 0 9px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.values-point p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.values-photo-grid,
|
||||
.values-bento {
|
||||
max-width: 1180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.values-inner {
|
||||
padding: 0 var(--space-container-x-mobile);
|
||||
}
|
||||
|
||||
.values-photo-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: auto;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.values-photo-card {
|
||||
grid-row: auto;
|
||||
min-height: 178px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.values-photo-card-featured {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: auto;
|
||||
min-height: 240px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.values-photo-caption {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
display: grid;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
gap: 3px;
|
||||
padding: 10px 11px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.values-photo-caption-solo {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.values-photo-name {
|
||||
font-size: 12px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.values-photo-detail {
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
line-clamp: 2;
|
||||
text-align: left;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.values-bento {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.values-contrast {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.values-contrast-cell {
|
||||
padding: 26px 22px;
|
||||
}
|
||||
|
||||
.values-contrast-body {
|
||||
font-size: var(--body-copy-size-mobile);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.values-contrast-list li {
|
||||
font-size: var(--body-copy-size-mobile);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.values-points-header {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.values-points-title {
|
||||
max-width: 16ch;
|
||||
font-size: clamp(22px, 6.4vw, 27px);
|
||||
}
|
||||
|
||||
.values-points {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.values-point {
|
||||
padding: 26px 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reveal ── */
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 16px), 0);
|
||||
transition:
|
||||
opacity var(--motion-reveal-opacity, 0.3s ease),
|
||||
transform var(--motion-reveal-transform, 0.45s cubic-bezier(0.2, 0.8, 0.2, 1));
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
/* Tier choreography — header, then photo gallery cascades tight,
|
||||
contrast cells settle, then the bottom-row "extras" cascade. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(.reveal-visible) .values-inner > .section-header { transition-delay: 40ms; }
|
||||
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(1) { transition-delay: 160ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(2) { transition-delay: 220ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(3) { transition-delay: 280ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(4) { transition-delay: 340ms; }
|
||||
:global(.reveal-visible) .values-photo-grid > :nth-child(5) { transition-delay: 400ms; }
|
||||
|
||||
:global(.reveal-visible) .values-contrast > :nth-child(1) { transition-delay: 460ms; }
|
||||
:global(.reveal-visible) .values-contrast > :nth-child(2) { transition-delay: 540ms; }
|
||||
|
||||
:global(.reveal-visible) .values-points-header { transition-delay: 620ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(1) { transition-delay: 680ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(2) { transition-delay: 740ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(3) { transition-delay: 800ms; }
|
||||
:global(.reveal-visible) .values-points > :nth-child(n + 4) { transition-delay: 860ms; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user