Meet & Greet nudge

This commit is contained in:
2026-05-02 09:43:32 +12:00
parent 8f31a3fea4
commit cd8d581f7a
11 changed files with 845 additions and 35 deletions
+3 -3
View File
@@ -34,7 +34,7 @@ values (
"highlight": "Your Dog''s Day!", "highlight": "Your Dog''s Day!",
"mobileTitle": "Unleashing Fun in\nYour Dog''s Day!", "mobileTitle": "Unleashing Fun in\nYour Dog''s Day!",
"primaryCta": { "label": "Learn more", "href": "#services", "variant": "yellow" }, "primaryCta": { "label": "Learn more", "href": "#services", "variant": "yellow" },
"secondaryCta": { "label": "Enroll today", "href": "#reservation", "variant": "outline" }, "secondaryCta": { "label": "Enroll today", "href": "#newlead", "variant": "outline" },
"imageUrl": "/images/auckland-dog-walking-happy-dog-hero.png", "imageUrl": "/images/auckland-dog-walking-happy-dog-hero.png",
"imageAlt": "Happy dog on a walk with Goodwalk" "imageAlt": "Happy dog on a walk with Goodwalk"
}, },
@@ -51,7 +51,7 @@ values (
"subtitle": "happy humans", "subtitle": "happy humans",
"body": "Offering tailored pack walks for small and medium dogs, and one-on-one walks for large breeds. Our walkers give personalised attention to each dog, easing stress, anxiety and ensuring a quality experience. Our expertise in small-medium breeds ensures tailored care for their unique needs. Join our", "body": "Offering tailored pack walks for small and medium dogs, and one-on-one walks for large breeds. Our walkers give personalised attention to each dog, easing stress, anxiety and ensuring a quality experience. Our expertise in small-medium breeds ensures tailored care for their unique needs. Join our",
"emphasis": "TINY GANG!", "emphasis": "TINY GANG!",
"cta": { "label": "Book now", "href": "#reservation", "variant": "green" }, "cta": { "label": "Book now", "href": "#newlead", "variant": "green" },
"imageUrl": "/images/auckland-dog-walking-happy-dogs-happy-humans.png", "imageUrl": "/images/auckland-dog-walking-happy-dogs-happy-humans.png",
"imageAlt": "Woman cuddling a dog for Goodwalk Auckland dog walking services" "imageAlt": "Woman cuddling a dog for Goodwalk Auckland dog walking services"
}, },
@@ -144,7 +144,7 @@ values (
"intro": "We cover most of Auckland Central''s suburbs:", "intro": "We cover most of Auckland Central''s suburbs:",
"suburbs": "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.", "suburbs": "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.",
"nearbyText": "Live in a nearby suburb?", "nearbyText": "Live in a nearby suburb?",
"nearbyCta": { "label": "Get in touch!", "href": "#reservation" }, "nearbyCta": { "label": "Get in touch!", "href": "#newlead" },
"hoursLabel": "Opening Hours", "hoursLabel": "Opening Hours",
"hours": "Monday to Friday, 8am - 4pm.", "hours": "Monday to Friday, 8am - 4pm.",
"faqTitle": "FAQ''s", "faqTitle": "FAQ''s",
+53 -22
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import SuccessModal from '$lib/components/SuccessModal.svelte'; import SuccessModal from '$lib/components/SuccessModal.svelte';
import ErrorModal from '$lib/components/ErrorModal.svelte';
import { reveal } from '$lib/actions/reveal'; import { reveal } from '$lib/actions/reveal';
import type { BookingContent } from '$lib/types'; import type { BookingContent } from '$lib/types';
@@ -26,7 +27,32 @@
let errors: Record<string, string> = {}; let errors: Record<string, string> = {};
let submitting = false; let submitting = false;
let submitted = false; let submitted = false;
let submitError = ''; let showErrorModal = false;
let submitErrorDetail = '';
function validateEmail(raw: string): string {
const value = raw.trim();
if (!value) return 'Please enter your email address';
if (!value.includes('@')) return 'Email is missing the @ sign';
const [local, ...domainParts] = value.split('@');
const domain = domainParts.join('@');
if (domainParts.length > 1) return 'Email can only contain one @ sign';
if (!local) return 'Please add the part before the @';
if (!domain) return 'Please add a domain after the @, like @gmail.com';
if (!domain.includes('.')) return 'Please include a domain ending, like @gmail.com';
const tld = domain.split('.').pop() ?? '';
if (tld.length < 2) return 'That domain ending looks too short';
if (/\s/.test(value)) return 'Email cannot contain spaces';
const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
if (!re.test(value)) return 'That email doesnt look quite right';
return '';
}
const defaultDogIntro = const defaultDogIntro =
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.'; 'Tell us about your dog and where you are based so we can plan the right Meet & Greet.';
@@ -70,7 +96,10 @@
const next: Record<string, string> = {}; const next: Record<string, string> = {};
if (!fullName.trim()) next.fullName = 'Please enter your full name'; if (!fullName.trim()) next.fullName = 'Please enter your full name';
if (!email.trim() || !emailInput?.checkValidity()) next.email = 'Please enter a valid email address';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number'; if (!phone.trim()) next.phone = 'Please enter your contact number';
errors = next; errors = next;
@@ -108,7 +137,8 @@
} }
submitting = true; submitting = true;
submitError = ''; submitErrorDetail = '';
showErrorModal = false;
try { try {
const res = await fetch('/api/submit', { const res = await fetch('/api/submit', {
@@ -124,19 +154,23 @@
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
throw new Error(body.detail ?? 'Something went wrong. Please try again.'); const detail = typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
throw new Error(detail);
} }
submitted = true; submitted = true;
} catch (err: unknown) { } catch (err: unknown) {
submitError = err instanceof Error ? err.message : 'Something went wrong. Please try again.'; submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true;
} finally { } finally {
submitting = false; submitting = false;
} }
} }
</script> </script>
<section id="reservation" use:reveal={{ delay: 70 }} class="reveal-block"> <section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
<div class="form-inner"> <div class="form-inner">
{#if submitted} {#if submitted}
@@ -148,6 +182,14 @@
/> />
{/if} {/if}
{#if showErrorModal}
<ErrorModal
detail={submitErrorDetail}
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
<div class="booking-header"> <div class="booking-header">
<h2 class="booking-title"> <h2 class="booking-title">
<span class="booking-title-plain">{headingParts.plain}</span>{' '}<span class="booking-title-highlight">{headingParts.highlight}</span> <span class="booking-title-plain">{headingParts.plain}</span>{' '}<span class="booking-title-highlight">{headingParts.highlight}</span>
@@ -228,6 +270,11 @@
placeholder="Email" placeholder="Email"
class:input-invalid={errors.email} class:input-invalid={errors.email}
on:input={() => clearError('email')} on:input={() => clearError('email')}
on:blur={() => {
if (!email.trim()) return;
const msg = validateEmail(email);
errors = { ...errors, email: msg };
}}
/> />
{#if errors.email} {#if errors.email}
<p class="field-error"> <p class="field-error">
@@ -377,13 +424,6 @@
{#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if} {#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if}
</button> </button>
</div> </div>
{#if submitError}
<p class="booking-submit-error">
<Icon name="fas fa-circle-exclamation" />
{submitError}
</p>
{/if}
{/if} {/if}
</form> </form>
</div> </div>
@@ -403,13 +443,4 @@
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
} }
.booking-submit-error {
margin: 16px 0 0;
display: flex;
align-items: center;
gap: 8px;
color: #c0392b;
font-size: 14px;
}
</style> </style>
+287
View File
@@ -0,0 +1,287 @@
<script lang="ts">
export let email = 'info@goodwalk.co.nz';
export let onClose: () => void;
export let onRetry: (() => void) | null = null;
export let detail = '';
$: mailtoHref =
`mailto:${email}` +
`?subject=${encodeURIComponent('Booking enquiry')}` +
`&body=${encodeURIComponent(
'Hi Aless,\n\nI tried to submit the booking form but it didnt go through. Here are my details:\n\nName:\nPhone:\nDogs name:\nLocation:\n\nThanks!'
)}`;
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="error-modal-heading"
on:click|self={onClose}
on:keydown={handleKeydown}
tabindex="-1"
>
<div class="modal-card">
<button class="modal-close" type="button" aria-label="Close" on:click={onClose}>
&#x2715;
</button>
<div class="modal-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
</div>
<h2 id="error-modal-heading" class="modal-heading">We couldn&rsquo;t send that</h2>
<p class="modal-body">
Something went wrong on our end and your message didn&rsquo;t reach us. The
quickest way to get in touch is to email us directly &mdash; we&rsquo;ll
get back to you the same day.
</p>
<a href={mailtoHref} class="modal-email">
<span class="modal-email-label">Email us at</span>
<span class="modal-email-address">{email}</span>
</a>
{#if detail}
<p class="modal-detail" title={detail}>{detail}</p>
{/if}
<div class="modal-actions">
<a href={mailtoHref} class="modal-btn modal-btn-primary">
Open email
</a>
{#if onRetry}
<button type="button" class="modal-btn modal-btn-secondary" on:click={onRetry}>
Try again
</button>
{/if}
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(10, 20, 10, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: backdrop-in 0.25s ease;
}
.modal-card {
position: relative;
width: 100%;
max-width: 480px;
padding: 52px 48px 40px;
background: #fff;
border-radius: 24px;
box-shadow: 0 24px 80px rgba(10, 20, 10, 0.22);
text-align: center;
animation: card-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-close {
position: absolute;
top: 18px;
right: 20px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #f2f2f0;
color: #888;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s ease,
color 0.15s ease;
}
.modal-close:hover {
background: #e8e8e4;
color: #333;
}
.modal-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: #fdecea;
color: #c0392b;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 18px;
animation: bounce-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.1s both;
}
.modal-heading {
margin: 0 0 12px;
font-size: 24px;
font-weight: 700;
color: #213021;
line-height: 1.25;
}
.modal-body {
margin: 0 0 22px;
font-size: 15px;
color: #555;
line-height: 1.65;
}
.modal-email {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 20px;
margin: 0 auto 8px;
border-radius: 14px;
background: #f7f6f1;
border: 1px solid #ebe9df;
color: #213021;
text-decoration: none;
transition:
background 0.15s ease,
transform 0.15s ease,
border-color 0.15s ease;
}
.modal-email:hover {
background: #fff8d6;
border-color: #ffd100;
transform: translateY(-1px);
}
.modal-email-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #888;
}
.modal-email-address {
font-size: 17px;
font-weight: 600;
color: #213021;
word-break: break-all;
}
.modal-detail {
margin: 12px 0 0;
font-size: 12px;
color: #aaa;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-word;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 26px;
}
.modal-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 13px 28px;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: 999px;
cursor: pointer;
text-decoration: none;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.15s ease,
border-color 0.15s ease;
}
.modal-btn-primary {
background: #213021;
color: #ffd100;
}
.modal-btn-primary:hover {
background: #2e4a2e;
transform: translateY(-1px);
}
.modal-btn-secondary {
background: transparent;
color: #213021;
border: 1px solid #d4d2c6;
}
.modal-btn-secondary:hover {
background: #f2f2f0;
border-color: #213021;
}
@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes card-in {
from {
opacity: 0;
transform: scale(0.88) translateY(16px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes bounce-in {
from {
opacity: 0;
transform: scale(0.4);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 480px) {
.modal-card {
padding: 44px 28px 32px;
}
.modal-actions {
flex-direction: column;
}
.modal-btn {
width: 100%;
}
}
</style>
+264 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal'; import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte'; import BookingSection from '$lib/components/BookingSection.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
@@ -7,6 +8,107 @@
export let content: SiteSharedContent; export let content: SiteSharedContent;
export let pageContent: PricingPageContent; export let pageContent: PricingPageContent;
const promptDelayMs = 10000;
const promptIdleMs = 6000;
const minPromptDwellMs = 3500;
const desktopPromptMediaQuery = '(min-width: 769px)';
let showMeetGreetPrompt = false;
let dismissMeetGreetPrompt = false;
let bookingInView = false;
let promptShown = false;
let canShowDesktopPrompt = false;
function revealMeetGreetPrompt() {
if (dismissMeetGreetPrompt || bookingInView || promptShown || !canShowDesktopPrompt) {
return;
}
showMeetGreetPrompt = true;
promptShown = true;
}
function closeMeetGreetPrompt() {
showMeetGreetPrompt = false;
dismissMeetGreetPrompt = true;
}
function handleMeetGreetCta() {
showMeetGreetPrompt = false;
dismissMeetGreetPrompt = true;
}
$: if ((!canShowDesktopPrompt || bookingInView) && showMeetGreetPrompt) {
showMeetGreetPrompt = false;
}
onMount(() => {
const startedAt = Date.now();
let lastInteractionAt = startedAt;
const desktopPromptQuery = window.matchMedia(desktopPromptMediaQuery);
canShowDesktopPrompt = desktopPromptQuery.matches;
const recordInteraction = () => {
lastInteractionAt = Date.now();
};
const handleDesktopPromptViewportChange = (event: MediaQueryListEvent) => {
canShowDesktopPrompt = event.matches;
};
const interactionEvents: Array<keyof WindowEventMap> = [
'pointerdown',
'mousemove',
'keydown',
'scroll',
'touchstart'
];
interactionEvents.forEach((eventName) => {
window.addEventListener(eventName, recordInteraction);
});
const delayedPrompt = window.setTimeout(() => {
revealMeetGreetPrompt();
}, promptDelayMs);
const idlePromptCheck = window.setInterval(() => {
const now = Date.now();
const hasWaitedLongEnough = now - startedAt >= minPromptDwellMs;
const looksIdle = now - lastInteractionAt >= promptIdleMs;
if (document.visibilityState === 'visible' && hasWaitedLongEnough && looksIdle) {
revealMeetGreetPrompt();
}
}, 1000);
const bookingSection = document.getElementById('newlead');
const bookingObserver = bookingSection
? new IntersectionObserver(
([entry]) => {
bookingInView = entry.isIntersecting;
},
{ threshold: 0.2 }
)
: null;
if (bookingObserver && bookingSection) {
bookingObserver.observe(bookingSection);
}
desktopPromptQuery.addEventListener('change', handleDesktopPromptViewportChange);
return () => {
window.clearTimeout(delayedPrompt);
window.clearInterval(idlePromptCheck);
interactionEvents.forEach((eventName) => {
window.removeEventListener(eventName, recordInteraction);
});
desktopPromptQuery.removeEventListener('change', handleDesktopPromptViewportChange);
bookingObserver?.disconnect();
};
});
</script> </script>
<main class="pricing-page"> <main class="pricing-page">
@@ -59,7 +161,7 @@
{/each} {/each}
</ul> </ul>
<a class="btn btn-yellow pricing-plan-cta" href="#reservation">Book Now</a> <a class="btn btn-yellow pricing-plan-cta" href="#newlead">Book Now</a>
</article> </article>
{/each} {/each}
</div> </div>
@@ -69,6 +171,26 @@
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} /> <TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
<BookingSection booking={pageContent.booking} /> <BookingSection booking={pageContent.booking} />
{#if showMeetGreetPrompt}
<aside class="meet-greet-prompt" aria-label="Free meet and greet reminder">
<button class="meet-greet-close" type="button" aria-label="Dismiss reminder" on:click={closeMeetGreetPrompt}>
<Icon name="fas fa-xmark" />
</button>
<div class="meet-greet-copy">
<span class="meet-greet-kicker">
<Icon name="fas fa-comment-dots" />
Free Meet & Greet
</span>
<p>
Not sure which option fits best? We can talk it through together and make sure your dog ends up happy.
</p>
</div>
<a class="meet-greet-cta" href="#newlead" on:click={handleMeetGreetCta}>Let's chat</a>
</aside>
{/if}
</main> </main>
<style> <style>
@@ -239,6 +361,125 @@
font-family: var(--font-head); font-family: var(--font-head);
} }
.meet-greet-prompt {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 30;
display: flex;
align-items: flex-end;
gap: 18px;
width: min(420px, calc(100vw - 32px));
padding: 18px 18px 18px 20px;
border-radius: 24px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 243, 232, 0.98));
box-shadow:
0 20px 40px rgba(17, 20, 24, 0.16),
0 0 0 1px rgba(17, 20, 24, 0.06);
animation: meet-greet-rise 0.28s cubic-bezier(0.22, 1, 0.36, 1);
backdrop-filter: blur(10px);
}
.meet-greet-copy {
min-width: 0;
flex: 1;
}
.meet-greet-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(33, 48, 33, 0.08);
color: var(--green);
font-family: var(--font-head);
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.meet-greet-kicker :global(.icon) {
font-size: 12px;
}
.meet-greet-copy p {
margin: 0;
color: #2f3134;
font-size: 15px;
line-height: 1.55;
}
.meet-greet-cta {
flex-shrink: 0;
align-self: center;
padding: 12px 18px;
border-radius: 999px;
background: var(--yellow);
color: #111;
font-family: var(--font-head);
font-size: 14px;
text-decoration: none;
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.08);
transition:
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.2s ease,
background 0.2s ease;
}
.meet-greet-close {
position: absolute;
top: 12px;
right: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 999px;
background: rgba(17, 20, 24, 0.05);
color: #2f3134;
cursor: pointer;
transition:
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
background 0.2s ease;
}
@media (hover: hover) {
.meet-greet-cta:hover {
transform: translateY(-2px);
background: #ffd100;
box-shadow:
inset 0 -2px 0 rgba(0, 0, 0, 0.08),
0 12px 24px rgba(17, 20, 24, 0.12);
}
.meet-greet-close:hover {
transform: scale(1.05);
background: rgba(17, 20, 24, 0.09);
}
}
.meet-greet-cta:active,
.meet-greet-close:active {
transform: scale(0.97);
}
@keyframes meet-greet-rise {
from {
opacity: 0;
transform: translate3d(0, 16px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (hover: hover) { @media (hover: hover) {
.pricing-plan-card:hover { .pricing-plan-card:hover {
transform: translateY(-8px) scale(1.012); transform: translateY(-8px) scale(1.012);
@@ -318,5 +559,27 @@
.pricing-plan-price { .pricing-plan-price {
font-size: 46px; font-size: 46px;
} }
.meet-greet-prompt {
right: 16px;
left: 16px;
bottom: 16px;
width: auto;
flex-direction: column;
align-items: stretch;
gap: 14px;
padding: 18px 18px 16px;
border-radius: 20px;
}
.meet-greet-copy p {
font-size: 14px;
line-height: 1.5;
padding-right: 26px;
}
.meet-greet-cta {
align-self: flex-start;
}
} }
</style> </style>
+1 -1
View File
@@ -78,7 +78,7 @@
{/each} {/each}
</ul> </ul>
<a class="btn btn-yellow service-plan-cta" href="#reservation">Book Now</a> <a class="btn btn-yellow service-plan-cta" href="#newlead">Book Now</a>
</article> </article>
{/each} {/each}
</div> </div>
@@ -7,7 +7,7 @@
export let testimonials: TestimonialContent[]; export let testimonials: TestimonialContent[];
export let heading = 'Why people choose us!'; export let heading = 'Why people choose us!';
export let blurb = export let blurb =
'Real dogs, real routines, real happy humans. Follow along on Instagram to see the Tiny Gang out on their daily adventures.'; 'Real dogs, real routines, trusted by happy owners. Follow along on Instagram to see the Tiny Gang out on their daily adventures.';
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/'; export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
export let instagramLabel = '@goodwalk.nz'; export let instagramLabel = '@goodwalk.nz';
+15 -1
View File
@@ -3,6 +3,20 @@
import type { IconCard } from '$lib/types'; import type { IconCard } from '$lib/types';
export let values: IconCard[]; export let values: IconCard[];
$: orderedValues = values
.map((value, index) => ({ value, index }))
.sort((a, b) => {
const aOrder = a.value.order ?? Number.POSITIVE_INFINITY;
const bOrder = b.value.order ?? Number.POSITIVE_INFINITY;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.index - b.index;
})
.map(({ value }) => value);
</script> </script>
<section id="values"> <section id="values">
@@ -10,7 +24,7 @@
<h2 class="section-heading">Where dogs come first</h2> <h2 class="section-heading">Where dogs come first</h2>
<div class="values-grid"> <div class="values-grid">
{#each values as value} {#each orderedValues as value}
<div class="value-card"> <div class="value-card">
<Icon name={value.icon} className="value-card-icon" /> <Icon name={value.icon} className="value-card-icon" />
<h3>{value.title}</h3> <h3>{value.title}</h3>
+6 -4
View File
@@ -34,7 +34,7 @@ export const homepageContent: HomePageContent = {
highlight: "Your Dog's Day!", highlight: "Your Dog's Day!",
mobileTitle: "Unleashing Fun in\nYour Dog's Day!", mobileTitle: "Unleashing Fun in\nYour Dog's Day!",
primaryCta: { label: 'Learn more', href: '#services', variant: 'yellow' }, primaryCta: { label: 'Learn more', href: '#services', variant: 'yellow' },
secondaryCta: { label: 'Enroll today', href: '#reservation', variant: 'outline' }, secondaryCta: { label: 'Enroll today', href: '#newlead', variant: 'outline' },
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png', imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service' imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
}, },
@@ -52,7 +52,7 @@ export const homepageContent: HomePageContent = {
body: body:
'Offering tailored pack walks for small and medium dogs, and one-on-one walks for large breeds. Our walkers give personalised attention to each dog, easing stress, anxiety and ensuring a quality experience. Our expertise in small-medium breeds ensures tailored care for their unique needs. Join our', 'Offering tailored pack walks for small and medium dogs, and one-on-one walks for large breeds. Our walkers give personalised attention to each dog, easing stress, anxiety and ensuring a quality experience. Our expertise in small-medium breeds ensures tailored care for their unique needs. Join our',
emphasis: 'TINY GANG!', emphasis: 'TINY GANG!',
cta: { label: 'Book now', href: '#reservation', variant: 'green' }, cta: { label: 'Book now', href: '#newlead', variant: 'green' },
imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.png', imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.png',
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services' imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
}, },
@@ -92,12 +92,14 @@ export const homepageContent: HomePageContent = {
{ {
icon: 'fas fa-users', icon: 'fas fa-users',
title: 'Small Pack Sizes', title: 'Small Pack Sizes',
order: 2,
body: body:
'With just 4-8 dogs per group, our walks are calm, controlled, and respectful of public spaces - ensuring every dog gets the attention and care they deserve.' 'With just 4-8 dogs per group, our walks are calm, controlled, and respectful of public spaces - ensuring every dog gets the attention and care they deserve.'
}, },
{ {
icon: 'fas fa-shield-heart', icon: 'fas fa-shield-heart',
title: 'Safety', title: 'Safety',
order: 1,
body: body:
'Our team is fully pet first aid certified and trained to handle any situation calmly and confidently. With proactive safety protocols and constant situational awareness, we create a secure environment for every walk.' 'Our team is fully pet first aid certified and trained to handle any situation calmly and confidently. With proactive safety protocols and constant situational awareness, we create a secure environment for every walk.'
}, },
@@ -157,7 +159,7 @@ export const homepageContent: HomePageContent = {
suburbs: suburbs:
'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.', '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.',
nearbyText: 'Live in a nearby suburb?', nearbyText: 'Live in a nearby suburb?',
nearbyCta: { label: 'Get in touch!', href: '#reservation' }, nearbyCta: { label: 'Get in touch!', href: '#newlead' },
hoursLabel: 'Opening Hours', hoursLabel: 'Opening Hours',
hours: 'Monday to Friday, 8am - 4pm.', hours: 'Monday to Friday, 8am - 4pm.',
faqTitle: "FAQ's", faqTitle: "FAQ's",
@@ -189,7 +191,7 @@ export const homepageContent: HomePageContent = {
] ]
}, },
instagram: { instagram: {
title: 'Follow us on Instagram', title: 'Follow the Tiny Gang adventures on Instagram!',
label: '@goodwalk.nz', label: '@goodwalk.nz',
href: 'https://www.instagram.com/goodwalk.nz/', href: 'https://www.instagram.com/goodwalk.nz/',
variant: 'green', variant: 'green',
+1 -2
View File
@@ -56,7 +56,6 @@ section {
gap: 18px; gap: 18px;
align-items: center; align-items: center;
width: 100%; width: 100%;
max-width: 960px;
margin: 0; margin: 0;
padding: 18px 22px; padding: 18px 22px;
border-radius: 26px; border-radius: 26px;
@@ -134,7 +133,7 @@ section {
} }
#services, #services,
#reservation { #newlead {
background: #fff; background: #fff;
} }
+1
View File
@@ -61,6 +61,7 @@ export interface IconCard {
title: string; title: string;
body: string; body: string;
href?: string; href?: string;
order?: number;
} }
export interface TestimonialContent { export interface TestimonialContent {
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import { page } from '$app/stores';
$: status = $page.status;
$: errorMessage = $page.error?.message ?? '';
$: isNotFound = status === 404;
$: heading = isNotFound ? 'This page wandered off' : 'Something went wrong';
$: subtitle = isNotFound
? 'The page you were looking for cant be found. It may have moved, or the link might be a little chewed up.'
: 'We hit a snag loading this page. Try heading home and well get you back on the trail.';
</script>
<svelte:head>
<title>{status} · GoodWalk</title>
<meta name="robots" content="noindex" />
</svelte:head>
<main class="error-page">
<div class="error-bg" aria-hidden="true">
<span class="paw paw-1">🐾</span>
<span class="paw paw-2">🐾</span>
<span class="paw paw-3">🐾</span>
<span class="paw paw-4">🐾</span>
<span class="paw paw-5">🐾</span>
</div>
<div class="error-content">
<div class="error-status">{status}</div>
<h1 class="error-heading">{heading}</h1>
<p class="error-subtitle">{subtitle}</p>
{#if errorMessage && errorMessage !== heading}
<p class="error-detail">{errorMessage}</p>
{/if}
<div class="error-actions">
<a href="/" class="btn btn-yellow">Take me home</a>
<a href="/booking" class="btn btn-outline">Book a Meet &amp; Greet</a>
</div>
</div>
</main>
<style>
.error-page {
position: relative;
min-height: 100dvh;
display: grid;
place-items: center;
background:
radial-gradient(circle at 20% 15%, #2a3e2a 0%, transparent 55%),
radial-gradient(circle at 80% 85%, #1a261a 0%, transparent 55%),
var(--green);
color: #fff;
overflow: hidden;
padding: 48px 24px;
}
.error-bg {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.paw {
position: absolute;
user-select: none;
color: var(--yellow);
opacity: 0.07;
line-height: 1;
}
.paw-1 {
top: 6%;
left: 5%;
font-size: 110px;
transform: rotate(-18deg);
}
.paw-2 {
top: 14%;
right: 8%;
font-size: 140px;
transform: rotate(22deg);
}
.paw-3 {
bottom: 10%;
left: 9%;
font-size: 160px;
transform: rotate(35deg);
}
.paw-4 {
bottom: 6%;
right: 6%;
font-size: 120px;
transform: rotate(-12deg);
}
.paw-5 {
top: 48%;
left: 50%;
font-size: 80px;
transform: translate(-50%, -50%) rotate(8deg);
opacity: 0.04;
}
.error-content {
position: relative;
text-align: center;
max-width: 640px;
z-index: 1;
animation: rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.error-status {
font-family: var(--font-head);
font-size: clamp(120px, 22vw, 220px);
font-weight: 700;
color: var(--yellow);
line-height: 0.9;
letter-spacing: -0.04em;
margin-bottom: 16px;
text-shadow: 0 6px 32px rgba(0, 0, 0, 0.25);
}
.error-heading {
font-family: var(--font-head);
font-size: clamp(28px, 4vw, 40px);
font-weight: 600;
line-height: 1.2;
margin: 0 0 16px;
color: #fff;
}
.error-subtitle {
font-size: clamp(15px, 1.6vw, 17px);
line-height: 1.65;
color: rgba(255, 255, 255, 0.78);
margin: 0 auto 16px;
max-width: 520px;
}
.error-detail {
display: inline-block;
font-size: 12px;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.55);
margin: 0 0 32px;
padding: 6px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 100px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.error-content :not(.error-detail) + .error-actions {
margin-top: 36px;
}
.error-actions {
display: flex;
gap: 14px;
justify-content: center;
flex-wrap: wrap;
margin-top: 8px;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.error-content {
animation: none;
}
}
@media (max-width: 560px) {
.error-page {
padding: 32px 20px;
}
.paw-1 {
font-size: 70px;
}
.paw-2 {
font-size: 86px;
}
.paw-3 {
font-size: 96px;
}
.paw-4 {
font-size: 76px;
}
.paw-5 {
display: none;
}
.error-actions {
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.error-actions :global(.btn) {
width: 100%;
}
}
</style>