Meet & Greet nudge
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 doesn’t 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>
|
||||
|
||||
@@ -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 didn’t go through. Here are my details:\n\nName:\nPhone:\nDog’s 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}>
|
||||
✕
|
||||
</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’t send that</h2>
|
||||
|
||||
<p class="modal-body">
|
||||
Something went wrong on our end and your message didn’t reach us. The
|
||||
quickest way to get in touch is to email us directly — we’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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface IconCard {
|
||||
title: string;
|
||||
body: string;
|
||||
href?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface TestimonialContent {
|
||||
|
||||
@@ -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 can’t 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 we’ll 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 & 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>
|
||||
Reference in New Issue
Block a user