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
+25
View File
@@ -0,0 +1,25 @@
export function accordion(node: HTMLElement) {
function handleToggle(event: Event) {
const target = event.target;
if (!(target instanceof HTMLDetailsElement) || !target.open || !node.contains(target)) {
return;
}
const details = node.querySelectorAll('details');
for (const item of details) {
if (item !== target) {
item.open = false;
}
}
}
node.addEventListener('toggle', handleToggle, true);
return {
destroy() {
node.removeEventListener('toggle', handleToggle, true);
}
};
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { reveal } from './reveal';
class TestIntersectionObserver {
static instances: TestIntersectionObserver[] = [];
callback: IntersectionObserverCallback;
disconnect = vi.fn();
observe = vi.fn();
unobserve = vi.fn();
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
TestIntersectionObserver.instances.push(this);
}
trigger(target: Element, isIntersecting: boolean) {
this.callback(
[{ isIntersecting, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
}
describe('reveal action', () => {
afterEach(() => {
TestIntersectionObserver.instances = [];
vi.unstubAllGlobals();
});
it('toggles visibility as the element enters and leaves the viewport', () => {
vi.stubGlobal('IntersectionObserver', TestIntersectionObserver);
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
} as MediaQueryList);
const node = document.createElement('div');
document.body.appendChild(node);
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue({
width: 100,
height: 100,
top: window.innerHeight + 100,
right: 100,
bottom: window.innerHeight + 200,
left: 0,
x: 0,
y: window.innerHeight + 100,
toJSON() {
return {};
}
} as DOMRect);
const action = reveal(node, { delay: 40, distance: 32 });
const observer = TestIntersectionObserver.instances[0];
expect(node.classList.contains('reveal-ready')).toBe(true);
expect(node.classList.contains('reveal-visible')).toBe(false);
expect(node.style.getPropertyValue('--reveal-delay')).toBe('40ms');
expect(node.style.getPropertyValue('--reveal-distance')).toBe('32px');
observer.trigger(node, true);
expect(node.classList.contains('reveal-visible')).toBe(true);
observer.trigger(node, false);
expect(node.classList.contains('reveal-visible')).toBe(false);
action.destroy();
expect(observer.disconnect).toHaveBeenCalledTimes(1);
});
});
+9 -4
View File
@@ -47,13 +47,18 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
if (entry.isIntersecting) {
node.classList.add('reveal-visible');
continue;
}
node.classList.add('reveal-visible');
observer.disconnect();
break;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const rect = entry.boundingClientRect;
const fullyOutOfView = rect.bottom <= 0 || rect.top >= viewportHeight;
if (fullyOutOfView) {
node.classList.remove('reveal-visible');
}
}
},
{
+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>
+55 -14
View File
@@ -4,36 +4,77 @@ export const aboutPageContent: AboutPageContent = {
title: 'About Us',
sections: [
{
eyebrow: 'Our story',
title: 'Who we are',
body: [
"At GoodWalk, we're not your average dog walking service. We're a team of passionate dog lovers dedicated to providing top-notch care for your furry friends. Specialising in small dogs, we understand their unique needs firsthand, being small dog owners ourselves!",
"Our commitment to excellence has quickly made us a leader in Auckland Central's dog-walking scene. From pack walks to one-on-one sessions, we ensure the happiness and well-being of every dog in our care."
"Goodwalk is built around Alessandra — who started this because she couldn't find a walker she actually trusted, and hasn't stopped showing up the same way since. She walks every dog herself, posts updates to Instagram so you can see exactly what your dog is up to, and has built relationships with some dogs from as young as ten weeks old. Thirty-plus five-star Google reviews later, the feedback keeps saying the same thing: the dogs adore her, and their owners finally stop worrying.",
"We specialise in small and medium dogs because we understand them — not as a category, but as actual dogs with specific needs, specific quirks, and specific ways they fall apart in the wrong environment. The pace of a walk matters. The size of the group matters. The temperament of the other dogs matters. That's why we built a service around them, not just one that fits them in."
],
imageUrl: '/images/auckland-pack-walk-dog.jpg',
imageAlt: 'Dog on a Goodwalk pack walk'
imageUrl: '/images/auckland-pack-walk-small-dogs-group.png',
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park"
},
{
title: 'Our impact',
eyebrow: 'What we stand for',
title: 'How we do things',
body: [
"At GoodWalk, we believe in positive reinforcement training to help your dog thrive in the world. Safety, professionalism, well-being, fun, structure, and compassion are the cornerstones of our business ethos.",
"When you choose GoodWalk, you're choosing a partner who will treat your dog like family, because that's exactly what they are to us."
"Every walk you've seen across our pages — the Tiny Gang outings, the one-on-ones, the puppy visits — runs on the same principles. Calm handling, positive reinforcement, and a walker who already knows your dog. That's not a promise we make at signup. It's how every single walk actually goes.",
"We keep packs small because we mean it when we say your dog gets real attention. We cover pickup and drop-off because your day shouldn't have to work around us. And every walker holds pet first aid certification and public liability insurance — because the dogs in our care aren't just bookings, they're the whole reason we do this."
],
imageUrl: '/images/auckland-dog-group-outing.jpg',
imageAlt: 'Goodwalk dogs enjoying an outing together',
imageAlt: 'Goodwalk dogs enjoying a group outing in Auckland',
reverse: true,
accent: 'gradient'
},
{
title: 'Meet the team',
eyebrow: 'Meet the founder',
title: 'The heart of Goodwalk',
body: [
'Behind GoodWalk is Alessandra, an Italian who has a deep passion for dogs. With her love for animals and years of experience, Alessandra leads our team with dedication and expertise, ensuring that every dog receives the love and attention they deserve.',
"And let's not forget about Maya, our marketing manager! A Cavalier King Charles cross Shih Tzu, Maya is full of sass and personality, bringing a touch of charm and flair to everything we do."
"Alessandra started Goodwalk because she couldn't find a walker she actually trusted with Maya. So she became one. Italian-born and Auckland Central-based, she leads every walk herself — not because she has to, but because handing that off was never something she was willing to do. The dogs she walks have her full attention. Their owners have her number.",
"Maya is a Cavalier King Charles cross Shih Tzu, and the reason small dogs sit at the centre of everything Goodwalk does. She is opinionated, dramatic when it rains, and completely impossible to ignore on a walk. She is also the best argument we have for why small dogs deserve a service built specifically around them — not just accommodated by one."
],
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
imageAlt: 'Goodwalk staff member Aless'
imageUrl: '/images/founder-image-aless-goodwalk.png',
imageAlt: 'Alessandra, founder of Goodwalk Auckland',
accent: 'founder'
}
],
faqTitle: 'Questions about Goodwalk',
faqs: [
{
question: 'Who actually walks my dog?',
answer:
'Alessandra, the founder, personally leads every walk. We are not a platform or an agency — you will always know who is at the door.'
},
{
question: 'Why do you specialise in small dogs?',
answer:
'Small dogs have different energy levels, social dynamics, and handling needs compared to larger breeds. Alessandra is a small dog owner herself, and Goodwalk was built specifically around what those dogs need — not adapted from a one-size-fits-all model.'
},
{
question: 'How big are your packs?',
answer:
'We keep Tiny Gang packs to 48 dogs. Smaller packs mean better supervision, calmer outings, and dogs that actually come home settled rather than overstimulated.'
},
{
question: "What is a Meet & Greet?",
answer:
'A Meet & Greet is a free, no-obligation introduction where Alessandra meets you and your dog in person. It is a chance to ask questions, see how your dog responds, and decide if Goodwalk is the right fit — with no pressure either way.'
},
{
question: 'What suburbs do you cover?',
answer:
'We cover most of Auckland Central including Ponsonby, Grey Lynn, Mt Eden, Kingsland, Morningside, Sandringham, Mt Albert, Mt Roskill, Herne Bay, Freemans Bay, Pt Chevalier, Avondale, Eden Terrace, Balmoral, and more. If you are nearby and unsure, just ask.'
},
{
question: 'Are your walkers insured and first-aid trained?',
answer:
'Yes. All walkers hold public liability insurance and a current pet first aid certificate. Your dog is covered from pickup to drop-off.'
},
{
question: 'What does onboarding look like?',
answer:
'Every new dog goes through a screening process that includes at minimum two assessment walks. This lets us make sure the pack is the right fit for your dog, and your dog is the right fit for the pack.'
}
],
servicesTitle: 'Explore our services',
contact: {
title: "Let's get started!",
email: 'info@goodwalk.co.nz',
+2 -1
View File
@@ -7,7 +7,8 @@ export const dogWalkingContent: ServicePageContent = {
paragraphs: [
'Goodwalk 1:1 Walks are for dogs who do better with more individual attention, a quieter setup, and a walk tailored to their own pace, confidence, and routine.',
'They can be a great fit for larger dogs, dogs who are not suited to group walks, or owners who want a more personal approach with extra care and consistency.',
'If your dog needs space, structure, and a walker who can focus fully on them, our one-on-one walks are designed for exactly that.'
'If your dog needs space, structure, and a walker who can focus fully on them, our one-on-one walks are designed for exactly that.',
'We run 1:1 walks across Auckland Central — including Mt Eden, Ponsonby, Kingsland, Grey Lynn, Herne Bay, and surrounding suburbs — with free pickup and drop-off included.'
],
imageUrl: '/images/auckland-large-dog-one-on-one-walk.jpg',
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk'
+27 -56
View File
@@ -1,4 +1,5 @@
import type { HomePageContent } from '$lib/types';
import { sharedServices } from '$lib/content/services';
export const homepageContent: HomePageContent = {
seo: {
@@ -23,11 +24,12 @@ export const homepageContent: HomePageContent = {
],
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' },
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
megaMenuServices: [
{ icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' },
{ icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' },
{ icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' }
],
megaMenuServices: sharedServices.map((service) => ({
icon: service.icon,
label: service.title,
description: service.megaMenuDescription,
href: service.href
})),
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
},
hero: {
@@ -38,10 +40,9 @@ export const homepageContent: HomePageContent = {
'Dog walking for busy Auckland Central professionals who want a reliable, relationship-led team their dog knows by name.',
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
secondaryCta: {
label: 'Message us on Instagram',
href: 'https://www.instagram.com/goodwalk.nz/',
variant: 'outline',
external: true
label: 'See how it works',
href: '#how-it-works',
variant: 'outline'
},
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
@@ -58,37 +59,22 @@ export const homepageContent: HomePageContent = {
title: 'Meet Aless,',
subtitle: 'the heart of Goodwalk',
body: [
'Goodwalk was built for owners who want more than a basic walk. Alessandra leads the business with a calm, hands-on approach shaped by years of experience, a love of small dogs, and a real focus on trust, routine, and safety.',
"From house keys to nervous first walks, we take the responsibility seriously. You'll know who is walking your dog, your dog will know who is at the door, and you'll get a reliable team that treats your dog like family. Ready to join the"
'Goodwalk is built around one thing: a dog who knows the routine, and an owner who stops worrying.',
'Alessandra runs every walk personally — calm, experienced, and trusted by Auckland Central families.',
"Your dog knows who's at the door. You know what happens on the walk. Ready to join the"
],
emphasis: 'TINY GANG?',
cta: { label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'green' },
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
imageUrl: '/images/founder-image-aless-goodwalk.png',
imageAlt: 'Alessandra from Goodwalk with a dog in Auckland'
},
services: [
{
icon: 'fas fa-dog',
title: 'Pack Walks',
body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.',
priceFrom: 'From $49.50 / walk',
href: '/pack-walks'
},
{
icon: 'fas fa-person-walking',
title: '1:1 Walks',
body: "One-on-one walks tailored to your dog's individual pace, personality, and needs.",
priceFrom: 'From $45 / walk',
href: '/dog-walking'
},
{
icon: 'fas fa-house',
title: 'Puppy Visits',
body: 'In-home visits to check in on your puppy, play, and keep them company.',
priceFrom: 'From $39 / visit',
href: '/puppy-visits'
}
],
services: sharedServices.map((service) => ({
icon: service.icon,
title: service.title,
body: service.cardBody,
priceFrom: service.priceFrom,
href: service.href
})),
howItWorks: {
title: 'How it works',
intro:
@@ -125,40 +111,25 @@ export const homepageContent: HomePageContent = {
icon: 'fas fa-heart',
title: 'Calm, kind handling',
body:
'We use positive reinforcement, gentle handling, and patient routines so dogs build confidence instead of stress.'
'Positive reinforcement and patient routines so your dog builds confidence, not stress.'
},
{
icon: 'fas fa-camera',
title: 'Daily updates you will actually want',
title: 'Updates you will actually want',
body:
"You get to see your dog out enjoying the day, which means less wondering and more peace of mind while you're at work."
"See your dog out enjoying the day. Less wondering, more peace of mind while you're at work."
},
{
icon: 'fas fa-users',
title: 'Small Pack Sizes',
order: 2,
title: 'Small pack sizes',
body:
'With just 4-8 dogs per group, walks stay calm, structured, and manageable, with enough attention for every dog.'
'48 dogs per group. Calm, structured walks with enough attention for every dog in the pack.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety-first by default',
order: 1,
body:
'Pet first aid, careful screening, and proactive handling are built into every walk, not added on as a nice extra.'
},
{
icon: 'fas fa-calendar-check',
title: 'Built for real schedules',
body:
"We specialise in regular walks, but we know life changes. Give us notice and we'll do our best to help keep things running smoothly."
},
{
icon: 'fas fa-clock',
title: 'Reliable pickup, clear communication',
order: 3,
body:
"You should not have to chase your dog walker. We keep things consistent, communicate clearly, and make the practical side feel easy."
'Pet first aid, careful screening, and proactive handling built into every walk not added as an extra.'
}
],
testimonials: [
+450
View File
@@ -0,0 +1,450 @@
import type { LocationPageContent } from '$lib/types';
export const locationPages: LocationPageContent[] = [
{
suburb: 'Mt Eden',
slug: 'mt-eden',
intro:
'Mt Eden is one of Auckland Central\'s most walked neighbourhoods — and for good reason. The volcanic cone, leafy streets, and mix of open reserves and quiet paths make it an ideal place for small dogs who thrive on a proper daily outing. Goodwalk runs pack walks and 1:1 walks through Mt Eden as part of a regular weekly routine, with free pickup and drop-off included.',
parks: [
{
name: 'Maungawhau / Mt Eden Domain',
description:
'The volcanic cone at the heart of Mt Eden offers wide open paths, panoramic views across Auckland, and a mix of gentle and steeper terrain. Popular with local dog walkers and a staple route for the Tiny Gang.',
leashNote: 'Dogs must be on leash on the summit and in the Domain.'
},
{
name: 'Potters Park',
description:
'A well-used neighbourhood park on the border of Mt Eden and Sandringham with open grass areas and shade trees. Good for shorter walks and a regular favourite for local dogs.',
leashNote: 'On-leash area.'
},
{
name: 'Cornwall Park / One Tree Hill Domain',
description:
'Just south of Mt Eden, Cornwall Park is one of Auckland\'s most expansive green spaces with sweeping lawns, mature trees, and wide walking paths. The scale makes it excellent for a longer, more varied outing.',
leashNote: 'Dogs must be on leash and are not permitted in fenced farm animal areas.'
}
]
},
{
suburb: 'Kingsland',
slug: 'kingsland',
intro:
'Kingsland sits right in the heart of Auckland Central, with easy access to some of the area\'s best parks and green corridors. Its central position makes it one of our most efficient pickup stops, and dogs from Kingsland are a regular part of the Tiny Gang. Goodwalk covers Kingsland for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Fowlds Park',
description:
'A large open park in neighbouring Morningside, Fowlds Park offers generous grass areas, walking paths, and space to roam. It is one of the most popular spots for dog walking in the central suburbs and a regular route for Goodwalk outings.',
leashNote: 'On-leash area throughout most of the park.'
},
{
name: 'Western Springs Park',
description:
'Surrounding the historic Western Springs lake, this large park provides shaded paths, waterside walking, and a calm environment that suits dogs who prefer a quieter outing away from busy streets.',
leashNote: 'On-leash. Dogs are not permitted in the zoo area.'
},
{
name: 'Chamberlain Park',
description:
'A wide green space adjacent to the golf course with open walkways and flat terrain — easy going for smaller dogs and a good spot for a steady, unhurried walk.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Ponsonby',
slug: 'ponsonby',
intro:
'Ponsonby\'s tree-lined streets and proximity to several of Auckland\'s best parks make it a natural home for dog-loving households. Many of the small dogs in our Tiny Gang are based in Ponsonby, and we know the neighbourhood well. Goodwalk offers pack walks, 1:1 walks, and puppy visits across Ponsonby.',
parks: [
{
name: 'Western Park',
description:
'A terraced hillside reserve running alongside Ponsonby Road, Western Park offers shaded paths, native planting, and a quiet contrast to the busy street above. Well-suited to dogs who enjoy a more enclosed, leafy environment.',
leashNote: 'On-leash area.'
},
{
name: 'Victoria Park',
description:
'One of Auckland\'s most central parks, Victoria Park is a large open space with wide paths, mature trees, and plenty of room for small dogs to enjoy a proper walk without feeling crowded.',
leashNote: 'On-leash area.'
},
{
name: 'Herne Bay Foreshore',
description:
'A short walk from central Ponsonby, the Herne Bay waterfront and reserve offers coastal views, fresh sea air, and a relaxed route that small dogs tend to love.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Grey Lynn',
slug: 'grey-lynn',
intro:
'Grey Lynn is a dog-friendly suburb with one of Auckland Central\'s most popular parks right at its centre. It\'s a densely walkable area with good access to open green space, and a regular part of our Tiny Gang routes. Goodwalk serves Grey Lynn for all services — pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Grey Lynn Park',
description:
'One of the most popular dog walking destinations in Auckland Central, Grey Lynn Park has large open grass areas, wide paths, and an off-leash zone. It\'s a social, active park where small dogs thrive alongside the community.',
leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage for the designated area.'
},
{
name: 'Arch Hill Reserve',
description:
'A smaller hilltop reserve on the Grey Lynn / Arch Hill border with native planting, good views, and a quieter atmosphere than the main park. Good for dogs who prefer a less busy setting.',
leashNote: 'On-leash area.'
},
{
name: 'Cox\'s Bay Reserve',
description:
'A short walk from Grey Lynn, Cox\'s Bay Reserve sits on the Waitemata Harbour foreshore in Herne Bay. Flat, scenic, and popular — an excellent route extension for dogs who enjoy walking near the water.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Sandringham',
slug: 'sandringham',
intro:
'Sandringham is a well-connected central suburb with good access to several parks used by our Tiny Gang. Its quiet residential streets are easy to navigate for pickups, and its proximity to Mt Eden and Morningside means dogs based here have a range of walking routes available. Goodwalk covers Sandringham for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Potters Park',
description:
'On the Sandringham / Mt Eden border, Potters Park is a flat, open space with grassy areas and mature trees — a reliable neighbourhood park and a regular stop on our routes.',
leashNote: 'On-leash area.'
},
{
name: 'Fowlds Park',
description:
'Easily accessible from Sandringham, Fowlds Park is one of the central Auckland area\'s largest parks — open, spacious, and consistently popular with local dog walkers.',
leashNote: 'On-leash area.'
},
{
name: 'Woodside Reserve',
description:
'A quieter local reserve within Sandringham, Woodside is good for calmer walks and dogs who do better with fewer distractions — a solid neighbourhood option between the larger parks.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Mt Albert',
slug: 'mt-albert',
intro:
'Mt Albert combines a relaxed suburban feel with excellent access to parks and green corridors that make it one of the better areas for dog walking in Auckland Central. The Oakley Creek walkway alone is a standout route for small dogs. Goodwalk serves Mt Albert for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Oakley Creek / Te Auaunga Walkway',
description:
'A beautiful linear walkway following Oakley Creek through native bush restoration plantings and open stream-side paths. One of Auckland Central\'s most scenic dog walking routes — calm, well-maintained, and a genuine highlight for dogs and owners alike.',
leashNote: 'On-leash in most sections to protect local wildlife.'
},
{
name: 'Mt Albert Domain',
description:
'A well-maintained local domain with open lawns, sports fields, and walking paths. Reliable, well-used, and a regular part of our Mt Albert routes.',
leashNote: 'On-leash area.'
},
{
name: 'Phyllis Reserve',
description:
'A popular neighbourhood reserve with open grass and easy walking paths. Regularly used by local dog owners and a good stop for dogs who enjoy a steadier, more predictable walk.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Herne Bay',
slug: 'herne-bay',
intro:
'Herne Bay is one of Auckland\'s most picturesque suburbs, with waterfront reserves, harbour views, and a calm residential feel that suits small dogs perfectly. It\'s a natural fit for our Tiny Gang, and we regularly pick up from the area. Goodwalk covers Herne Bay for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Cox\'s Bay Reserve',
description:
'A much-loved waterfront reserve on the Waitemata Harbour with flat grassy areas, harbour views, and a relaxed atmosphere. One of Auckland\'s best spots for a morning walk with a small dog.',
leashNote: 'On-leash area.'
},
{
name: 'Herne Bay Foreshore',
description:
'The foreshore walkway along the Herne Bay waterfront provides easy, flat walking with sea breezes and good views across the harbour. Popular with dog owners at any time of day.',
leashNote: 'On-leash area.'
},
{
name: 'Western Park',
description:
'A short walk into Ponsonby, Western Park\'s terraced paths and native planting offer a quiet contrast to the open waterfront — a good second option for dogs who enjoy varied terrain.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Morningside',
slug: 'morningside',
intro:
'Morningside is home to some of the best park access in the central Auckland area — Fowlds Park and Western Springs are both on the doorstep. It\'s a regular part of our Tiny Gang routes, and pickup logistics are straightforward. Goodwalk serves Morningside for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Fowlds Park',
description:
'Morningside\'s standout park — large, open, and well-maintained with generous grass areas and plenty of room for a proper group outing. It\'s one of our most-used locations for Tiny Gang walks.',
leashNote: 'On-leash area throughout most of the park.'
},
{
name: 'Western Springs Park',
description:
'Surrounding a historic lake and well-established trees, Western Springs provides a calmer, more shaded alternative to Fowlds — ideal for dogs who prefer a quieter environment or warmer days.',
leashNote: 'On-leash. Dogs are not permitted in the zoo precinct.'
},
{
name: 'Chamberlain Park',
description:
'Adjacent to the golf course and easily reached from Morningside, Chamberlain Park offers flat, open walking in a less busy setting than the suburb\'s larger parks.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Freemans Bay',
slug: 'freemans-bay',
intro:
'Freemans Bay sits just below Ponsonby and within easy reach of Victoria Park and the waterfront. Its compact streets and central location make it one of our quickest pickup stops. Goodwalk serves Freemans Bay for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Victoria Park',
description:
'One of Auckland\'s most central and well-used parks, Victoria Park is right on Freemans Bay\'s doorstep — wide open lawns, mature trees, and a consistently good environment for small dogs on a group walk.',
leashNote: 'On-leash area.'
},
{
name: 'Westhaven Promenade',
description:
'The Westhaven foreshore walkway provides flat, scenic walking alongside the marina with harbour views and fresh sea air. A favourite for dogs who enjoy a coastal route.',
leashNote: 'On-leash area.'
},
{
name: 'Western Park',
description:
'Easily accessible from Freemans Bay, Western Park\'s shaded hillside paths offer a more enclosed and quieter alternative to the open parks nearby.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Pt Chevalier',
slug: 'pt-chevalier',
intro:
'Pt Chevalier has a relaxed, community-oriented feel and excellent park access — including beach reserves, wetland walkways, and open fields. It\'s a genuinely good suburb for dog walking and a regular part of our routes. Goodwalk serves Pt Chevalier for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Pt Chevalier Beach Reserve',
description:
'A popular local beach reserve with grassy areas, pohutukawa trees, and a relaxed foreshore setting. Dogs enjoy the sea air and the open space, and it\'s never too far from the water\'s edge.',
leashNote: 'On-leash area. Check Auckland Council signage for beach access rules by season.'
},
{
name: 'Meola Creek / Meola Reef',
description:
'A peaceful wetland walkway following Meola Creek out to the Meola Reef reserve on the foreshore. Excellent native bird habitat and a calm, scenic route that small dogs tend to enjoy.',
leashNote: 'On-leash to protect the wetland wildlife.'
},
{
name: 'Seddon Fields',
description:
'Large open sports fields with plenty of space for a good walk. Less structured than the beach reserve but useful for dogs who need more open room to stretch their legs.',
leashNote: 'On-leash area outside designated off-leash zones.'
}
]
},
{
suburb: 'Avondale',
slug: 'avondale',
intro:
'Avondale offers solid access to green space and the Oakley Creek walkway, one of the better dog walking routes in West Auckland. It sits on the western edge of our service area and is well-suited to dogs who enjoy more varied terrain. Goodwalk covers Avondale for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Oakley Creek / Te Auaunga Walkway',
description:
'The Oakley Creek walkway stretches through Avondale along a stream-side path lined with native plantings. One of the most enjoyable walking routes in the area — calm, green, and well away from traffic.',
leashNote: 'On-leash in most sections.'
},
{
name: 'Avondale Domain',
description:
'A local domain with open grass and easy paths — a reliable neighbourhood option for a straightforward, unhurried walk.',
leashNote: 'On-leash area.'
},
{
name: 'Hendon Park',
description:
'A quieter local reserve in Avondale with grassy open areas and a low-key atmosphere. Good for dogs who prefer a smaller, less busy setting.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Three Kings',
slug: 'three-kings',
intro:
'Three Kings is a quieter residential suburb with some genuinely interesting walking terrain — most notably the old quarry reserve that gives the suburb its character. It\'s well-positioned for pickup and sits within easy reach of Monte Cecilia Park. Goodwalk serves Three Kings for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Three Kings Reserve',
description:
'Formed from a former volcanic quarry, Three Kings Reserve is a dramatic and unusual park with steep rocky outcrops, native planting, and elevated views. An interesting change of scenery for dogs accustomed to flat suburban parks.',
leashNote: 'On-leash area.'
},
{
name: 'Monte Cecilia Park',
description:
'A large, well-landscaped park bordering Three Kings and Hillsborough with open lawns, mature trees, and walking paths. Popular with local dog walkers and a consistent favourite on our southern routes.',
leashNote: 'On-leash area.'
},
{
name: 'Winstone Park',
description:
'A local neighbourhood reserve with open grass and a calm environment — reliable for shorter walks and a regular stop for dogs in the Three Kings area.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Hillsborough',
slug: 'hillsborough',
intro:
'Hillsborough sits on the southern edge of our service area with access to Monte Cecilia Park and a network of quieter residential streets. It\'s a relaxed, lower-density suburb well-suited to dogs who do better on calmer, less congested walks. Goodwalk serves Hillsborough for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Monte Cecilia Park',
description:
'One of the southern central suburbs\' best parks — large open lawns, historic homestead grounds, and well-maintained walking paths through mature trees. A consistently good spot for a group outing.',
leashNote: 'On-leash area.'
},
{
name: 'Richardson Domain',
description:
'A large sports and recreation reserve with plenty of open space, wide paths, and a relaxed atmosphere. Good for dogs who need room to move without a lot of competing activity around them.',
leashNote: 'On-leash area in most sections.'
},
{
name: 'Hillsborough Reserve',
description:
'A smaller neighbourhood reserve within Hillsborough — useful for shorter walks and a reliable local option between the area\'s larger parks.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Eden Terrace',
slug: 'eden-terrace',
intro:
'Eden Terrace is a compact, centrally-located suburb with quick access to Myers Park and the Auckland Domain. Its urban density makes it an efficient pickup point, and dogs from Eden Terrace often join Tiny Gang outings to nearby Mt Eden and Grey Lynn parks. Goodwalk serves Eden Terrace for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Myers Park',
description:
'An urban park in the heart of the city with terraced gardens, shaded paths, and a pedestrian-friendly layout. A good option for a quick midday walk for dogs based in Eden Terrace.',
leashNote: 'On-leash area.'
},
{
name: 'Auckland Domain',
description:
'Auckland\'s oldest park, the Domain offers expansive lawns, tree-lined paths, and some of the city\'s best open green space. Just a short drive from Eden Terrace and a regular destination for our longer Tiny Gang outings.',
leashNote: 'On-leash area. Dogs are not permitted in the formal garden sections.'
},
{
name: 'Basque Park',
description:
'A small pocket park near the Newton Gully with quiet paths and native planting — useful as a local walking option for dogs in the immediate area.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Balmoral',
slug: 'balmoral',
intro:
'Balmoral is a well-established residential suburb with easy access to several parks used by our Tiny Gang. Its quiet streets and proximity to Mt Eden and Sandringham make it a natural part of our regular routes. Goodwalk serves Balmoral for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Balmoral Reserve',
description:
'A well-used neighbourhood reserve with open grass, walking paths, and a relaxed local atmosphere. A reliable everyday option for dogs based in Balmoral.',
leashNote: 'On-leash area.'
},
{
name: 'Potters Park',
description:
'On the Balmoral / Sandringham / Mt Eden boundary, Potters Park is a flat, open park and a regular stop on our central Auckland routes.',
leashNote: 'On-leash area.'
},
{
name: 'Cornwall Park',
description:
'Easily accessible from Balmoral, Cornwall Park\'s sweeping open lawns and wide paths make it one of Auckland\'s best walking parks — particularly suited to longer outings with a small group.',
leashNote: 'On-leash. Dogs are not permitted in fenced farm animal areas.'
}
]
},
{
suburb: 'Arch Hill',
slug: 'arch-hill',
intro:
'Arch Hill sits between Grey Lynn and Kingsland with good access to both Grey Lynn Park and the surrounding reserves. It\'s a compact suburb with a strong dog-owning community and a regular part of our pickup circuit. Goodwalk serves Arch Hill for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Arch Hill Reserve',
description:
'A hilltop reserve with native planting and elevated views — quieter than the main parks nearby and a good option for dogs who prefer a less busy environment.',
leashNote: 'On-leash area.'
},
{
name: 'Grey Lynn Park',
description:
'A short walk from Arch Hill, Grey Lynn Park is one of Auckland Central\'s most popular dog walking destinations with open lawns, wide paths, and a lively community feel.',
leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage.'
},
{
name: 'Fowlds Park',
description:
'Easily reached from Arch Hill via Kingsland, Fowlds Park provides generous open space and a reliable walking environment for dogs who need more room to move.',
leashNote: 'On-leash area.'
}
]
},
{
suburb: 'Mt Roskill',
slug: 'mt-roskill',
intro:
'Mt Roskill sits on the southern edge of our service area with access to Monte Cecilia Park and the Richardson Domain — two of the larger parks in the south-central Auckland belt. It\'s a well-connected suburb and a regular part of our extended routes. Goodwalk serves Mt Roskill for pack walks, 1:1 walks, and puppy visits.',
parks: [
{
name: 'Monte Cecilia Park',
description:
'One of the area\'s best parks — large, well-kept grounds with open lawns, historic gardens, and good walking paths under mature trees. A standout destination for group walks in the southern central suburbs.',
leashNote: 'On-leash area.'
},
{
name: 'Richardson Domain',
description:
'A large recreation reserve with wide open fields and walking paths. Excellent for dogs who need space and a good stretch without a lot of congestion.',
leashNote: 'On-leash area in most sections.'
},
{
name: 'Keith Hay Park',
description:
'A large sports and community park in Mt Roskill with open grass areas and a calm neighbourhood environment — a reliable local option for dogs in the immediate area.',
leashNote: 'On-leash area.'
}
]
}
];
export const locationsBySlug = Object.fromEntries(
locationPages.map((loc) => [loc.slug, loc])
);
+8 -3
View File
@@ -1,15 +1,20 @@
import type { PricingPageContent } from '$lib/types';
import { sharedServices } from '$lib/content/services';
import { dogWalkingContent } from './dog-walking';
import { packWalksContent } from './pack-walks';
import { puppyVisitsContent } from './puppy-visits';
const packWalksService = sharedServices.find((service) => service.title === 'Pack Walks');
const oneToOneService = sharedServices.find((service) => service.title === '1:1 Walks');
const puppyVisitsService = sharedServices.find((service) => service.title === 'Puppy Visits');
export const ourPricingContent: PricingPageContent = {
title: 'Our Pricing',
subtitle: 'Choose the Goodwalk routine that fits your dog, your week, and the kind of support you need.',
sections: [
{
title: 'Pack Walks',
icon: 'fas fa-paw',
icon: packWalksService?.icon ?? 'fas fa-paw',
blurb:
'Our specialty for sociable small and medium dogs who thrive with calm structure, regular weekly outings, and the right dog company.',
detailCta: {
@@ -21,7 +26,7 @@ export const ourPricingContent: PricingPageContent = {
},
{
title: '1:1 Walks',
icon: 'fas fa-person-walking',
icon: oneToOneService?.icon ?? 'fas fa-person-walking',
blurb:
'A more individual option for dogs who need extra attention, more space, or a walk shaped around their own pace and confidence.',
detailCta: {
@@ -33,7 +38,7 @@ export const ourPricingContent: PricingPageContent = {
},
{
title: 'Puppy Visits',
icon: 'fas fa-dog',
icon: puppyVisitsService?.icon ?? 'fas fa-dog',
blurb:
'Home visits for young puppies who need company, toilet breaks, routine support, and a calmer start before they are ready for bigger adventures.',
detailCta: {
+2 -1
View File
@@ -7,7 +7,8 @@ export const packWalksContent: ServicePageContent = {
paragraphs: [
'Goodwalk Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday.',
'Our Tiny Gang packs stay small, calm, and carefully matched, so sociable dogs can build confidence, enjoy safe group outings, and come home settled instead of overstimulated.',
'Tiny Gang is best suited to sociable small and medium dogs who enjoy being around other dogs. If your dog would be better with a quieter, more individual setup, our 1:1 walks may be a better fit.'
'Tiny Gang is best suited to sociable small and medium dogs who enjoy being around other dogs. If your dog would be better with a quieter, more individual setup, our 1:1 walks may be a better fit.',
'We run pack walks across Auckland Central — including Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, and surrounding suburbs — with free pickup and drop-off included in every booking.'
],
imageUrl: '/images/auckland-pack-walk-small-dogs-group.png',
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park"
+2 -1
View File
@@ -7,7 +7,8 @@ export const puppyVisitsContent: ServicePageContent = {
paragraphs: [
'Goodwalk Puppy Visits are designed for busy owners who want their puppy cared for properly during the day, with toilet breaks, play, feeding, and calm one-on-one attention at home.',
'They are also the first stage of the Goodwalk journey. For puppies who may later join our Pack Walks, these visits help build familiarity, confidence, and the early routines that make that transition much smoother.',
'Instead of just getting through the day, your puppy gets a more thoughtful start, and you get more peace of mind while you are away.'
'Instead of just getting through the day, your puppy gets a more thoughtful start, and you get more peace of mind while you are away.',
'We offer puppy visits across Auckland Central including Mt Eden, Ponsonby, Grey Lynn, Kingsland, Sandringham, Herne Bay, and surrounding suburbs.'
],
imageUrl: '/images/auckland-puppy-home-visit.jpg',
imageAlt: 'Puppy receiving a calm Goodwalk home visit in Auckland'
+39
View File
@@ -0,0 +1,39 @@
export interface SharedServiceDefinition {
title: 'Pack Walks' | '1:1 Walks' | 'Puppy Visits';
href: string;
icon: string;
megaMenuDescription: string;
cardBody: string;
locationDescription: string;
priceFrom: string;
}
export const sharedServices: SharedServiceDefinition[] = [
{
title: 'Pack Walks',
href: '/pack-walks',
icon: 'fas fa-paw',
megaMenuDescription: 'Tiny Gang outdoor adventures',
cardBody: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.',
locationDescription: 'Small group outings for sociable dogs. From $49.50.',
priceFrom: 'From $49.50 / walk'
},
{
title: '1:1 Walks',
href: '/dog-walking',
icon: 'fas fa-person-walking',
megaMenuDescription: 'Personalised solo walks',
cardBody: "One-on-one walks tailored to your dog's individual pace, personality, and needs.",
locationDescription: 'One dog, full attention, tailored pace.',
priceFrom: 'From $45 / walk'
},
{
title: 'Puppy Visits',
href: '/puppy-visits',
icon: 'fas fa-dog',
megaMenuDescription: 'Home visits for young pups',
cardBody: 'In-home visits to check in on your puppy, play, and keep them company.',
locationDescription: 'In-home care for puppies during the day.',
priceFrom: 'From $39 / visit'
}
];
+13 -12
View File
@@ -1,37 +1,38 @@
export const staticPages = {
'pack-walks': {
title: 'Pack Walks for Small & Medium Dogs | Auckland Central',
title: 'Pack Walks for Small Dogs | Mt Eden, Kingsland & Auckland Central | Goodwalk',
description:
'Pack walks for sociable small and medium dogs in Auckland Central. Calm group outings, regular weekly routines, and free Meet & Greet with Goodwalk.',
'Tiny Gang pack walks for small and medium dogs across Mt Eden, Kingsland, Ponsonby, Grey Lynn and Auckland Central. Small groups, calm outings, free pickup and drop-off.',
canonicalPath: '/pack-walks'
},
'dog-walking': {
title: '1:1 Dog Walks for Dogs Who Need More Attention | Auckland Central',
title: '1:1 Dog Walks Auckland | Mt Eden, Ponsonby & Kingsland | Goodwalk',
description:
'One-on-one dog walks in Auckland Central for dogs who need more attention, more space, or a calmer routine. Free Meet & Greet with Goodwalk.',
'One-on-one dog walks across Auckland Central — Mt Eden, Kingsland, Ponsonby, Grey Lynn and more. For dogs who need more space, attention, and a calmer routine.',
canonicalPath: '/dog-walking'
},
'puppy-visits': {
title: 'Puppy Visits & In-Home Puppy Care | Auckland Central',
title: 'Puppy Visits Auckland Central | Mt Eden, Ponsonby & Grey Lynn | Goodwalk',
description:
'In-home puppy visits across Auckland Central with toilet breaks, feeding, play, and early routine support. A calm start before future pack walks.',
'In-home puppy visits across Mt Eden, Ponsonby, Grey Lynn, Kingsland and Auckland Central. Toilet breaks, feeding, play, and calm one-on-one care while you are out.',
canonicalPath: '/puppy-visits'
},
'our-pricing': {
title: 'Our Pricing',
title: 'Dog Walking Prices Auckland | Pack Walks & 1:1 Walks | Goodwalk',
description:
'Learn more about the pricing for Goodwalk. Prices for our Tiny Gang pack walks and 1 on 1 solo walks.',
'Transparent pricing for Goodwalk pack walks, 1:1 dog walks, and puppy visits across Auckland Central. From $49.50 per walk. Free Meet & Greet included.',
canonicalPath: '/our-pricing'
},
about: {
title: 'About Us | Dog Walkers',
title: 'About Goodwalk | Dog Walkers in Mt Eden, Kingsland & Auckland Central',
description:
'Learn more about Kingsland based dog walking company Goodwalk. We offer our Tiny Gang pack walks throughout the Auckland region. Solo (1:1 walks), homestays and more.',
'Meet Alessandra, founder of Goodwalk — Auckland Central\'s small dog walking specialist. Serving Mt Eden, Kingsland, Ponsonby, Grey Lynn and surrounding suburbs with 30+ five-star reviews.',
canonicalPath: '/about'
},
'contact-us': {
title: 'Contact Us',
description: 'Book a Meet & Greet or send a general enquiry to Goodwalk Auckland dog walking services.',
title: 'Book a Dog Walker in Auckland | Contact Goodwalk',
description:
'Book a free Meet & Greet or send an enquiry to Goodwalk. Auckland Central dog walking specialists serving Mt Eden, Kingsland, Ponsonby, Grey Lynn and surrounding suburbs.',
canonicalPath: '/contact-us'
},
'terms-and-conditions': {
+17
View File
@@ -0,0 +1,17 @@
import type { Picture } from '@sveltejs/enhanced-img';
// In dev, imagetools can be painfully slow for large local assets.
// Fall back to the already-served static files and keep enhanced variants for production builds.
const modules: Record<string, { default: Picture }> = import.meta.env.DEV
? {}
: (import.meta.glob('./images/**/*.{jpg,jpeg,png,webp,avif,gif}', {
eager: true,
query: { enhanced: true }
}) as Record<string, { default: Picture }>);
export function getEnhancedImage(src: string | undefined | null): Picture | null {
if (!src) return null;
// '/images/foo.png' -> './images/foo.png' (relative to src/lib/)
const key = '.' + src;
return modules[key]?.default ?? null;
}
+1 -1
View File
@@ -25,7 +25,7 @@ const imageMetadata: Record<string, ImageMetadata> = {
'/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg': { width: 3327, height: 2217 },
'/images/auckland-pack-walk-dog.jpg': { width: 480, height: 640 },
'/images/auckland-dog-group-outing.jpg': { width: 640, height: 480 },
'/images/goodwalk-dog-walker-alessandra.png': { width: 640, height: 640 }
'/images/founder-image-aless-goodwalk.png': { width: 1076, height: 1461 }
};
export function getImageMetadata(src: string | undefined | null): ImageMetadata | null {
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 262">
<path fill="#4285F4" d="M255.68 133.53c0-8.89-.8-17.43-2.29-25.63H130.8v48.48h70.06c-3.02 16.31-12.2 30.12-25.99 39.35v32.65h41.95c24.54-22.6 38.86-55.92 38.86-94.85Z"/>
<path fill="#34A853" d="M130.8 261.1c35.1 0 64.53-11.63 86.04-31.55l-41.95-32.65c-11.63 7.79-26.53 12.38-44.09 12.38-33.88 0-62.58-22.88-72.83-53.62H14.6v33.67c21.39 42.42 65.29 71.77 116.2 71.77Z"/>
<path fill="#FBBC05" d="M57.97 155.65c-2.61-7.79-4.09-16.11-4.09-24.65s1.48-16.86 4.09-24.65V72.68H14.6C5.28 91.24 0 110.62 0 131s5.28 39.76 14.6 58.32l43.37-33.67Z"/>
<path fill="#EA4335" d="M130.8 52.72c19.08 0 36.23 6.57 49.72 19.48l37.29-37.29C195.28 13.35 165.87 0 130.8 0 79.89 0 35.99 29.35 14.6 72.68l43.37 33.67c10.25-30.74 38.95-53.63 72.83-53.63Z"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { locationPages } from '$lib/content/locations';
import { buildAreaServed, buildBreadcrumb, buildLocationSeo } from './seo';
import type { LocationPageContent } from '$lib/types';
describe('seo helpers', () => {
it('derives area served from location content', () => {
const areaServed = buildAreaServed();
expect(areaServed).toHaveLength(locationPages.length);
expect(areaServed[0]).toEqual({
'@type': 'Place',
name: `${locationPages[0].suburb}, Auckland, New Zealand`
});
});
it('builds location seo from data with sensible defaults', () => {
const seo = buildLocationSeo(locationPages[0]);
expect(seo.title).toBe('Dog Walkers in Mt Eden | Goodwalk Auckland');
expect(seo.canonicalPath).toBe('/locations/mt-eden');
expect(seo.image).toBe('/images/auckland-pack-walk-small-dogs-group.png');
expect(seo.imageAlt).toBe('Goodwalk dog walkers in Mt Eden, Auckland');
expect(seo.structuredData).toHaveLength(2);
});
it('builds breadcrumbs from real paths', () => {
const breadcrumb = buildBreadcrumb([
{ name: 'Home', url: 'https://www.goodwalk.co.nz' },
{ name: 'Mt Eden', path: '/locations/mt-eden' }
]);
expect(breadcrumb.itemListElement).toEqual([
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: 'https://www.goodwalk.co.nz'
},
{
'@type': 'ListItem',
position: 2,
name: 'Mt Eden',
item: 'https://www.goodwalk.co.nz/locations/mt-eden'
}
]);
});
it('uses the first park image for seo when no explicit seo image is set', () => {
const location: LocationPageContent = {
suburb: 'Test Suburb',
slug: 'test-suburb',
intro: 'Test intro',
parks: [
{
name: 'Test Park',
description: 'A local park for testing.',
image: {
src: '/images/test-park-photo.webp',
alt: 'Dogs walking through Test Park'
}
}
]
};
const seo = buildLocationSeo(location);
expect(seo.image).toBe('/images/test-park-photo.webp');
expect(seo.imageAlt).toBe('Dogs walking through Test Park');
});
});
+104
View File
@@ -0,0 +1,104 @@
import { locationPages } from '$lib/content/locations';
import type { LocationPageContent } from '$lib/types';
const siteUrl = 'https://www.goodwalk.co.nz';
const defaultLocationImage = '/images/auckland-pack-walk-small-dogs-group.png';
const defaultLocationImageAlt = 'Goodwalk Auckland dog walking services';
interface BreadcrumbItem {
name: string;
path?: string;
url?: string;
}
export function absoluteUrl(value: string) {
if (!value) {
return siteUrl;
}
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
}
export function buildBreadcrumb(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url ?? absoluteUrl(item.path ?? '/')
}))
};
}
export function buildAreaServed(locations = locationPages) {
return locations.map((location) => ({
'@type': 'Place',
name: `${location.suburb}, Auckland, New Zealand`
}));
}
function getLocationSeoImage(location: LocationPageContent) {
return location.seo?.image ?? location.parks.find((park) => park.image?.src)?.image?.src ?? defaultLocationImage;
}
function getLocationSeoImageAlt(location: LocationPageContent) {
return (
location.seo?.imageAlt ??
location.parks.find((park) => park.image?.alt)?.image?.alt ??
`Goodwalk dog walkers in ${location.suburb}, Auckland`
);
}
export function buildLocationSeo(location: LocationPageContent) {
const canonicalPath = `/locations/${location.slug}`;
const title =
location.seo?.title ?? `Dog Walkers in ${location.suburb} | Goodwalk Auckland`;
const description =
location.seo?.description ??
`Goodwalk provides pack walks, 1:1 walks, and puppy visits in ${location.suburb}, Auckland Central. Small dog specialists with free pickup and drop-off. Book a free Meet & Greet.`;
const image = getLocationSeoImage(location);
const imageAlt = getLocationSeoImageAlt(location);
const breadcrumbLabel = location.seo?.breadcrumbLabel ?? location.suburb;
const serviceName = location.seo?.serviceName ?? `Goodwalk Dog Walking - ${location.suburb}`;
const serviceType = location.seo?.serviceType ?? 'Dog walking and puppy visits';
return {
title,
description,
canonicalPath,
image,
imageAlt,
structuredData: [
{
'@context': 'https://schema.org',
'@type': 'Service',
name: serviceName,
description,
serviceType,
provider: {
'@type': 'LocalBusiness',
name: 'Goodwalk',
url: siteUrl,
telephone: '+64226421011',
email: 'info@goodwalk.co.nz'
},
areaServed: {
'@type': 'Place',
name: `${location.suburb}, Auckland, New Zealand`
},
image: absoluteUrl(image),
url: absoluteUrl(canonicalPath)
},
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: breadcrumbLabel, path: canonicalPath }
])
] as Record<string, unknown>[]
};
}
+12 -1
View File
@@ -4,8 +4,11 @@
justify-content: center;
gap: 10px;
padding: 13px 28px;
font-family: var(--font-head);
font-size: 14px;
font-weight: 600;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
border-radius: 40px;
cursor: pointer;
transition:
@@ -71,3 +74,11 @@
background: var(--gw-green);
color: #fff;
}
@media (max-width: 768px) {
.btn-mobile-center {
display: flex;
width: fit-content;
margin-inline: auto;
}
}
+19 -5
View File
@@ -105,6 +105,12 @@
}
}
@media (max-width: 1024px) {
.booking-title {
font-size: clamp(36px, 5vw, 52px);
}
}
@media (prefers-reduced-motion: reduce) {
.booking-title-highlight::after {
animation: none;
@@ -189,19 +195,23 @@
.booking-panel {
display: flex;
flex-direction: column;
gap: 18px;
gap: 0;
}
.booking-panel-banner {
background: linear-gradient(180deg, #f6f2ea 0%, #f1ece3 100%);
color: #34363a;
border-radius: 30px;
padding: 24px 28px 34px;
border-radius: 28px 28px 0 0;
padding: 22px 28px 28px;
text-align: center;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.55;
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
border: 1px solid rgba(17, 20, 24, 0.06);
border-bottom: none;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.18),
0 8px 22px rgba(17, 20, 24, 0.035);
}
.booking-card-grid {
@@ -210,7 +220,11 @@
}
.booking-card-grid-with-banner {
margin-top: -18px;
margin-top: 0;
}
.booking-card-grid-with-banner .booking-field-card {
border-radius: 0 0 28px 28px;
}
.booking-card-grid-owner {
+5 -4
View File
@@ -353,7 +353,6 @@ nav {
}
.services-grid,
.values-grid,
.testimonials-grid {
margin-top: 48px;
}
@@ -366,8 +365,10 @@ nav {
.values-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
grid-template-columns: repeat(2, 1fr);
gap: 0;
margin-top: 48px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.testimonials-grid {
@@ -385,7 +386,7 @@ nav {
.footer-inner {
display: grid;
grid-template-columns: 1.25fr 0.95fr 1.15fr;
grid-template-columns: 1.1fr 0.8fr 0.8fr 1fr;
gap: 24px;
margin-bottom: 48px;
align-items: start;
+32 -10
View File
@@ -28,10 +28,6 @@
.hero-text h1 {
font-size: 40px;
}
.values-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
@@ -108,8 +104,11 @@
padding: 11px 14px;
background: rgba(33, 48, 33, 0.1);
color: var(--gw-green);
font-family: var(--font-head);
font-size: 13px;
font-weight: 600;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
}
.mobile-phone .icon {
@@ -191,6 +190,14 @@
animation-delay: 210ms;
}
.mobile-menu.open a:nth-child(8) {
animation-delay: 240ms;
}
.mobile-menu.open a:nth-child(9) {
animation-delay: 270ms;
}
#hero {
min-height: auto;
padding: 50px 20px 0;
@@ -221,7 +228,7 @@
.hero-subtitle {
margin: -10px 0 22px;
max-width: none;
font-size: 15.5px;
font-size: 16px;
line-height: 1.5;
}
@@ -239,7 +246,7 @@
display: block;
color: #fff;
font-family: var(--font-head);
font-size: 33.5px;
font-size: 36px;
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.04em;
@@ -258,7 +265,7 @@
width: 0;
min-width: 0;
padding: 14px 10px;
font-size: 12.5px;
font-size: 13px;
font-weight: 700;
text-align: center;
border-radius: 999px;
@@ -376,7 +383,6 @@
}
.services-grid,
.values-grid,
.testimonials-grid,
.info-inner,
.field-group,
@@ -384,6 +390,18 @@
grid-template-columns: 1fr;
}
.values-grid {
grid-template-columns: 1fr;
}
.value-card:nth-child(odd) {
border-right: none;
}
.value-card {
padding: 24px 0;
}
.service-icon-bubble {
width: 78px;
height: 78px;
@@ -489,6 +507,10 @@
border-radius: 24px;
}
.booking-card-grid-with-banner .booking-field-card {
border-radius: 0 0 24px 24px;
}
.booking-field-card-group {
padding: 24px 22px;
}
@@ -645,7 +667,7 @@
}
.hero-text h1 .hero-heading-mobile {
font-size: 30px;
font-size: 31px;
line-height: 1.12;
}
+121 -22
View File
@@ -1,4 +1,9 @@
section {
#promise,
#services,
#values,
#testimonials,
#info,
#newlead {
padding: 80px 0;
}
@@ -43,8 +48,11 @@ section {
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;
text-decoration: none;
transition:
background 0.2s ease,
@@ -95,6 +103,12 @@ section {
margin: -12px -18px -72px 0;
}
@media (max-width: 1100px) {
.hero-img img {
margin-bottom: -32px;
}
}
#intro {
background: #fff;
padding: 8px 50px 26px;
@@ -219,6 +233,11 @@ section {
font-size: 20px;
}
.intro-trust-cta:focus-visible {
outline: 2px solid rgba(10, 48, 78, 0.28);
outline-offset: 3px;
}
@keyframes introGoogleShine {
0%,
64%,
@@ -420,38 +439,57 @@ footer {
}
.value-card {
background: rgba(255, 255, 255, 0.07);
border-radius: 28px;
padding: 32px 28px;
border: 1px solid rgba(255, 255, 255, 0.12);
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
border-color 0.22s ease;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 22px;
padding: 36px 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.2s ease;
}
.value-card:nth-child(odd) {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
@media (hover: hover) {
.value-card:hover {
transform: translateY(-6px) scale(1.012);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.14);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
}
}
.value-card:active {
transform: translateY(-1px) scale(0.994);
.value-icon-wrap {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
margin-top: 2px;
}
.value-card .value-card-icon {
font-size: 28px;
font-size: 22px;
color: var(--yellow);
margin-bottom: 16px;
}
.value-text h3 {
margin: 0 0 8px;
font-family: var(--font-head);
font-size: 17px;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.value-card p {
margin: 0;
font-size: 14px;
line-height: 1.7;
opacity: 0.85;
opacity: 0.82;
}
.testimonial-card {
@@ -501,36 +539,78 @@ footer {
}
.faq details {
border-bottom: 1px solid #ddd;
padding: 16px 0;
border-radius: 16px;
border: 1px solid rgba(33, 48, 33, 0.1);
background: #fff;
padding: 0;
margin-bottom: 10px;
transition: box-shadow 0.2s ease;
}
.faq details[open] {
box-shadow: 0 8px 24px rgba(17, 20, 24, 0.06);
}
.faq summary {
font-weight: 600;
font-size: 16px;
cursor: pointer;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 22px;
border-radius: 16px;
transition: background 0.18s ease;
}
.faq summary::-webkit-details-marker {
display: none;
}
.faq summary:focus-visible {
outline: 2px solid rgba(10, 48, 78, 0.28);
outline-offset: -2px;
}
.faq summary:hover {
background: rgba(33, 48, 33, 0.03);
}
.faq details[open] summary {
border-bottom: 1px solid rgba(33, 48, 33, 0.08);
border-radius: 16px 16px 0 0;
}
.faq summary::after {
content: '+';
font-size: 20px;
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(33, 48, 33, 0.06);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 400;
color: var(--gw-green);
transition: background 0.18s ease, transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.faq details[open] summary::after {
content: '';
background: rgba(33, 48, 33, 0.1);
transform: rotate(180deg);
}
.faq details p {
margin-top: 10px;
margin: 0;
padding: 16px 22px 20px;
color: var(--gray);
font-size: 15px;
line-height: 1.65;
}
#instagram {
@@ -552,7 +632,7 @@ footer {
.footer-logo {
display: block;
height: 24px;
height: 30px;
width: auto;
margin-bottom: 18px;
}
@@ -625,6 +705,10 @@ footer {
gap: 2px 24px;
}
.footer-explore .footer-nav {
grid-template-columns: 1fr;
}
.footer-nav li {
margin: 0;
min-width: 0;
@@ -669,8 +753,11 @@ footer {
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-weight: 700;
font-size: 15px;
line-height: 1.2;
letter-spacing: 0.01em;
transition: background 0.2s, transform 0.15s;
margin-bottom: 10px;
}
@@ -697,7 +784,11 @@ footer {
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
opacity: 0.8;
transition: background 0.2s, opacity 0.2s;
}
@@ -747,6 +838,7 @@ footer {
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
width: 100%;
gap: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 28px;
@@ -754,10 +846,17 @@ footer {
opacity: 0.6;
}
.footer-bottom > span {
flex: 1 1 auto;
}
.footer-legal {
display: flex;
justify-content: flex-end;
gap: 20px;
flex-wrap: wrap;
flex: 0 0 auto;
text-align: right;
}
.footer-legal a {
+3
View File
@@ -25,6 +25,9 @@
#instagram h2 {
font-family: var(--font-head);
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
}
.section-heading {
+26
View File
@@ -0,0 +1,26 @@
import type { TestimonialContent } from '$lib/types';
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
export function getSeededTestimonialIndex(
testimonials: Array<Pick<TestimonialContent, 'reviewer'>>,
seedKey: string
) {
if (!testimonials.length) {
return 0;
}
if (!seedKey) {
return 0;
}
return hashString(seedKey) % testimonials.length;
}
+34 -2
View File
@@ -172,17 +172,20 @@ export interface PricingPageContent {
export interface AboutPageSection {
title: string;
eyebrow?: string;
body: string[];
imageUrl: string;
imageAlt: string;
reverse?: boolean;
accent?: 'plain' | 'gradient';
accent?: 'plain' | 'gradient' | 'founder';
}
export interface AboutPageContent {
title: string;
sections: AboutPageSection[];
servicesTitle: string;
servicesTitle?: string;
faqTitle?: string;
faqs?: FaqItem[];
contact: {
title: string;
email: string;
@@ -191,6 +194,35 @@ export interface AboutPageContent {
};
}
export interface LocationPark {
name: string;
description: string;
leashNote?: string;
image?: {
src: string;
alt: string;
caption?: string;
};
}
export interface LocationSeoContent {
title?: string;
description?: string;
image?: string;
imageAlt?: string;
serviceName?: string;
serviceType?: string;
breadcrumbLabel?: string;
}
export interface LocationPageContent {
suburb: string;
slug: string;
intro: string;
parks: LocationPark[];
seo?: LocationSeoContent;
}
export interface LegalPageBlock {
type: 'paragraph' | 'list';
content: string | string[];
+17 -1
View File
@@ -1,8 +1,24 @@
import { getHomepageContent } from '$lib/server/content';
import { isHomepageHowItWorksEnabled } from '$lib/server/feature-flags';
export async function load() {
const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']);
export async function load({ url }) {
const hostname = url.hostname.toLowerCase();
const siteVariant =
onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'onboarding'
? 'onboarding'
: 'marketing';
if (siteVariant === 'onboarding') {
return {
siteVariant,
isPreview: url.searchParams.get('preview') === 'onboarding'
};
}
return {
siteVariant,
content: await getHomepageContent(),
howItWorksEnabled: isHomepageHowItWorksEnabled()
};
+129 -119
View File
@@ -11,6 +11,7 @@
import ServicesSection from '$lib/components/ServicesSection.svelte';
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
import ValuesSection from '$lib/components/ValuesSection.svelte';
import OnboardingPage from '$lib/components/OnboardingPage.svelte';
import type { PageData } from './$types';
export let data: PageData;
@@ -25,127 +26,136 @@
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
}
$: homepageStructuredData = [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Goodwalk',
url: siteUrl,
inLanguage: 'en-NZ'
},
{
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Goodwalk',
description:
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
url: siteUrl,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
image: absoluteUrl(data.content.hero.imageUrl),
email: 'info@goodwalk.co.nz',
telephone: '+64-22-642-1011',
sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'],
address: {
'@type': 'PostalAddress',
addressLocality: 'Auckland Central',
addressRegion: 'Auckland',
addressCountry: 'NZ'
},
areaServed: [
'Morningside',
'Kingsland',
'Ponsonby',
'Grey Lynn',
'Mt Albert',
'Mt Eden',
'Sandringham',
'Mt Roskill',
'Arch Hill',
'Freemans Bay',
'Herne Bay',
'Pt Chevalier',
'Avondale',
'Three Kings',
'Hillsborough',
'Eden Terrace',
'Balmoral'
],
openingHoursSpecification: [
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
opens: '08:00',
closes: '16:00'
}
],
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'Dog Walking Services',
itemListElement: data.content.services.map((service) => ({
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: service.title,
url: `${siteUrl}${service.href}`
$: homepageStructuredData =
data.siteVariant === 'marketing' && data.content
? [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Goodwalk',
url: siteUrl,
inLanguage: 'en-NZ'
},
{
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Goodwalk',
description:
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
url: siteUrl,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
image: absoluteUrl(data.content.hero.imageUrl),
email: 'info@goodwalk.co.nz',
telephone: '+64-22-642-1011',
sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'],
address: {
'@type': 'PostalAddress',
addressLocality: 'Auckland Central',
addressRegion: 'Auckland',
addressCountry: 'NZ'
},
areaServed: [
'Morningside',
'Kingsland',
'Ponsonby',
'Grey Lynn',
'Mt Albert',
'Mt Eden',
'Sandringham',
'Mt Roskill',
'Arch Hill',
'Freemans Bay',
'Herne Bay',
'Pt Chevalier',
'Avondale',
'Three Kings',
'Hillsborough',
'Eden Terrace',
'Balmoral'
],
openingHoursSpecification: [
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
opens: '08:00',
closes: '16:00'
}
],
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'Dog Walking Services',
itemListElement: data.content.services.map((service) => ({
'@type': 'Offer',
itemOffered: {
'@type': 'Service',
name: service.title,
url: `${siteUrl}${service.href}`
}
}))
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '5.0',
bestRating: '5',
worstRating: '1',
reviewCount: String(data.content.testimonials.length)
},
review: data.content.testimonials.map((testimonial) => ({
'@context': 'https://schema.org',
'@type': 'Review',
reviewRating: {
'@type': 'Rating',
ratingValue: '5',
bestRating: '5'
},
author: {
'@type': 'Person',
name: testimonial.reviewer
},
reviewBody: testimonial.quote
}))
},
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: data.content.info.faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer
}
}))
}
}))
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '5.0',
bestRating: '5',
worstRating: '1',
reviewCount: String(data.content.testimonials.length)
},
review: data.content.testimonials.map((testimonial) => ({
'@type': 'Review',
reviewRating: {
'@type': 'Rating',
ratingValue: '5',
bestRating: '5'
},
author: {
'@type': 'Person',
name: testimonial.reviewer
},
reviewBody: testimonial.quote
}))
},
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: data.content.info.faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer
}
}))
}
];
]
: [];
</script>
<SeoHead
title={data.content.seo.title}
description={data.content.seo.description}
canonicalPath="/"
image={data.content.hero.imageUrl}
imageAlt={data.content.hero.imageAlt}
structuredData={homepageStructuredData}
preloadImage={true}
/>
{#if data.siteVariant === 'onboarding'}
<OnboardingPage preview={data.isPreview} />
{:else}
{@const content = data.content!}
<SeoHead
title={content.seo.title}
description={content.seo.description}
canonicalPath="/"
image={content.hero.imageUrl}
imageAlt={content.hero.imageAlt}
structuredData={homepageStructuredData}
preloadImage={true}
/>
<Header navigation={data.content.navigation} />
<HeroSection hero={data.content.hero} reviewCta={data.content.intro.reviewCta} />
<PromiseSection promise={data.content.promise} />
<ServicesSection services={data.content.services} />
{#if data.howItWorksEnabled}
<HowItWorksSection content={data.content.howItWorks} />
<Header navigation={content.navigation} />
<HeroSection hero={content.hero} reviewCta={content.intro.reviewCta} />
<PromiseSection promise={content.promise} />
<ServicesSection services={content.services} />
{#if data.howItWorksEnabled}
<HowItWorksSection content={content.howItWorks} />
{/if}
<TestimonialsSection testimonials={content.testimonials} seedKey="/" />
<ValuesSection values={content.values} />
<BookingSection booking={content.booking} />
<InfoSection info={content.info} />
<InstagramSection instagram={content.instagram} />
<Footer footer={content.footer} />
{/if}
<TestimonialsSection testimonials={data.content.testimonials} />
<ValuesSection values={data.content.values} />
<BookingSection booking={data.content.booking} />
<InfoSection info={data.content.info} />
<InstagramSection instagram={data.content.instagram} />
<Footer footer={data.content.footer} />
+41 -38
View File
@@ -14,6 +14,7 @@
import { privacyPolicyContent } from '$lib/content/privacy-policy';
import { puppyVisitsContent } from '$lib/content/puppy-visits';
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
import { buildAreaServed, buildBreadcrumb, absoluteUrl } from '$lib/seo';
import type { PageData } from './$types';
export let data: PageData;
@@ -22,14 +23,6 @@
const defaultSeoImage = '/images/auckland-dog-walking-happy-dog-hero.png';
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
function absoluteUrl(value: string) {
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
}
function aggregateOfferSchema(plans: { price: string }[]) {
const numericPrices = plans
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
@@ -52,26 +45,7 @@
};
}
function breadcrumbSchema(name: string, path: string) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteUrl
},
{
'@type': 'ListItem',
position: 2,
name,
item: `${siteUrl}${path}`
}
]
};
}
const areaServed = buildAreaServed();
let seoImage = defaultSeoImage;
let seoImageAlt = defaultSeoImageAlt;
@@ -90,7 +64,10 @@
description: data.page.description,
url: `${siteUrl}${data.page.canonicalPath}`
},
breadcrumbSchema(data.page.title, data.page.canonicalPath)
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: data.page.title, path: data.page.canonicalPath }
])
];
if (data.slug === 'pack-walks') {
@@ -109,12 +86,15 @@
name: 'Goodwalk',
url: siteUrl
},
areaServed: 'Auckland Central, New Zealand',
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
},
breadcrumbSchema('Pack Walks', data.page.canonicalPath)
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'Pack Walks', path: data.page.canonicalPath }
])
];
} else if (data.slug === 'dog-walking') {
preloadHeroImage = true;
@@ -132,12 +112,15 @@
name: 'Goodwalk',
url: siteUrl
},
areaServed: 'Auckland Central, New Zealand',
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
},
breadcrumbSchema('1:1 Walks', data.page.canonicalPath)
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: '1:1 Walks', path: data.page.canonicalPath }
])
];
} else if (data.slug === 'puppy-visits') {
preloadHeroImage = true;
@@ -155,12 +138,15 @@
name: 'Goodwalk',
url: siteUrl
},
areaServed: 'Auckland Central, New Zealand',
areaServed,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`,
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
},
breadcrumbSchema('Puppy Visits', data.page.canonicalPath)
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'Puppy Visits', path: data.page.canonicalPath }
])
];
} else if (data.slug === 'our-pricing') {
pageStructuredData = [
@@ -171,7 +157,10 @@
description: data.page.description,
url: `${siteUrl}${data.page.canonicalPath}`
},
breadcrumbSchema('Our Pricing', data.page.canonicalPath)
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'Our Pricing', path: data.page.canonicalPath }
])
];
} else if (data.slug === 'about') {
seoImage = aboutPageContent.sections[0].imageUrl;
@@ -185,7 +174,21 @@
url: `${siteUrl}${data.page.canonicalPath}`,
image: absoluteUrl(seoImage)
},
breadcrumbSchema('About Us', data.page.canonicalPath)
...(aboutPageContent.faqs && aboutPageContent.faqs.length
? [{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: aboutPageContent.faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: { '@type': 'Answer', text: faq.answer }
}))
}]
: []),
buildBreadcrumb([
{ name: 'Home', url: siteUrl },
{ name: 'About Us', path: data.page.canonicalPath }
])
];
}
}
@@ -212,7 +215,7 @@
{:else if data.slug === 'our-pricing'}
<PricingPage content={data.content} pageContent={ourPricingContent} />
{:else if data.slug === 'about'}
<AboutPage content={data.content} pageContent={aboutPageContent} />
<AboutPage pageContent={aboutPageContent} />
{:else if data.slug === 'terms-and-conditions'}
<LegalPage pageContent={termsAndConditionsContent} />
{:else if data.slug === 'privacy-policy'}
+20 -11
View File
@@ -1,16 +1,22 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import { aboutPageContent } from '$lib/content/about';
import { dogWalkingContent } from '$lib/content/dog-walking';
import { ourPricingContent } from '$lib/content/our-pricing';
import { packWalksContent } from '$lib/content/pack-walks';
import { puppyVisitsContent } from '$lib/content/puppy-visits';
import { staticPages } from '$lib/content/static-pages';
import SlugPage from './+page.svelte';
import { createStaticRouteData } from '../../test/fixtures';
describe('static slug route page', () => {
it.each([
['pack-walks', 'Join our Tiny Gang!'],
['dog-walking', 'Walks for larger breeds, too!'],
['puppy-visits', 'Introducing Puppy Visits: Building strong foundations for our pack walks!'],
['our-pricing', 'Simple, transparent pricing — no lock-in contracts.'],
['about', 'Who we are'],
['contact-us', "Fill in the form below and we'll be in touch to arrange a free introduction."],
['pack-walks', packWalksContent.hero.title],
['dog-walking', dogWalkingContent.hero.title],
['puppy-visits', puppyVisitsContent.hero.title],
['our-pricing', ourPricingContent.subtitle],
['about', aboutPageContent.sections[0].title],
['contact-us', "Let's meet!"],
['terms-and-conditions', '1. Application of Terms'],
['privacy-policy', 'How we collect your information']
] as const)('renders the %s page branch', (slug, expectedText) => {
@@ -18,6 +24,11 @@ describe('static slug route page', () => {
data: createStaticRouteData(slug)
});
if (slug === 'contact-us') {
expect(screen.getByRole('heading', { name: expectedText })).toBeInTheDocument();
return;
}
expect(screen.getByText(expectedText)).toBeInTheDocument();
});
@@ -26,7 +37,7 @@ describe('static slug route page', () => {
data: createStaticRouteData('about')
});
expect(document.title).toBe('About Us | Dog Walkers | Goodwalk');
expect(document.title).toBe(staticPages.about.title);
expect(document.head.innerHTML).toContain('AboutPage');
});
@@ -37,10 +48,8 @@ describe('static slug route page', () => {
data: createStaticRouteData(slug)
});
expect(
screen.queryByText("Fill in the form below and we'll be in touch to arrange a free introduction.")
).not.toBeInTheDocument();
expect(screen.queryByText('Why people choose us!')).not.toBeInTheDocument();
expect(screen.queryByText("Let's meet!")).not.toBeInTheDocument();
expect(screen.queryByText('What our clients say')).not.toBeInTheDocument();
}
);
+18
View File
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']);
export const load: PageServerLoad = async ({ url }) => {
const hostname = url.hostname.toLowerCase();
const isOnboardingHost =
onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'contract';
if (!isOnboardingHost) {
throw error(404, 'Not found');
}
return {
isPreview: url.searchParams.get('preview') === 'contract',
};
};
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import ContractPage from '$lib/components/ContractPage.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<ContractPage preview={data.isPreview} />
+10 -2
View File
@@ -16,12 +16,19 @@ vi.mock('$lib/server/feature-flags', () => ({
import { load } from './+page.server';
function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
return {
url: new URL(url)
} as Parameters<typeof load>[0];
}
describe('home page server load', () => {
it('returns homepage content', async () => {
getHomepageContent.mockResolvedValue(homepageContent);
isHomepageHowItWorksEnabled.mockReturnValue(false);
await expect(load()).resolves.toEqual({
await expect(load(createLoadEvent())).resolves.toEqual({
siteVariant: 'marketing',
content: homepageContent,
howItWorksEnabled: false
});
@@ -31,7 +38,8 @@ describe('home page server load', () => {
getHomepageContent.mockResolvedValue(homepageContent);
isHomepageHowItWorksEnabled.mockReturnValue(true);
await expect(load()).resolves.toEqual({
await expect(load(createLoadEvent())).resolves.toEqual({
siteVariant: 'marketing',
content: homepageContent,
howItWorksEnabled: true
});
+5 -4
View File
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import { homepageContent } from '$lib/content/homepage';
import HomePage from './+page.svelte';
import { createHomepageRouteData } from '../test/fixtures';
@@ -9,11 +10,11 @@ describe('home page route', () => {
data: createHomepageRouteData()
});
expect(screen.getAllByText("Your Dog's Day!").length).toBeGreaterThan(0);
expect(document.body.textContent).toContain('Happy pets,');
expect(screen.getByText('Locations & Hours')).toBeInTheDocument();
expect(screen.getByText(homepageContent.hero.highlight)).toBeInTheDocument();
expect(screen.getByText(homepageContent.howItWorks.title)).toBeInTheDocument();
expect(screen.getByText(homepageContent.info.title)).toBeInTheDocument();
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
expect(document.title).toBe('Home | Auckland Dog Walking | Goodwalk');
expect(document.title).toBe(homepageContent.seo.title);
expect(document.head.innerHTML).toContain('FAQPage');
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png');
});
@@ -0,0 +1,17 @@
import { error } from '@sveltejs/kit';
import { locationsBySlug } from '$lib/content/locations';
import { getSharedPageContent } from '$lib/server/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const location = locationsBySlug[params.suburb];
if (!location) {
throw error(404, 'Page not found');
}
return {
content: await getSharedPageContent(),
location
};
}
@@ -0,0 +1,26 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
import SeoHead from '$lib/components/SeoHead.svelte';
import LocationPage from '$lib/components/LocationPage.svelte';
import { buildLocationSeo } from '$lib/seo';
import type { PageData } from './$types';
export let data: PageData;
$: location = data.location;
$: seo = buildLocationSeo(location);
</script>
<SeoHead
title={seo.title}
description={seo.description}
canonicalPath={seo.canonicalPath}
image={seo.image}
imageAlt={seo.imageAlt}
structuredData={seo.structuredData}
/>
<Header navigation={data.content.navigation} />
<LocationPage {location} testimonials={data.content.testimonials} />
<Footer footer={data.content.footer} />
+6
View File
@@ -1,4 +1,5 @@
import type { RequestHandler } from './$types';
import { locationPages } from '$lib/content/locations';
const siteUrl = 'https://www.goodwalk.co.nz';
@@ -16,6 +17,11 @@ const routes: SitemapRoute[] = [
{ path: '/our-pricing', priority: '0.8', changefreq: 'monthly' },
{ path: '/about', priority: '0.7', changefreq: 'monthly' },
{ path: '/contact-us', priority: '0.7', changefreq: 'monthly' },
...locationPages.map((loc) => ({
path: `/locations/${loc.slug}`,
priority: '0.8',
changefreq: 'monthly'
})),
{ path: '/terms-and-conditions', priority: '0.3', changefreq: 'yearly' },
{ path: '/privacy-policy', priority: '0.3', changefreq: 'yearly' }
];
+3 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GET } from './+server';
import { locationPages } from '$lib/content/locations';
describe('sitemap endpoint', () => {
afterEach(() => {
@@ -18,6 +19,7 @@ describe('sitemap endpoint', () => {
expect(body).toContain('<loc>https://www.goodwalk.co.nz/contact-us</loc>');
expect(body).toContain('<loc>https://www.goodwalk.co.nz/privacy-policy</loc>');
expect(body).toContain('<lastmod>2026-05-01</lastmod>');
expect(body.match(/<url>/g)).toHaveLength(9);
expect(body).toContain('<loc>https://www.goodwalk.co.nz/locations/mt-eden</loc>');
expect(body.match(/<url>/g)).toHaveLength(9 + locationPages.length);
});
});
+3 -1
View File
@@ -12,7 +12,9 @@ export const sharedPageContent = {
export function createHomepageRouteData() {
return {
content: homepageContent
siteVariant: 'marketing',
content: homepageContent,
howItWorksEnabled: false
};
}