Onboarding / Deployment Scripts / Marketing updates

This commit is contained in:
2026-05-11 21:02:24 +12:00
parent a90dfb7c66
commit 955a563d14
110 changed files with 9803 additions and 937 deletions
+481 -106
View File
@@ -1,68 +1,164 @@
<script lang="ts">
import { accordion } from '$lib/actions/accordion';
import { reveal } from '$lib/actions/reveal';
import ServicesSection from '$lib/components/ServicesSection.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { AboutPageContent } from '$lib/types';
export let content: SiteSharedContent;
export let pageContent: AboutPageContent;
$: standardSections = pageContent.sections.filter((s) => s.accent !== 'founder');
$: founderSection = pageContent.sections.find((s) => s.accent === 'founder') ?? null;
const founderHeadingLead = 'Meet Aless,';
const founderHeadingHighlight = 'the heart of Goodwalk';
</script>
<main class="about-page">
<!-- ── Hero ── -->
<section class="about-hero">
<div class="about-inner">
<span class="about-hero-eyebrow">About Goodwalk</span>
<h1>{pageContent.title}</h1>
<p class="about-hero-desc">Small dog specialists serving Auckland Central. A team your dog knows by name.</p>
<div class="about-hero-chips">
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="about-hero-chip about-hero-chip-link"
>
<span class="about-chip-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
<span class="about-hero-chip">Auckland Central</span>
<span class="about-hero-chip">Small dog specialists</span>
</div>
</div>
</section>
{#each pageContent.sections as section}
<!-- ── Standard sections (Who we are, Our impact) ── -->
{#each standardSections as section}
{@const enhanced = getEnhancedImage(section.imageUrl)}
<section
use:reveal
class:about-section-gradient={section.accent === 'gradient'}
class="about-section reveal-block"
class:about-section-gradient={section.accent === 'gradient'}
>
<div class:about-section-reverse={section.reverse} class="about-inner about-section-grid">
<div class="about-inner about-section-grid" class:about-section-reverse={section.reverse}>
<div class="about-copy">
{#if section.eyebrow}
<span class="about-eyebrow">{section.eyebrow}</span>
{/if}
<h2>{section.title}</h2>
{#each section.body as paragraph}
<p>{paragraph}</p>
{/each}
</div>
<div class="about-media">
<img
src={section.imageUrl}
alt={section.imageAlt}
width={getImageMetadata(section.imageUrl)?.width}
height={getImageMetadata(section.imageUrl)?.height}
loading="lazy"
decoding="async"
/>
{#if enhanced}
<enhanced:img src={enhanced} alt={section.imageAlt} loading="lazy" decoding="async" />
{:else}
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" decoding="async" />
{/if}
</div>
</div>
</section>
{/each}
<ServicesSection services={content.services} heading={pageContent.servicesTitle} />
<!-- ── Founder section ── -->
{#if founderSection}
{@const founderEnhanced = getEnhancedImage(founderSection.imageUrl)}
<section use:reveal={{ delay: 50 }} class="about-founder reveal-block">
<div class="about-inner about-founder-grid">
<div class="about-founder-media">
{#if founderEnhanced}
<enhanced:img
src={founderEnhanced}
alt={founderSection.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img
src={founderSection.imageUrl}
alt={founderSection.imageAlt}
loading="lazy"
decoding="async"
/>
{/if}
</div>
<div class="about-founder-copy">
{#if founderSection.eyebrow}
<span class="about-eyebrow">{founderSection.eyebrow}</span>
{/if}
<h2 class="about-founder-heading">
<span class="about-founder-heading-desktop">
<span class="about-founder-title-main">{founderHeadingLead}</span>
<br />
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
</span>
<span class="about-founder-heading-mobile">
<span class="about-founder-title-main">{founderHeadingLead}</span>
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
</span>
</h2>
{#each founderSection.body as paragraph}
<p>{paragraph}</p>
{/each}
<a href="/contact-us" class="btn btn-green btn-mobile-center">Book a free Meet &amp; Greet</a>
</div>
</div>
</section>
{/if}
<section use:reveal={{ delay: 70 }} class="about-contact reveal-block">
<!-- ── FAQs ── -->
{#if pageContent.faqs && pageContent.faqs.length}
<section use:reveal={{ delay: 30 }} class="about-faq reveal-block">
<div class="about-inner">
<div class="about-faq-header">
<span class="about-eyebrow">FAQ</span>
<h2>{pageContent.faqTitle ?? 'Common questions'}</h2>
</div>
<div use:accordion class="faq about-faq-list">
{#each pageContent.faqs as item}
<details>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
{/each}
</div>
</div>
</section>
{/if}
<!-- ── Contact CTA ── -->
<section use:reveal={{ delay: 50 }} class="about-contact reveal-block">
<div class="about-inner">
<div class="about-contact-card">
<span class="about-contact-eyebrow">Get in touch</span>
<h2>{pageContent.contact.title}</h2>
<div class="about-contact-grid">
<p class="about-contact-desc">Questions, pricing, or your first Meet &amp; Greet — start here and we'll reply within 24 hours.</p>
<a class="btn btn-yellow btn-mobile-center about-contact-btn" href={pageContent.contact.cta.href}>
{pageContent.contact.cta.label}
</a>
<div class="about-contact-links">
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
<Icon name="fas fa-envelope" />
{pageContent.contact.email}
</a>
<a class="btn btn-yellow" href={pageContent.contact.cta.href}>
{pageContent.contact.cta.label}
</a>
<a class="about-contact-link" href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}>
<a
class="about-contact-link"
href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}
>
<Icon name="fas fa-phone" />
{pageContent.contact.phone}
</a>
</div>
</div>
</div>
</section>
</main>
<style>
@@ -76,47 +172,112 @@
padding: 0 50px;
}
/* ── Shared eyebrow ── */
.about-eyebrow,
.about-hero-eyebrow,
.about-contact-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.about-eyebrow {
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.about-hero-eyebrow,
.about-contact-eyebrow {
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
/* ── Hero ── */
.about-hero {
padding: 72px 0 40px;
}
.about-hero h1,
.about-copy h2,
.about-contact-card h2 {
margin: 0;
font-family: var(--font-head);
font-size: clamp(34px, 4vw, 56px);
line-height: 1.05;
letter-spacing: -0.04em;
color: #000;
}
.about-hero h1 {
background: var(--gw-green);
color: #fff;
padding: 80px 0 72px;
text-align: center;
}
.about-hero h1 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(40px, 5vw, 68px);
font-weight: 800;
line-height: 1.02;
letter-spacing: -0.04em;
color: #fff;
}
.about-hero-desc {
max-width: 480px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.55;
}
.about-hero-chips {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.about-hero-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
.about-hero-chip-link {
text-decoration: none;
transition: background 0.18s ease;
}
.about-hero-chip-link:hover {
background: rgba(255, 255, 255, 0.18);
}
.about-chip-stars {
color: var(--yellow);
letter-spacing: 1px;
font-size: 13px;
}
/* ── Standard sections ── */
.about-section {
padding: 0 0 88px;
padding: 88px 0;
}
.about-section-gradient {
margin: 0 24px 88px;
padding: 40px 0;
border-radius: 28px;
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
}
.about-section-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 44px;
gap: 60px;
align-items: center;
}
.about-section-reverse {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.about-section-reverse .about-copy {
order: 2;
}
@@ -126,29 +287,227 @@
}
.about-copy h2 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #0d1a0d;
}
.about-copy p {
margin: 18px 0 0;
margin: 12px 0 0;
color: #34363a;
font-size: 17px;
line-height: 1.75;
}
.about-media {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 28px;
box-shadow: 0 16px 48px rgba(17, 20, 24, 0.1);
}
.about-media img {
display: block;
width: 100%;
max-width: 460px;
aspect-ratio: 4 / 3;
height: auto;
margin-left: auto;
margin-right: auto;
border-radius: 28px;
height: 100%;
object-fit: cover;
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
object-position: center top;
}
/* ── Founder section ── */
.about-founder {
padding: 88px 0;
background: #fff;
}
.about-founder-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 64px;
align-items: center;
}
.about-founder-media img {
display: block;
width: 100%;
max-width: 400px;
height: auto;
border-radius: 28px;
object-fit: cover;
box-shadow: 0 24px 56px rgba(17, 20, 24, 0.12);
margin: 0 auto;
}
.about-founder-copy h2 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(30px, 3.5vw, 44px);
font-weight: 800;
line-height: 1.06;
letter-spacing: -0.03em;
text-wrap: balance;
color: #0d1a0d;
}
.about-founder-heading-desktop {
display: block;
}
.about-founder-heading-mobile {
display: none;
}
.about-founder-heading-mobile .about-founder-title-main,
.about-founder-heading-mobile .about-founder-title-highlight {
display: block;
}
.about-founder-title-main {
color: #0d1a0d;
}
.about-founder-title-highlight {
position: relative;
display: inline-block;
color: #0d1a0d;
}
.about-founder-title-highlight::after {
content: '';
position: absolute;
left: 0;
right: -6px;
bottom: -16px;
height: 24px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
center/contain no-repeat;
transform-origin: left center;
animation: about-founder-underline-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both;
}
@keyframes about-founder-underline-draw {
0% {
opacity: 0;
transform: scaleX(0.2) translateY(6px) rotate(-1.5deg);
}
65% {
opacity: 1;
transform: scaleX(1.04) translateY(0) rotate(0deg);
}
100% {
opacity: 1;
transform: scaleX(1) translateY(0) rotate(0deg);
}
}
.about-founder-copy p {
margin: 14px 0 0;
color: #34363a;
font-size: 17px;
line-height: 1.75;
}
.about-founder-copy .btn {
display: flex;
width: fit-content;
margin: 28px auto 0;
}
/* ── FAQs ── */
.about-faq {
padding: 80px 0;
background: var(--off-white);
}
.about-faq-header {
text-align: center;
margin-bottom: 40px;
}
.about-faq-header h2 {
margin: 0;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #0d1a0d;
}
.about-faq-list {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Contact CTA ── */
.about-contact {
padding: 0 0 88px;
}
.about-contact-card {
background: var(--gw-green);
color: #fff;
border-radius: 28px;
padding: 56px 48px;
text-align: center;
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.18);
}
.about-contact-card h2 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: clamp(28px, 3vw, 42px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #fff;
}
.about-contact-desc {
max-width: 440px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.75);
font-size: 16px;
line-height: 1.6;
}
.about-contact-btn {
display: inline-flex;
}
.about-contact-links {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
margin-top: 22px;
}
.about-contact-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.72);
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: color 0.18s ease;
}
.about-contact-link:hover {
color: #fff;
}
/* ── Reveal ── */
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
@@ -163,41 +522,12 @@
transform: translate3d(0, 0, 0);
}
.about-contact {
padding: 0 0 88px;
}
.about-contact-card {
border-radius: 28px;
background: #fff;
padding: 42px 48px;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
text-align: center;
}
.about-contact-card h2 {
font-size: clamp(28px, 3vw, 42px);
}
.about-contact-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
align-items: center;
margin-top: 28px;
}
.about-contact-link {
color: #34363a;
font-size: 20px;
font-weight: 600;
text-decoration: none;
}
/* ── Tablet ── */
@media (max-width: 1024px) {
.about-section-grid,
.about-section-reverse {
.about-founder-grid {
grid-template-columns: 1fr;
gap: 36px;
}
.about-section-reverse .about-copy,
@@ -205,38 +535,44 @@
order: initial;
}
.about-contact-grid {
grid-template-columns: 1fr;
.about-founder-media img {
max-width: 420px;
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.about-inner {
padding: 0 24px;
}
.about-hero {
padding: 56px 0 24px;
padding: 56px 0 48px;
}
.about-section,
.about-contact {
padding-bottom: 64px;
.about-hero h1 {
font-size: 38px;
}
.about-section-gradient {
margin: 0 12px 64px;
padding: 28px 0;
border-radius: 28px;
.about-hero-desc {
font-size: 15px;
}
.about-hero-chip {
font-size: 13px;
padding: 8px 14px;
}
.about-section {
padding: 60px 0;
}
.about-section-grid {
gap: 24px;
gap: 28px;
}
.about-copy h2,
.about-contact-card h2 {
font-size: 30px;
.about-copy h2 {
font-size: 28px;
}
.about-copy p {
@@ -244,16 +580,55 @@
line-height: 1.7;
}
.about-founder {
padding: 60px 0;
}
.about-founder-grid {
gap: 28px;
}
.about-founder-copy h2 {
font-size: 26px;
line-height: 1.02;
}
.about-founder-heading-desktop {
display: none;
}
.about-founder-heading-mobile {
display: block;
}
.about-founder-copy p {
font-size: 16px;
line-height: 1.7;
}
.about-faq {
padding: 60px 0;
}
.about-contact {
padding-bottom: 60px;
}
.about-contact-card {
padding: 30px 24px;
padding: 36px 24px;
border-radius: 24px;
}
.about-contact-grid {
margin-top: 22px;
.about-contact-links {
flex-direction: column;
align-items: center;
gap: 14px;
}
}
.about-contact-link {
font-size: 18px;
@media (prefers-reduced-motion: reduce) {
.about-founder-title-highlight::after {
animation: none;
}
}
</style>
+4 -1
View File
@@ -91,8 +91,11 @@
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
font-family: var(--font-head);
font-size: 14px;
font-weight: 600;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
color: #fff;
transition: background 0.2s;
}
+17 -1
View File
@@ -249,11 +249,27 @@
}
}
function sortSelectedServices(services: string[]) {
return [...services].sort((a, b) => {
const indexA = booking.serviceOptions.indexOf(a);
const indexB = booking.serviceOptions.indexOf(b);
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}
function toggleService(service: string, checked: boolean) {
noteInteraction();
if (checked) {
selectedServices = [service, ...selectedServices.filter((item) => item !== service)];
selectedServices = sortSelectedServices([
...selectedServices.filter((item) => item !== service),
service
]);
return;
}
+1 -1
View File
@@ -24,7 +24,7 @@ async function fillDogStep() {
await fireEvent.input(screen.getByLabelText(/Location/i), {
target: { value: 'Kingsland' }
});
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
await fireEvent.input(screen.getByLabelText(/Pack Walks fit/i), {
target: { value: 'Loves small group walks.' }
});
}
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import type { FooterContent, LinkItem } from '$lib/types';
import { locationPages } from '$lib/content/locations';
export let footer: FooterContent;
const socialLinks: LinkItem[] = [
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
@@ -32,12 +34,10 @@
<footer>
<div class="footer-inner">
<div class="footer-brand">
<img
src="/images/goodwalk-auckland-dog-walking-logo.png"
<enhanced:img
src="$lib/images/goodwalk-auckland-dog-walking-logo.png"
alt="Goodwalk Auckland dog walking service logo"
class="footer-logo"
width="241"
height="48"
loading="lazy"
decoding="async"
/>
@@ -72,6 +72,15 @@
</ul>
</div>
<div class="footer-locations">
<p class="footer-col-label">Areas we serve</p>
<ul class="footer-nav">
{#each locationPages as loc}
<li><a href="/locations/{loc.slug}">{loc.suburb}</a></li>
{/each}
</ul>
</div>
<div class="footer-action footer-panel footer-panel-accent">
<p class="footer-col-label">Get Started</p>
<h3 class="footer-action-title">Ready when you are</h3>
+16 -7
View File
@@ -3,6 +3,12 @@
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import type { NavigationContent } from '$lib/types';
import type { Picture } from '@sveltejs/enhanced-img';
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
import logoMobile from '$lib/images/goodwalk-auckland-dog-walking-logo-mobile.png?enhanced';
const desktop = logoDesktop as Picture;
const mobile = logoMobile as Picture;
export let navigation: NavigationContent;
@@ -141,15 +147,18 @@
<a href="/" class="logo" aria-label="Goodwalk Auckland Dog Walking, home">
<picture>
<source
media="(max-width: 768px)"
srcset="/images/goodwalk-auckland-dog-walking-logo-mobile.png"
/>
{#if mobile.sources?.webp}
<source media="(max-width: 768px)" type="image/webp" srcset={mobile.sources.webp} />
{/if}
<source media="(max-width: 768px)" srcset={mobile.img.src} />
{#if desktop.sources?.webp}
<source type="image/webp" srcset={desktop.sources.webp} />
{/if}
<img
src="/images/goodwalk-auckland-dog-walking-logo.png"
src={desktop.img.src}
alt="Goodwalk Auckland dog walking service logo"
width="241"
height="48"
width={desktop.img.w}
height={desktop.img.h}
decoding="async"
/>
</picture>
+19 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { CallToAction, HeroContent } from '$lib/types';
export let hero: HeroContent;
@@ -8,7 +8,7 @@
$: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
$: heroImage = getImageMetadata(hero.imageUrl);
$: heroEnhanced = getEnhancedImage(hero.imageUrl);
function splitTitle(title: string) {
const trimmed = title.trim();
@@ -99,15 +99,23 @@
</div>
<div class="hero-img">
<img
src={hero.imageUrl}
alt={hero.imageAlt}
width={heroImage?.width}
height={heroImage?.height}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{#if heroEnhanced}
<enhanced:img
src={heroEnhanced}
alt={hero.imageAlt}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{:else}
<img
src={hero.imageUrl}
alt={hero.imageAlt}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{/if}
</div>
</div>
</section>
+272 -229
View File
@@ -1,43 +1,297 @@
<script lang="ts">
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import type { HowItWorksContent } from '$lib/types';
export let content: HowItWorksContent;
</script>
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
<div class="how-it-works-inner">
<div class="how-it-works-header">
<div class="hiw-inner">
<div class="hiw-header">
<span class="hiw-eyebrow">Getting started</span>
<h2 class="section-heading">{content.title}</h2>
{#if content.intro}
<p class="how-it-works-intro">{content.intro}</p>
<p class="hiw-intro">{content.intro}</p>
{/if}
</div>
<div class="how-it-works-flow" aria-label="How it works">
<div class="hiw-steps">
{#each content.steps as step, index}
<article class:how-it-works-step-payoff={index === content.steps.length - 1} class="how-it-works-step">
<div class="how-it-works-rail-node" aria-hidden="true">
<span class="how-it-works-rail-dot"></span>
<div class="hiw-step">
<div class="hiw-step-meta">
<span class="hiw-phase">{step.phase}</span>
<span class="hiw-num">0{index + 1}</span>
</div>
<div class="how-it-works-step-top">
<span class="how-it-works-count">{`0${index + 1}`}</span>
<span class="how-it-works-phase">{step.phase || `Step ${index + 1}`}</span>
<div class="hiw-icon-wrap">
<Icon name={step.icon} className="hiw-step-icon" />
</div>
<div class="how-it-works-copy">
<h3>{step.title}</h3>
{#if step.benefit}
<p class="how-it-works-benefit">{step.benefit}</p>
{/if}
</div>
<p class="how-it-works-body">{step.body}</p>
</article>
<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">
<a href="#newlead" class="btn btn-green btn-mobile-center">Book your free Meet &amp; Greet</a>
<p class="hiw-cta-note">Free, no-obligation. We reply within 24 hours.</p>
</div>
</div>
</section>
<style>
#how-it-works {
background: var(--off-white);
padding: 80px 0;
}
.hiw-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
}
/* ── Header ── */
.hiw-header {
text-align: center;
margin-bottom: 56px;
}
.hiw-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.hiw-intro {
max-width: 580px;
margin: 16px auto 0;
color: #4c5056;
font-size: 16px;
line-height: 1.65;
}
/* ── Steps grid ── */
.hiw-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
position: relative;
}
.hiw-step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 40px 40px 36px;
background: #fff;
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
transition: box-shadow 0.22s ease, transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.hiw-step:first-child {
border-radius: 28px 0 0 28px;
}
.hiw-step:last-child {
border-radius: 0 28px 28px 0;
}
.hiw-step + .hiw-step {
border-left: none;
}
@media (hover: hover) {
.hiw-step:hover {
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.09);
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: 999px;
background: var(--yellow);
color: #000;
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(33, 48, 33, 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(--gw-green);
box-shadow: 0 10px 28px rgba(33, 48, 33, 0.2);
}
.hiw-icon-wrap :global(.hiw-step-icon) {
font-size: 26px;
color: #fff;
}
/* ── Content ── */
.hiw-title {
margin: 0 0 14px;
font-family: var(--font-head);
font-size: 20px;
font-weight: 700;
line-height: 1.2;
color: #0d1a0d;
}
.hiw-body {
margin: 0 0 20px;
color: #4c5056;
font-size: 15px;
line-height: 1.65;
flex: 1;
}
.hiw-benefit {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
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: #888;
font-size: 13px;
}
/* ── Mobile ── */
@media (max-width: 768px) {
#how-it-works {
padding: 64px 0;
}
.hiw-inner {
padding: 0 24px;
}
.hiw-header {
margin-bottom: 32px;
}
.hiw-intro {
font-size: 15px;
line-height: 1.55;
}
.hiw-steps {
grid-template-columns: 1fr;
gap: 12px;
}
.hiw-step {
align-items: flex-start;
text-align: left;
padding: 28px 24px;
border-radius: 24px !important;
border: 1px solid rgba(17, 20, 24, 0.06);
}
.hiw-step + .hiw-step {
border-left: 1px solid rgba(17, 20, 24, 0.06);
}
.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: 18px;
}
.hiw-body {
font-size: 14px;
line-height: 1.6;
}
.hiw-cta {
margin-top: 36px;
}
}
/* ── Reveal ── */
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
@@ -51,215 +305,4 @@
opacity: 1;
transform: translate3d(0, 0, 0);
}
#how-it-works {
background: #fff;
padding-top: 20px;
}
.how-it-works-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
}
.how-it-works-header {
text-align: center;
}
.how-it-works-intro {
max-width: 640px;
margin: 14px auto 0;
color: #4c5056;
font-size: 16px;
line-height: 1.6;
}
.how-it-works-flow {
display: grid;
position: relative;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 28px;
align-items: stretch;
margin-top: 40px;
}
.how-it-works-flow::before {
content: '';
position: absolute;
left: 18px;
right: 18px;
top: 22px;
height: 1px;
background: linear-gradient(
90deg,
rgba(33, 48, 33, 0.12) 0%,
rgba(33, 48, 33, 0.28) 50%,
rgba(33, 48, 33, 0.12) 100%
);
}
.how-it-works-step {
position: relative;
display: flex;
flex-direction: column;
padding: 0 18px 0 0;
text-align: left;
}
.how-it-works-step-payoff {
transform: translateY(-4px);
}
.how-it-works-rail-node {
position: relative;
display: flex;
align-items: center;
min-height: 44px;
margin-bottom: 24px;
}
.how-it-works-rail-dot {
display: inline-flex;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--gw-green);
box-shadow:
0 0 0 7px #fff,
0 0 0 8px rgba(33, 48, 33, 0.12);
}
.how-it-works-step-top {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.how-it-works-count {
color: rgba(33, 48, 33, 0.3);
font-family: var(--font-head);
font-size: 36px;
font-weight: 800;
line-height: 0.9;
}
.how-it-works-phase {
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.how-it-works-copy {
padding: 22px 22px 20px;
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 250, 240, 0.92) 0%, rgba(248, 244, 234, 0.92) 100%);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.how-it-works-step h3 {
margin: 0;
font-size: 20px;
line-height: 1.18;
}
.how-it-works-benefit {
margin: 10px 0 0;
color: #6b5830;
font-size: 14px;
font-weight: 700;
line-height: 1.35;
}
.how-it-works-body {
margin: 14px 0 0;
padding: 0 4px 0 22px;
color: #4c5056;
font-size: 15px;
line-height: 1.6;
}
@media (max-width: 768px) {
#how-it-works {
padding-top: 6px;
}
.how-it-works-inner {
padding: 0 24px;
}
.how-it-works-intro {
font-size: 15px;
line-height: 1.55;
}
.how-it-works-flow {
grid-template-columns: 1fr;
gap: 24px;
margin-top: 26px;
}
.how-it-works-flow::before {
left: 5px;
right: auto;
top: 22px;
bottom: 22px;
width: 1px;
height: auto;
background: linear-gradient(
180deg,
rgba(33, 48, 33, 0.12) 0%,
rgba(33, 48, 33, 0.28) 50%,
rgba(33, 48, 33, 0.12) 100%
);
}
.how-it-works-step {
padding: 0 0 0 28px;
}
.how-it-works-step-payoff {
transform: none;
}
.how-it-works-rail-node {
position: absolute;
left: 0;
top: 0;
min-height: 0;
margin: 18px 0 0;
}
.how-it-works-rail-dot {
box-shadow:
0 0 0 5px #fff,
0 0 0 6px rgba(33, 48, 33, 0.12);
}
.how-it-works-step-top {
margin-bottom: 10px;
}
.how-it-works-step h3 {
font-size: 18px;
line-height: 1.2;
}
.how-it-works-benefit {
margin-top: 8px;
font-size: 13px;
}
.how-it-works-body {
margin-top: 2px;
padding: 14px 2px 0 18px;
font-size: 14px;
line-height: 1.55;
}
}
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { accordion } from '$lib/actions/accordion';
import Icon from '$lib/components/Icon.svelte';
import type { InfoContent } from '$lib/types';
@@ -39,7 +40,7 @@
<div class="info-block">
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
<div class="faq">
<div use:accordion class="faq">
{#each info.faqs as faq}
<details>
<summary>{faq.question}</summary>
+1 -2
View File
@@ -4,7 +4,6 @@
export let instagram: HomePageContent['instagram'];
const dogCutoutSrc = '/images/dog-cutout.png';
</script>
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
@@ -22,7 +21,7 @@
</div>
<div class="instagram-dog-wrap" aria-hidden="true">
<img class="instagram-dog" src={dogCutoutSrc} alt="" loading="lazy" decoding="async" />
<enhanced:img src="$lib/images/dog-cutout.png" alt="" class="instagram-dog" loading="lazy" decoding="async" />
</div>
</div>
</aside>
+4 -2
View File
@@ -104,8 +104,10 @@
border-left: 3px solid var(--gw-green);
font-family: var(--font-head);
font-size: clamp(14px, 1.4vw, 17px);
line-height: 1.3;
letter-spacing: -0.01em;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.02em;
text-wrap: balance;
color: #000;
}
+863
View File
@@ -0,0 +1,863 @@
<script lang="ts">
import { sharedServices } from '$lib/content/services';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
import { getSeededTestimonialIndex } from '$lib/testimonials';
import type { LocationPageContent, TestimonialContent } from '$lib/types';
export let location: LocationPageContent;
export let testimonials: TestimonialContent[];
type ParkWithImage = LocationPageContent['parks'][number] & {
image: NonNullable<LocationPageContent['parks'][number]['image']>;
enhanced: ReturnType<typeof getEnhancedImage>;
};
$: featuredTestimonial = testimonials[getSeededTestimonialIndex(testimonials, location.slug)];
$: parksWithImages = location.parks
.filter((park): park is LocationPageContent['parks'][number] & { image: NonNullable<LocationPageContent['parks'][number]['image']> } => Boolean(park.image))
.map(
(park): ParkWithImage => ({
...park,
enhanced: getEnhancedImage(park.image.src)
})
);
$: serviceLinks = sharedServices.map((service) => ({
label: service.title,
href: service.href,
desc: service.locationDescription,
icon: service.icon
}));
$: locationHighlights = [
{
icon: 'fas fa-map-location-dot',
label: 'Local routes',
value: `${location.parks.length}+ parks`,
detail: `Regular walking options in and around ${location.suburb}`
},
{
icon: 'fas fa-paw',
label: 'Services',
value: '3 ways to help',
detail: 'Pack walks, 1:1 walks, and puppy visits'
},
{
icon: 'fas fa-van-shuttle',
label: 'Included',
value: 'Free pickup',
detail: 'Pickup and drop-off across the central suburbs'
}
];
</script>
<main class="loc-page">
<!-- ── Hero ── -->
<section class="loc-hero">
<div class="loc-inner">
<span class="loc-hero-eyebrow">Auckland Central Dog Walking</span>
<h1>Dog walkers in {location.suburb}</h1>
<p class="loc-hero-desc">{location.intro}</p>
<div class="loc-hero-actions">
<a href="/contact-us" class="btn btn-yellow btn-mobile-center">Book a free Meet &amp; Greet</a>
<a href="tel:+64226421011" class="loc-hero-phone">or call (022) 642 1011</a>
</div>
<div class="loc-hero-chips">
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="loc-chip loc-chip-link"
>
<span class="loc-chip-stars" aria-hidden="true">★★★★★</span>
30+ five-star Google reviews
</a>
<span class="loc-chip">Small dog specialists</span>
<span class="loc-chip">Free pickup &amp; drop-off</span>
</div>
</div>
</section>
<section class="loc-highlights" aria-label={`Goodwalk highlights in ${location.suburb}`}>
<div class="loc-inner">
<div class="loc-highlights-grid">
{#each locationHighlights as highlight}
<div class="loc-highlight-card">
<div class="loc-highlight-top">
<div class="loc-highlight-icon-wrap">
<Icon name={highlight.icon} className="loc-highlight-icon" />
</div>
<span class="loc-highlight-label">{highlight.label}</span>
</div>
<strong>{highlight.value}</strong>
<p>{highlight.detail}</p>
</div>
{/each}
</div>
</div>
</section>
<!-- ── Parks ── -->
<section use:reveal={{ delay: 30 }} class="loc-parks reveal-block">
<div class="loc-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">Where we walk</span>
<h2>Parks &amp; walks in {location.suburb}</h2>
<p class="loc-section-intro">
These are the parks and routes we know well in {location.suburb}. Every walk is planned around your dog's pace, size, and temperament — not just the nearest green space.
</p>
</div>
<div class="loc-parks-grid">
{#each location.parks as park}
<div class="loc-park-card">
<div class="loc-park-icon" aria-hidden="true">🐾</div>
<h3>{park.name}</h3>
<p>{park.description}</p>
{#if park.leashNote}
<span class="loc-park-leash">{park.leashNote}</span>
{/if}
</div>
{/each}
</div>
</div>
</section>
{#if parksWithImages.length > 0}
<section use:reveal={{ delay: 30 }} class="loc-gallery reveal-block">
<div class="loc-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">Local parks</span>
<h2>Park photos from {location.suburb}</h2>
<p class="loc-section-intro">
Real images from the parks we mention help each suburb page feel more specific and give search engines clearer local context.
</p>
</div>
<div class="loc-gallery-grid">
{#each parksWithImages as park}
<figure class="loc-gallery-card">
{#if park.enhanced}
<picture>
<img src={park.enhanced.img.src} alt={park.image.alt} loading="lazy" decoding="async" />
</picture>
{:else}
<img src={park.image.src} alt={park.image.alt} loading="lazy" decoding="async" />
{/if}
<figcaption>
<strong>{park.name}</strong>
{#if park.image.caption}
<span>{park.image.caption}</span>
{/if}
</figcaption>
</figure>
{/each}
</div>
</div>
</section>
{/if}
<!-- ── Services ── -->
<section use:reveal={{ delay: 30 }} class="loc-services reveal-block">
<div class="loc-inner">
<div class="loc-section-header">
<span class="loc-eyebrow">What we offer</span>
<h2>Goodwalk services in {location.suburb}</h2>
<p class="loc-section-intro">
We offer pack walks, 1:1 walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet &amp; Greet so we can understand your dog and recommend the right fit.
</p>
</div>
<div class="loc-services-grid">
{#each serviceLinks as svc}
<a href={svc.href} class="loc-service-card">
<div class="loc-service-icon-bubble">
<Icon name={svc.icon} className="loc-service-icon" />
</div>
<h3>{svc.label}</h3>
<p>{svc.desc}</p>
<span class="loc-service-link">Learn more →</span>
</a>
{/each}
</div>
</div>
</section>
<!-- ── Testimonial ── -->
{#if featuredTestimonial}
<section use:reveal={{ delay: 30 }} class="loc-review reveal-block">
<div class="loc-inner">
<div class="loc-review-card">
<span class="loc-review-stars" aria-hidden="true">★★★★★</span>
<blockquote class="loc-review-quote">"{featuredTestimonial.quote}"</blockquote>
<cite class="loc-review-cite">
{featuredTestimonial.reviewer}
{#if featuredTestimonial.detail}
<span class="loc-review-detail">{featuredTestimonial.detail}</span>
{/if}
</cite>
</div>
</div>
</section>
{/if}
<!-- ── CTA ── -->
<section use:reveal={{ delay: 30 }} class="loc-cta reveal-block">
<div class="loc-inner">
<div class="loc-cta-card">
<span class="loc-cta-eyebrow">Get in touch</span>
<h2>Ready to get started in {location.suburb}?</h2>
<p class="loc-cta-desc">
A free Meet &amp; Greet is the first step — no commitment, no pressure. We meet your dog, answer your questions, and see if Goodwalk is the right fit.
</p>
<a class="btn btn-yellow btn-mobile-center loc-cta-btn" href="/contact-us">Book a free Meet &amp; Greet</a>
<div class="loc-cta-links">
<a class="loc-cta-link" href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>
<a class="loc-cta-link" href="tel:+64226421011">(022) 642 1011</a>
</div>
</div>
</div>
</section>
</main>
<style>
.loc-page {
background: var(--off-white);
}
.loc-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 50px;
}
/* ── Eyebrow ── */
.loc-eyebrow,
.loc-hero-eyebrow,
.loc-cta-eyebrow {
display: inline-block;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loc-eyebrow {
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
}
.loc-hero-eyebrow,
.loc-cta-eyebrow {
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
/* ── Hero ── */
.loc-hero {
background: var(--gw-green);
color: #fff;
padding: 80px 0 112px;
text-align: center;
}
.loc-hero h1 {
margin: 0 0 16px;
font-family: var(--font-head);
font-size: clamp(36px, 5vw, 64px);
font-weight: 800;
line-height: 1.02;
letter-spacing: -0.04em;
color: #fff;
}
.loc-hero-desc {
max-width: 640px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
line-height: 1.65;
}
.loc-hero-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.loc-hero-phone {
color: rgba(255, 255, 255, 0.75);
font-size: 15px;
text-decoration: none;
transition: color 0.18s ease;
}
.loc-hero-phone:hover {
color: #fff;
}
.loc-hero-chips {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.loc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
.loc-chip-link {
text-decoration: none;
transition: background 0.18s ease;
}
.loc-chip-link:hover {
background: rgba(255, 255, 255, 0.18);
}
.loc-chip-stars {
color: var(--yellow);
letter-spacing: 1px;
font-size: 13px;
}
/* ── Highlights ── */
.loc-highlights {
margin-top: -56px;
padding: 0 0 88px;
position: relative;
z-index: 2;
}
.loc-highlights-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.loc-highlight-card {
position: relative;
overflow: hidden;
padding: 24px 24px 22px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(255, 209, 71, 0.22), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, #f7f4ec 100%);
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 18px 44px rgba(13, 26, 13, 0.09);
text-align: left;
}
.loc-highlight-card::after {
content: '';
position: absolute;
right: -18px;
bottom: -18px;
width: 96px;
height: 96px;
border-radius: 50%;
background: rgba(33, 48, 33, 0.05);
pointer-events: none;
}
.loc-highlight-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.loc-highlight-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 18px rgba(255, 209, 71, 0.24);
flex: 0 0 auto;
}
:global(.loc-highlight-icon-wrap .loc-highlight-icon) {
color: var(--gw-green);
font-size: 18px;
}
.loc-highlight-label {
display: inline-block;
color: var(--gw-green);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loc-highlight-card strong {
display: block;
margin: 0 0 8px;
color: #0d1a0d;
font-family: var(--font-head);
font-size: clamp(22px, 2.5vw, 28px);
line-height: 1.05;
letter-spacing: -0.03em;
}
.loc-highlight-card p {
margin: 0;
color: #4c5056;
font-size: 14px;
line-height: 1.6;
}
/* ── Section headers ── */
.loc-section-header {
text-align: center;
margin-bottom: 48px;
}
.loc-section-header h2 {
margin: 0 0 12px;
font-family: var(--font-head);
font-size: clamp(26px, 3vw, 38px);
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
color: #0d1a0d;
}
.loc-section-intro {
max-width: 560px;
margin: 0 auto;
color: #4c5056;
font-size: 16px;
line-height: 1.65;
}
/* ── Parks ── */
.loc-parks {
padding: 0 0 88px;
}
.loc-parks-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.loc-park-card {
background: #fff;
border-radius: 20px;
padding: 32px 28px;
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
}
.loc-park-icon {
font-size: 28px;
margin-bottom: 16px;
}
.loc-park-card h3 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #0d1a0d;
}
.loc-park-card p {
margin: 0 0 14px;
color: #4c5056;
font-size: 15px;
line-height: 1.65;
}
.loc-park-leash {
display: inline-block;
padding: 5px 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.07);
color: var(--gw-green);
font-size: 12px;
font-weight: 600;
}
/* ── Gallery ── */
.loc-gallery {
padding: 0 0 88px;
}
.loc-gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.loc-gallery-card {
margin: 0;
overflow: hidden;
border-radius: 20px;
background: #fff;
border: 1px solid rgba(17, 20, 24, 0.07);
box-shadow: 0 8px 28px rgba(17, 20, 24, 0.06);
}
.loc-gallery-card picture,
.loc-gallery-card img {
display: block;
width: 100%;
}
.loc-gallery-card img {
aspect-ratio: 4 / 3;
object-fit: cover;
}
.loc-gallery-card figcaption {
display: grid;
gap: 6px;
padding: 18px 20px 20px;
}
.loc-gallery-card strong {
color: #0d1a0d;
font-size: 16px;
line-height: 1.35;
}
.loc-gallery-card span {
color: #4c5056;
font-size: 14px;
line-height: 1.6;
}
/* ── Services ── */
.loc-services {
padding: 0 0 88px;
}
.loc-services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.loc-service-card {
display: flex;
flex-direction: column;
padding: 28px 24px;
background: var(--gw-green);
border-radius: 20px;
text-decoration: none;
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
}
@media (hover: hover) {
.loc-highlight-card:hover {
transform: translateY(-3px);
box-shadow: 0 20px 40px rgba(13, 26, 13, 0.12);
}
.loc-park-card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 32px rgba(17, 20, 24, 0.08);
}
.loc-service-card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 36px rgba(33, 48, 33, 0.2);
}
}
.loc-park-card,
.loc-highlight-card,
.loc-gallery-card,
.loc-service-card {
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
}
.loc-service-icon-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 0 20px;
border-radius: 50%;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 24px rgba(17, 20, 24, 0.16);
}
:global(.loc-service-icon-bubble .loc-service-icon) {
color: var(--gw-green);
font-size: 28px;
}
.loc-service-card h3 {
margin: 0 0 8px;
font-family: var(--font-head);
font-size: 20px;
font-weight: 700;
color: #fff;
}
.loc-service-card p {
margin: 0;
flex: 1;
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
line-height: 1.55;
}
.loc-service-link {
display: inline-block;
margin-top: 18px;
color: var(--yellow);
font-size: 14px;
font-weight: 700;
}
/* ── Review ── */
.loc-review {
padding: 0 0 88px;
}
.loc-review-card {
background: #fff;
border-radius: 24px;
padding: 48px 56px;
text-align: center;
border: 1px solid rgba(17, 20, 24, 0.06);
box-shadow: 0 8px 32px rgba(17, 20, 24, 0.05);
}
.loc-review-stars {
display: block;
color: var(--yellow);
font-size: 20px;
letter-spacing: 3px;
margin-bottom: 20px;
}
.loc-review-quote {
margin: 0 0 20px;
font-family: var(--font-head);
font-size: clamp(18px, 2.2vw, 24px);
font-weight: 600;
line-height: 1.45;
color: #0d1a0d;
font-style: normal;
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
.loc-review-cite {
font-style: normal;
color: var(--gw-green);
font-weight: 700;
font-size: 15px;
}
.loc-review-detail {
font-weight: 400;
color: #888;
margin-left: 4px;
}
/* ── CTA ── */
.loc-cta {
padding: 0 0 88px;
}
.loc-cta-card {
background: var(--gw-green);
color: #fff;
border-radius: 28px;
padding: 56px 48px;
text-align: center;
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.18);
}
.loc-cta-card h2 {
margin: 0 0 10px;
font-family: var(--font-head);
font-size: clamp(26px, 3vw, 40px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
color: #fff;
}
.loc-cta-desc {
max-width: 460px;
margin: 0 auto 28px;
color: rgba(255, 255, 255, 0.75);
font-size: 16px;
line-height: 1.6;
}
.loc-cta-btn {
display: flex;
width: fit-content;
margin: 0 auto;
}
.loc-cta-links {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
margin-top: 22px;
}
.loc-cta-link {
color: rgba(255, 255, 255, 0.72);
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: color 0.18s ease;
}
.loc-cta-link:hover {
color: #fff;
}
/* ── Reveal ── */
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
transition:
opacity 0.55s ease,
transform 0.7s 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);
}
/* ── Tablet ── */
@media (max-width: 1024px) {
.loc-highlights-grid,
.loc-parks-grid,
.loc-gallery-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.loc-inner {
padding: 0 24px;
}
.loc-hero {
padding: 56px 0 48px;
}
.loc-hero h1 {
font-size: 34px;
}
.loc-hero-desc {
font-size: 15px;
}
.loc-hero-actions {
flex-direction: column;
gap: 12px;
}
.loc-highlights {
margin-top: -24px;
padding-bottom: 60px;
}
.loc-highlights-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.loc-highlight-card {
padding: 20px 18px 18px;
}
.loc-parks {
padding: 60px 0;
}
.loc-parks-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.loc-gallery {
padding-bottom: 60px;
}
.loc-gallery-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.loc-services {
padding-bottom: 60px;
}
.loc-services-grid {
grid-template-columns: 1fr;
gap: 14px;
}
.loc-review {
padding-bottom: 60px;
}
.loc-review-card {
padding: 32px 24px;
}
.loc-cta {
padding-bottom: 60px;
}
.loc-cta-card {
padding: 36px 24px;
border-radius: 24px;
}
.loc-cta-links {
flex-direction: column;
align-items: center;
gap: 14px;
}
}
</style>
+5 -1
View File
@@ -24,7 +24,11 @@
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
$: pathname = $page.url.pathname;
$: hidden = pathname === '/contact-us' || pathname === '/booking';
$: hidden =
pathname === '/contact-us' ||
pathname === '/booking' ||
$page.url.hostname === 'onboarding.goodwalk.co.nz' ||
$page.url.searchParams.get('preview') === 'onboarding';
let visible = false;
let triggerPassed = false;
+363
View File
@@ -0,0 +1,363 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
export let context: 'onboarding' | 'contract' = 'onboarding';
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, string>; draft: Record<string, unknown> } }>();
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> = {};
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 ?? {};
}
} catch { /* ignore */ }
dispatch('authenticated', { email: data.email, profile, draft });
} 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>
<p>Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code.</p>
<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 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="123456"
autocomplete="one-time-code"
disabled={loading}
class="auth-code-input"
/>
</div>
{#if error}
<div class="auth-error">{error}</div>
{/if}
<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 class="auth-back" on:click={goBack}>
<Icon name="fas fa-arrow-left" /> Use a different email
</button>
{/if}
<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>
</div>
</div>
<footer class="auth-copyright">
<a href="https://goodwalk.co.nz">goodwalk.co.nz</a>
<span>&middot;</span>
<span>&copy; {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>
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
export let email = '';
const dispatch = createEventDispatcher<{ logout: void }>();
let loggingOut = false;
async function logout() {
loggingOut = true;
try {
const token = window.localStorage.getItem('gw_onboarding_session') ?? '';
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => { /* ignore network errors on logout */ });
}
} finally {
try { window.localStorage.removeItem('gw_onboarding_session'); } catch { /* ignore */ }
loggingOut = false;
dispatch('logout');
}
}
</script>
<footer class="ob-footer">
<div class="ob-footer-inner">
<div class="ob-footer-identity">
<Icon name="fas fa-circle-check" />
<span>Signed in as <strong>{email}</strong></span>
</div>
<button class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
<Icon name="fas fa-right-from-bracket" />
{loggingOut ? 'Signing out…' : 'Sign out'}
</button>
</div>
<div class="ob-footer-copyright">
<a href="https://goodwalk.co.nz">goodwalk.co.nz</a>
<span>&middot;</span>
<span>&copy; {new Date().getFullYear()} Goodwalk. All rights reserved.</span>
</div>
</footer>
<style>
.ob-footer {
background: #213021;
margin-top: auto;
}
.ob-footer-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 28px;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.ob-footer-identity {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
.ob-footer-identity strong {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.ob-footer-logout {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent;
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.ob-footer-logout:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.ob-footer-copyright {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 28px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
flex-wrap: wrap;
}
.ob-footer-copyright a {
color: rgba(255, 255, 255, 0.35);
text-decoration: none;
font-weight: 600;
}
.ob-footer-copyright a:hover {
color: rgba(255, 255, 255, 0.6);
}
@media (max-width: 768px) {
.ob-footer-inner {
padding: 0 18px;
}
.ob-footer-identity span {
display: none;
}
.ob-footer-copyright {
padding: 10px 18px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from 'svelte';
export let value = '';
export let disabled = false;
let canvas: HTMLCanvasElement;
let isDrawing = false;
let hasSigned = false;
let activePointerId: number | null = null;
let lines: { x: number; y: number }[][] = [];
function resizeCanvas() {
if (!canvas) return;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(rect.width * ratio));
canvas.height = Math.max(1, Math.round(rect.height * ratio));
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
drawAllLines();
syncValue();
}
function getContext() {
return canvas?.getContext('2d') ?? null;
}
function drawAllLines() {
const ctx = getContext();
if (!ctx || !canvas) return;
const width = canvas.width / Math.max(window.devicePixelRatio || 1, 1);
const height = canvas.height / Math.max(window.devicePixelRatio || 1, 1);
ctx.clearRect(0, 0, width, height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#213021';
ctx.lineWidth = 3;
for (const line of lines) {
if (!line.length) continue;
ctx.beginPath();
ctx.moveTo(line[0].x, line[0].y);
if (line.length === 1) {
ctx.lineTo(line[0].x + 0.01, line[0].y + 0.01);
} else {
for (const point of line.slice(1)) {
ctx.lineTo(point.x, point.y);
}
}
ctx.stroke();
}
}
function pointFromEvent(event: PointerEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
function syncValue() {
value = hasSigned && canvas ? canvas.toDataURL('image/png') : '';
}
function startDrawing(event: PointerEvent) {
if (disabled) return;
activePointerId = event.pointerId;
isDrawing = true;
canvas.setPointerCapture(event.pointerId);
const point = pointFromEvent(event);
lines = [...lines, [point]];
hasSigned = true;
drawAllLines();
syncValue();
}
function continueDrawing(event: PointerEvent) {
if (!isDrawing || disabled || activePointerId !== event.pointerId) return;
const point = pointFromEvent(event);
const nextLines = [...lines];
const currentLine = nextLines[nextLines.length - 1];
if (!currentLine) return;
currentLine.push(point);
lines = nextLines;
drawAllLines();
syncValue();
}
function stopDrawing(event?: PointerEvent) {
if (event && activePointerId === event.pointerId && canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
activePointerId = null;
isDrawing = false;
syncValue();
}
export function clear() {
lines = [];
hasSigned = false;
drawAllLines();
syncValue();
}
onMount(() => {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => {
window.removeEventListener('resize', resizeCanvas);
};
});
</script>
<div class:signature-disabled={disabled} class="signature-shell">
<canvas
bind:this={canvas}
class="signature-canvas"
aria-label="Draw your signature"
on:pointerdown={startDrawing}
on:pointermove={continueDrawing}
on:pointerup={stopDrawing}
on:pointerleave={stopDrawing}
on:pointercancel={stopDrawing}
></canvas>
{#if !value}
<div class="signature-hint" aria-hidden="true">Sign here</div>
{/if}
</div>
<style>
.signature-shell {
position: relative;
width: 100%;
min-height: 180px;
border-radius: 18px;
background: #fff;
overflow: hidden;
}
.signature-shell.signature-disabled {
opacity: 0.7;
}
.signature-canvas {
display: block;
width: 100%;
height: 180px;
touch-action: none;
cursor: crosshair;
}
.signature-disabled .signature-canvas {
cursor: not-allowed;
}
.signature-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-head);
font-size: 24px;
color: rgba(33, 48, 33, 0.22);
pointer-events: none;
}
@media (max-width: 768px) {
.signature-shell {
min-height: 160px;
}
.signature-canvas {
height: 160px;
}
.signature-hint {
font-size: 21px;
}
}
</style>
+15 -10
View File
@@ -192,7 +192,7 @@
{/each}
</div>
<a class="btn btn-yellow pricing-section-mobile-cta" href="#newlead">
<a class="btn btn-yellow btn-mobile-center pricing-section-mobile-cta" href="#newlead">
Book a Meet &amp; Greet
</a>
@@ -205,7 +205,7 @@
<p>
Book a free Meet &amp; Greet and well help you choose the right walk or visit for your dog.
</p>
<a class="btn btn-outline btn-outline-green pricing-mobile-consult-cta" href="#newlead">
<a class="btn btn-outline btn-outline-green btn-mobile-center pricing-mobile-consult-cta" href="#newlead">
Talk it through with us
</a>
</aside>
@@ -214,7 +214,11 @@
</section>
{/each}
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
<TestimonialsSection
heading={pageContent.testimonialsHeading}
testimonials={content.testimonials}
seedKey="/our-pricing"
/>
<BookingSection booking={pageContent.booking} />
{#if showMeetGreetPrompt}
@@ -281,8 +285,11 @@
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
font-family: var(--font-head);
font-size: 14px;
font-weight: 600;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
text-decoration: none;
transition:
background 0.2s ease,
@@ -308,10 +315,6 @@
font-size: 13px;
}
.pricing-trust-label {
letter-spacing: 0.01em;
}
:global(.pricing-trust .pricing-trust-arrow) {
font-size: 12px;
opacity: 0.85;
@@ -322,8 +325,10 @@
text-align: center;
font-family: var(--font-head);
font-size: clamp(24px, 2.8vw, 36px);
line-height: 1.1;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
color: #000;
}
@@ -658,7 +663,7 @@
gap: 18px;
}
.pricing-plan-popular {
.pricing-plan-card {
order: var(--mobile-order, 0);
}
+93 -13
View File
@@ -1,18 +1,25 @@
<script lang="ts">
import { getImageMetadata } from '$lib/image-metadata';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { PromiseContent } from '$lib/types';
export let promise: PromiseContent;
$: promiseImage = getImageMetadata(promise.imageUrl);
$: promiseEnhanced = getEnhancedImage(promise.imageUrl);
</script>
<section id="promise">
<div class="promise-inner">
<div class="promise-text">
<h2>
{promise.title}<br />
{promise.subtitle}
<h2 class="promise-heading">
<span class="promise-heading-desktop">
<span class="promise-title-main">{promise.title}</span>
<br />
<span class="promise-title-highlight">{promise.subtitle}</span>
</span>
<span class="promise-heading-mobile">
<span class="promise-title-main">{promise.title}</span>
<span class="promise-title-highlight">{promise.subtitle}</span>
</span>
</h2>
{#each promise.body as paragraph, idx}
@@ -29,15 +36,88 @@
<div class="promise-img">
<div class="promise-img-frame">
<img
src={promise.imageUrl}
alt={promise.imageAlt}
width={promiseImage?.width}
height={promiseImage?.height}
loading="lazy"
decoding="async"
/>
{#if promiseEnhanced}
<enhanced:img
src={promiseEnhanced}
alt={promise.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img src={promise.imageUrl} alt={promise.imageAlt} loading="lazy" decoding="async" />
{/if}
</div>
</div>
</div>
</section>
<style>
.promise-heading-desktop {
display: block;
}
.promise-heading-mobile {
display: none;
}
.promise-heading-mobile .promise-title-main,
.promise-heading-mobile .promise-title-highlight {
display: block;
}
.promise-title-main {
color: #0d1a0d;
}
.promise-title-highlight {
position: relative;
display: inline-block;
color: #0d1a0d;
}
.promise-title-highlight::after {
content: '';
position: absolute;
left: 0;
right: -6px;
bottom: -16px;
height: 24px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
center/contain no-repeat;
transform-origin: left center;
animation: promise-underline-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both;
}
@keyframes promise-underline-draw {
0% {
opacity: 0;
transform: scaleX(0.2) translateY(6px) rotate(-1.5deg);
}
65% {
opacity: 1;
transform: scaleX(1.04) translateY(0) rotate(0deg);
}
100% {
opacity: 1;
transform: scaleX(1) translateY(0) rotate(0deg);
}
}
@media (max-width: 768px) {
.promise-heading-desktop {
display: none;
}
.promise-heading-mobile {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.promise-title-highlight::after {
animation: none;
}
}
</style>
+91 -95
View File
@@ -3,7 +3,7 @@
import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte';
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import { getEnhancedImage } from '$lib/enhanced-images';
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
export let content: SiteSharedContent;
@@ -30,12 +30,12 @@
}));
}
$: heroImage = getImageMetadata(pageContent.hero.imageUrl);
$: highlightImage = pageContent.highlight ? getImageMetadata(pageContent.highlight.imageUrl) : null;
$: heroEnhanced = getEnhancedImage(pageContent.hero.imageUrl);
$: highlightEnhanced = pageContent.highlight ? getEnhancedImage(pageContent.highlight.imageUrl) : null;
$: highlightCollageImages =
pageContent.highlight?.collageImages?.map((image) => ({
...image,
meta: getImageMetadata(image.imageUrl)
enhanced: getEnhancedImage(image.imageUrl)
})) ?? [];
$: relatedServices = content.services.filter((s) => s.href && s.href !== currentPath);
$: pricingPlans = decoratePlans(pageContent.pricing.plans);
@@ -73,15 +73,23 @@
</div>
<div class="service-hero-media">
<img
src={pageContent.hero.imageUrl}
alt={pageContent.hero.imageAlt}
width={heroImage?.width}
height={heroImage?.height}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{#if heroEnhanced}
<enhanced:img
src={heroEnhanced}
alt={pageContent.hero.imageAlt}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{:else}
<img
src={pageContent.hero.imageUrl}
alt={pageContent.hero.imageAlt}
loading="eager"
fetchpriority="high"
decoding="async"
/>
{/if}
</div>
</div>
</section>
@@ -98,27 +106,31 @@
<div class="service-highlight-collage" aria-label={pageContent.highlight.title}>
{#each highlightCollageImages as image, index}
<figure class={`service-collage-card service-collage-card-${index + 1}`}>
<img
src={image.imageUrl}
alt={image.imageAlt}
width={image.meta?.width}
height={image.meta?.height}
loading="lazy"
decoding="async"
/>
{#if image.enhanced}
<enhanced:img src={image.enhanced} alt={image.imageAlt} loading="lazy" decoding="async" />
{:else}
<img src={image.imageUrl} alt={image.imageAlt} loading="lazy" decoding="async" />
{/if}
</figure>
{/each}
</div>
{:else}
<div class="service-highlight-image">
<img
src={pageContent.highlight.imageUrl}
alt={pageContent.highlight.imageAlt}
width={highlightImage?.width}
height={highlightImage?.height}
loading="lazy"
decoding="async"
/>
{#if highlightEnhanced}
<enhanced:img
src={highlightEnhanced}
alt={pageContent.highlight.imageAlt}
loading="lazy"
decoding="async"
/>
{:else}
<img
src={pageContent.highlight.imageUrl}
alt={pageContent.highlight.imageAlt}
loading="lazy"
decoding="async"
/>
{/if}
</div>
{/if}
</div>
@@ -172,7 +184,7 @@
Every booking starts with a free, no-obligation Meet &amp; Greet.
</p>
<a class="btn btn-yellow service-plan-mobile-cta" href="#newlead">Book a Meet &amp; Greet</a>
<a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta" href="#newlead">Book a Meet &amp; Greet</a>
{#if pageContent.pricing.extras?.length}
<div class="service-extras">
@@ -246,7 +258,11 @@
</section>
{/if}
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
<TestimonialsSection
heading={pageContent.testimonialsHeading}
testimonials={content.testimonials}
seedKey={currentPath}
/>
<BookingSection booking={pageContent.booking} />
</main>
@@ -276,67 +292,26 @@
}
.service-related-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 28px 26px;
align-items: center;
text-align: center;
gap: 0;
padding: 34px 28px 30px;
border-radius: 28px;
background: #fff;
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
background: var(--off-white);
box-shadow: 0 10px 28px rgba(17, 20, 24, 0.05);
color: #000;
text-decoration: none;
overflow: hidden;
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease;
}
.service-related-card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 4px;
background: var(--card-accent, var(--yellow));
}
.service-related-tint-0 {
--card-accent: var(--yellow);
}
.service-related-tint-0 .service-related-icon {
background: #fff3c6;
color: #5a4500;
}
.service-related-tint-1 {
--card-accent: var(--gw-green);
}
.service-related-tint-1 .service-related-icon {
background: #dce6dc;
color: var(--gw-green);
}
.service-related-tint-2 {
--card-accent: #c98a3f;
}
.service-related-tint-2 .service-related-icon {
background: #efe4d1;
color: var(--gw-green);
}
.service-related-tint-3 {
--card-accent: #9ca3af;
}
.service-related-tint-3 .service-related-icon {
background: #f3f4f6;
color: #4b5563;
}
@media (hover: hover) {
.service-related-card:hover {
transform: translateY(-6px) scale(1.012);
box-shadow: 0 20px 40px rgba(17, 20, 24, 0.09);
box-shadow: 0 18px 38px rgba(17, 20, 24, 0.1);
}
}
@@ -348,19 +323,22 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
width: 72px;
height: 72px;
border-radius: 50%;
background: #efe4d1;
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
color: var(--gw-green);
font-size: 18px;
margin-bottom: 8px;
font-size: 28px;
margin-bottom: 22px;
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 10px 24px rgba(17, 20, 24, 0.08);
}
.service-related-card h3 {
margin: 0;
margin: 0 0 10px;
font-family: var(--font-head);
font-size: 22px;
font-size: 20px;
line-height: 1.2;
}
@@ -368,37 +346,44 @@
margin: 0;
color: #34363a;
font-size: 15px;
line-height: 1.55;
line-height: 1.65;
}
.service-related-meta {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
}
.service-related-price {
display: inline-block;
padding: 6px 14px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.06);
font-family: var(--font-head);
font-weight: 700;
color: var(--gw-green);
font-size: 14px;
letter-spacing: 0.02em;
}
.service-related-pill {
display: inline-block;
padding: 4px 12px;
padding: 6px 14px;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
background: rgba(33, 48, 33, 0.06);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 12px;
font-weight: 600;
font-weight: 700;
letter-spacing: 0.02em;
}
.service-related-link {
margin-top: auto;
padding-top: 6px;
margin-top: 18px;
color: var(--gw-green);
font-family: var(--font-head);
font-weight: 700;
@@ -418,9 +403,20 @@
margin: 0;
font-family: var(--font-head);
font-size: clamp(34px, 4vw, 56px);
color: #000;
}
.service-hero-copy h1 {
line-height: 1.05;
letter-spacing: -0.04em;
color: #000;
}
.service-section-heading h2,
.service-highlight-copy h2 {
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
}
.service-hero-copy p,
@@ -834,7 +830,7 @@
gap: 24px;
}
.service-plan-popular {
.service-plan-card {
order: var(--mobile-order, 0);
}
@@ -148,4 +148,5 @@
text-underline-offset: 0.18em;
}
}
</style>
+35 -12
View File
@@ -2,7 +2,8 @@
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import { getEnhancedImage } from '$lib/enhanced-images';
import { getSeededTestimonialIndex } from '$lib/testimonials';
import type { TestimonialContent } from '$lib/types';
export let testimonials: TestimonialContent[];
@@ -11,6 +12,7 @@
export let blurb = 'Peace of mind for busy Auckland dog owners. Happier dogs, smoother routines, and a team owners trust with the important stuff.';
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
export let instagramLabel = 'goodwalk.nz';
export let seedKey = '';
type TestimonialSlide = TestimonialContent & { imageUrl: string };
@@ -50,6 +52,7 @@
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
let slideSignature = '';
$: slides = testimonials
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
@@ -59,6 +62,15 @@
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() : '';
@@ -163,16 +175,24 @@
<div class="testimonial-photo-wrap">
<div class="testimonial-photo-frame">
{#if index === activeIndex}
{@const imageMeta = getImageMetadata(testimonial.imageUrl)}
<img
class="testimonial-photo"
src={testimonial.imageUrl}
alt={testimonialAlt(testimonial)}
width={imageMeta?.width}
height={imageMeta?.height}
loading="lazy"
decoding="async"
/>
{@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>
@@ -489,8 +509,11 @@
border-radius: 999px;
background: #f8f8f8;
color: #0a304e;
font-family: var(--font-head);
font-size: 14px;
line-height: 1.3;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
}
+28 -13
View File
@@ -5,10 +5,10 @@ import { homepageContent } from '$lib/content/homepage';
import type { TestimonialContent } from '$lib/types';
const expectedMappedSlides = [
{ reviewer: 'Kate', src: '/images/archie-auckland-dog-walking-review.png' },
{ reviewer: 'Estelle', src: '/images/monty-auckland-dog-walking-review.png' },
{ reviewer: 'Ross', src: '/images/otis-auckland-dog-walking-review.png' },
{ reviewer: 'Nina', src: '/images/wallace-auckland-dog-walking-review.png' }
{ reviewer: 'Kate' },
{ reviewer: 'Estelle' },
{ reviewer: 'Ross' },
{ reviewer: 'Nina' }
];
function getActiveSlide(container: HTMLElement) {
@@ -23,6 +23,14 @@ 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();
@@ -33,11 +41,11 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
const nextButton = getNextButton(container);
for (const [index, slide] of expectedMappedSlides.entries()) {
expect(getActiveReviewer(container)).toBe(slide.reviewer);
expect(getActiveImage(container).getAttribute('src')).toBe(slide.src);
expect(getActiveImage(container)).toBeTruthy();
if (index < expectedMappedSlides.length - 1) {
await fireEvent.click(nextButton);
@@ -52,7 +60,7 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
const nextButton = getNextButton(container);
expect(getActiveReviewer(container)).toBe('Kate');
@@ -68,16 +76,14 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const previousButton = screen.getByRole('button', { name: /previous testimonial/i });
const previousButton = getPreviousButton(container);
expect(getActiveReviewer(container)).toBe('Kate');
await fireEvent.click(previousButton);
expect(getActiveReviewer(container)).toBe('Nina');
expect(getActiveImage(container).getAttribute('src')).toBe(
'/images/wallace-auckland-dog-walking-review.png'
);
expect(getActiveImage(container)).toBeTruthy();
});
it('keeps custom testimonial images and filters out testimonials with no image', async () => {
@@ -100,7 +106,7 @@ describe('TestimonialsSection', () => {
testimonials: customTestimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
const nextButton = getNextButton(container);
expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5);
@@ -109,7 +115,16 @@ describe('TestimonialsSection', () => {
}
expect(getActiveReviewer(container)).toBe('Casey');
expect(getActiveImage(container).getAttribute('src')).toBe('/images/custom-casey-review.png');
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');
});
});
+7 -3
View File
@@ -30,9 +30,13 @@
<div class="values-grid">
{#each orderedValues as value}
<div class="value-card">
<Icon name={value.icon} className="value-card-icon" />
<h3>{value.title}</h3>
<p>{value.body}</p>
<div class="value-icon-wrap">
<Icon name={value.icon} className="value-card-icon" />
</div>
<div class="value-text">
<h3>{value.title}</h3>
<p>{value.body}</p>
</div>
</div>
{/each}
</div>