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!",
"mobileTitle": "Unleashing Fun in\nYour Dog''s Day!",
"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",
"imageAlt": "Happy dog on a walk with Goodwalk"
},
@@ -51,7 +51,7 @@ values (
"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",
"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",
"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:",
"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?",
"nearbyCta": { "label": "Get in touch!", "href": "#reservation" },
"nearbyCta": { "label": "Get in touch!", "href": "#newlead" },
"hoursLabel": "Opening Hours",
"hours": "Monday to Friday, 8am - 4pm.",
"faqTitle": "FAQ''s",
+53 -22
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import SuccessModal from '$lib/components/SuccessModal.svelte';
import ErrorModal from '$lib/components/ErrorModal.svelte';
import { reveal } from '$lib/actions/reveal';
import type { BookingContent } from '$lib/types';
@@ -26,7 +27,32 @@
let errors: Record<string, string> = {};
let submitting = 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 =
'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> = {};
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';
errors = next;
@@ -108,7 +137,8 @@
}
submitting = true;
submitError = '';
submitErrorDetail = '';
showErrorModal = false;
try {
const res = await fetch('/api/submit', {
@@ -124,19 +154,23 @@
if (!res.ok) {
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;
} 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 {
submitting = false;
}
}
</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">
{#if submitted}
@@ -148,6 +182,14 @@
/>
{/if}
{#if showErrorModal}
<ErrorModal
detail={submitErrorDetail}
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
<div class="booking-header">
<h2 class="booking-title">
<span class="booking-title-plain">{headingParts.plain}</span>{' '}<span class="booking-title-highlight">{headingParts.highlight}</span>
@@ -228,6 +270,11 @@
placeholder="Email"
class:input-invalid={errors.email}
on:input={() => clearError('email')}
on:blur={() => {
if (!email.trim()) return;
const msg = validateEmail(email);
errors = { ...errors, email: msg };
}}
/>
{#if errors.email}
<p class="field-error">
@@ -377,13 +424,6 @@
{#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if}
</button>
</div>
{#if submitError}
<p class="booking-submit-error">
<Icon name="fas fa-circle-exclamation" />
{submitError}
</p>
{/if}
{/if}
</form>
</div>
@@ -403,13 +443,4 @@
opacity: 1;
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>
+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">
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte';
import Icon from '$lib/components/Icon.svelte';
@@ -7,6 +8,107 @@
export let content: SiteSharedContent;
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>
<main class="pricing-page">
@@ -59,7 +161,7 @@
{/each}
</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>
{/each}
</div>
@@ -69,6 +171,26 @@
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
<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>
<style>
@@ -239,6 +361,125 @@
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) {
.pricing-plan-card:hover {
transform: translateY(-8px) scale(1.012);
@@ -318,5 +559,27 @@
.pricing-plan-price {
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>
+1 -1
View File
@@ -78,7 +78,7 @@
{/each}
</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>
{/each}
</div>
@@ -7,7 +7,7 @@
export let testimonials: TestimonialContent[];
export let heading = 'Why people choose us!';
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 instagramLabel = '@goodwalk.nz';
+15 -1
View File
@@ -3,6 +3,20 @@
import type { IconCard } from '$lib/types';
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>
<section id="values">
@@ -10,7 +24,7 @@
<h2 class="section-heading">Where dogs come first</h2>
<div class="values-grid">
{#each values as value}
{#each orderedValues as value}
<div class="value-card">
<Icon name={value.icon} className="value-card-icon" />
<h3>{value.title}</h3>
+6 -4
View File
@@ -34,7 +34,7 @@ export const homepageContent: HomePageContent = {
highlight: "Your Dog's Day!",
mobileTitle: "Unleashing Fun in\nYour Dog's Day!",
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',
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
},
@@ -52,7 +52,7 @@ export const homepageContent: HomePageContent = {
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!',
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',
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
},
@@ -92,12 +92,14 @@ export const homepageContent: HomePageContent = {
{
icon: 'fas fa-users',
title: 'Small Pack Sizes',
order: 2,
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.'
},
{
icon: 'fas fa-shield-heart',
title: 'Safety',
order: 1,
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.'
},
@@ -157,7 +159,7 @@ export const homepageContent: HomePageContent = {
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?',
nearbyCta: { label: 'Get in touch!', href: '#reservation' },
nearbyCta: { label: 'Get in touch!', href: '#newlead' },
hoursLabel: 'Opening Hours',
hours: 'Monday to Friday, 8am - 4pm.',
faqTitle: "FAQ's",
@@ -189,7 +191,7 @@ export const homepageContent: HomePageContent = {
]
},
instagram: {
title: 'Follow us on Instagram',
title: 'Follow the Tiny Gang adventures on Instagram!',
label: '@goodwalk.nz',
href: 'https://www.instagram.com/goodwalk.nz/',
variant: 'green',
+1 -2
View File
@@ -56,7 +56,6 @@ section {
gap: 18px;
align-items: center;
width: 100%;
max-width: 960px;
margin: 0;
padding: 18px 22px;
border-radius: 26px;
@@ -134,7 +133,7 @@ section {
}
#services,
#reservation {
#newlead {
background: #fff;
}
+1
View File
@@ -61,6 +61,7 @@ export interface IconCard {
title: string;
body: string;
href?: string;
order?: number;
}
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>