This commit is contained in:
2026-05-18 09:43:29 +12:00
parent b950229003
commit 6ff970015f
189 changed files with 18603 additions and 2727 deletions
+960
View File
@@ -0,0 +1,960 @@
<!--
/meet-greet-v2 — A/B test variant of the Meet & Greet booking flow.
Standalone "sticker stack" multi-step wizard. NOT linked from the main
nav. Submits to the same /api/submit backend as the live contact form
(BookingSection.svelte) using an identical payload shape, so the
downstream handler treats it the same. Marked noindex so the variant
does not appear in search results during the test.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import Icon from '$lib/components/Icon.svelte';
import { homepageContent } from '$lib/content/homepage';
type SuccessModalComponentType = typeof import('$lib/components/SuccessModal.svelte').default;
type ErrorModalComponentType = typeof import('$lib/components/ErrorModal.svelte').default;
const navigation = homepageContent.navigation;
const footerContent = homepageContent.footer;
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const maxJourneyEntries = 8;
const serviceOptions = ['Tiny Gang Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'];
const dogShortcuts = ['puppy', 'senior', 'rescue', 'shy'];
let step = 1;
const totalSteps = 4;
let dogDetails = '';
let selectedServices: string[] = [];
let message = '';
let fullName = '';
let email = '';
let phone = '';
let location = '';
let website = ''; // honeypot
let formStartedAt = 0;
let visitStartedAt = 0;
let pageEnteredAt = 0;
let firstInteractionAt = 0;
let sendClickedAt = 0;
let stepChanges = 0;
let journey: string[] = [];
let errors: Record<string, string> = {};
let submitting = false;
let submitted = false;
let showErrorModal = false;
let submitErrorDetail = '';
let SuccessModalComponent: SuccessModalComponentType | null = null;
let ErrorModalComponent: ErrorModalComponentType | null = null;
$: dogFirstWord = dogDetails.trim().split(/[,\s]/)[0] || 'your dog';
$: dogNameDisplay = dogFirstWord
? dogFirstWord.charAt(0).toLocaleUpperCase() + dogFirstWord.slice(1)
: 'your dog';
$: stepCopy = [
{
eyebrow: 'Question one',
heading: "Who's the star?",
helper: 'Tell us your dog in one line — name, age, breed. We will use this to point you toward the right walk.'
},
{
eyebrow: 'Question two',
heading: `What's ${dogNameDisplay} after?`,
helper: 'Pick everything you are open to. We will recommend the best fit when we reply.'
},
{
eyebrow: 'Question three',
heading: 'Anything we should know?',
helper: 'Health quirks, anxiety triggers, weekly schedule, dream outcome — anything that helps us prepare.'
},
{
eyebrow: 'Question four',
heading: 'How do we reach you?',
helper: 'A real person replies within 24 hours, usually sooner.'
}
];
$: successPetName = dogNameDisplay;
$: if (submitted) {
ensureSuccessModal();
}
$: if (showErrorModal) {
ensureErrorModal();
}
onMount(() => {
const now = Date.now();
formStartedAt = now;
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
});
function readOrCreateVisitStartedAt(fallback: number) {
try {
const raw = window.sessionStorage.getItem(visitStartedStorageKey);
const parsed = raw ? Number(raw) : NaN;
if (Number.isFinite(parsed) && parsed > 0) return parsed;
window.sessionStorage.setItem(visitStartedStorageKey, String(fallback));
} catch {
return fallback;
}
return fallback;
}
function updateJourneySnapshot(pathname: string, search: string) {
const nextEntry = `${pathname}${search}`;
try {
const raw = window.sessionStorage.getItem(journeyStorageKey);
const previous = raw ? (JSON.parse(raw) as string[]) : [];
const cleaned = previous.filter((value) => typeof value === 'string' && value.trim());
const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry];
const nextJourney = deduped.slice(-maxJourneyEntries);
window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney));
return nextJourney;
} catch {
return [nextEntry];
}
}
async function ensureSuccessModal() {
if (SuccessModalComponent) return;
SuccessModalComponent = (await import('$lib/components/SuccessModal.svelte')).default;
}
async function ensureErrorModal() {
if (ErrorModalComponent) return;
ErrorModalComponent = (await import('$lib/components/ErrorModal.svelte')).default;
}
function noteInteraction() {
if (!firstInteractionAt) firstInteractionAt = Date.now();
}
function clearError(field: string) {
if (errors[field]) errors = { ...errors, [field]: '' };
}
function appendShortcut(token: string) {
noteInteraction();
const current = dogDetails.trim();
if (current.toLowerCase().includes(token.toLowerCase())) return;
dogDetails = current ? `${current.replace(/[,\s]+$/, '')}, ${token}` : token;
clearError('dogDetails');
}
function toggleService(service: string) {
noteInteraction();
if (selectedServices.includes(service)) {
selectedServices = selectedServices.filter((s) => s !== service);
} else {
selectedServices = [...selectedServices, service];
}
clearError('services');
}
function validateEmail(raw: string): string {
const value = raw.trim();
if (!value) return 'Please enter your email';
if (!value.includes('@')) return 'Email is missing the @ sign';
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 '';
}
function validateStep(target: number): boolean {
const next: Record<string, string> = {};
if (target === 1 && !dogDetails.trim()) {
next.dogDetails = "Tell us a little about your dog so we can match the right walk.";
}
if (target === 2 && selectedServices.length === 0) {
next.services = 'Pick at least one service to continue.';
}
if (target === 4) {
if (!fullName.trim()) next.fullName = 'Please enter your full name';
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
if (!phone.trim()) next.phone = 'Please enter your contact number';
if (!location.trim()) next.location = 'Please enter your suburb';
}
errors = next;
return Object.keys(next).length === 0;
}
function goNext() {
noteInteraction();
if (!validateStep(step)) return;
if (step < totalSteps) {
step += 1;
stepChanges += 1;
}
}
function goBack() {
noteInteraction();
if (step > 1) {
step -= 1;
stepChanges += 1;
errors = {};
}
}
async function handleSubmit() {
noteInteraction();
if (!validateStep(4)) return;
submitting = true;
sendClickedAt = Date.now();
submitErrorDetail = '';
showErrorModal = false;
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enquiryType: 'booking',
fullName,
email,
phone,
petName: dogDetails,
location,
message,
services: selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
referrer: typeof document !== 'undefined' ? document.referrer : '',
page: typeof window !== 'undefined' ? window.location.href : '',
variant: 'meet-greet-v2'
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
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) {
submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true;
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Book your free Meet & Greet | Goodwalk</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="description" content="Book a free Goodwalk Meet & Greet — short, guided application for Auckland dog owners." />
</svelte:head>
<Header {navigation} />
<main class="mgv2-page">
{#if submitted && SuccessModalComponent}
<svelte:component
this={SuccessModalComponent}
firstName={fullName.split(' ')[0]}
petName={successPetName}
{email}
enquiryType="booking"
onClose={() => (submitted = false)}
/>
{/if}
{#if showErrorModal && ErrorModalComponent}
<svelte:component
this={ErrorModalComponent}
detail={submitErrorDetail}
enquiryType="booking"
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
<section class="mgv2-shell">
<header class="mgv2-intro">
<span class="mgv2-kicker">Free Meet &amp; Greet</span>
<h1>Start with the right fit for your dog.</h1>
<p>Four short questions. A real reply within 24 hours.</p>
</header>
<div
class="mgv2-progress"
role="progressbar"
aria-valuemin="1"
aria-valuemax={totalSteps}
aria-valuenow={step}
>
{#each Array.from({ length: totalSteps }) as _, index}
<span class="mgv2-progress-dot" class:active={index + 1 === step}></span>
{/each}
</div>
<div class="mgv2-stack">
<span class="mgv2-decoy mgv2-decoy-left" aria-hidden="true"></span>
<span class="mgv2-decoy mgv2-decoy-right" aria-hidden="true"></span>
<article class="mgv2-card">
<span class="mgv2-badge" aria-hidden="true">
<Icon name="fas fa-paw" />
</span>
<input
bind:value={website}
type="text"
name="website"
class="mgv2-honeypot"
tabindex="-1"
autocomplete="new-password"
aria-hidden="true"
/>
{#key step}
<div class="mgv2-step" in:fade={{ duration: 200 }} out:fade={{ duration: 120 }}>
<span class="mgv2-eyebrow">{stepCopy[step - 1].eyebrow}</span>
<h2 class="mgv2-heading">{stepCopy[step - 1].heading}</h2>
<p class="mgv2-helper">{stepCopy[step - 1].helper}</p>
{#if step === 1}
<label class="mgv2-field" for="mgv2-dog">
<span class="mgv2-label">Your dog, in a line</span>
<input
bind:value={dogDetails}
on:input={() => clearError('dogDetails')}
id="mgv2-dog"
type="text"
placeholder="Teddy, 3, schnoodle"
class:mgv2-input-invalid={errors.dogDetails}
autocomplete="off"
/>
{#if errors.dogDetails}
<span class="mgv2-error">{errors.dogDetails}</span>
{/if}
</label>
<div class="mgv2-chips" aria-label="Quick add">
{#each dogShortcuts as token}
<button
type="button"
class="mgv2-chip"
on:click={() => appendShortcut(token)}
>+ {token}</button>
{/each}
</div>
{:else if step === 2}
<div
class="mgv2-service-grid"
class:mgv2-service-grid-invalid={errors.services}
role="group"
aria-label="Service interest"
>
{#each serviceOptions as service}
{@const checked = selectedServices.includes(service)}
<button
type="button"
class="mgv2-service"
class:active={checked}
aria-pressed={checked}
on:click={() => toggleService(service)}
>
<span class="mgv2-service-check" aria-hidden="true">
{#if checked}<Icon name="fas fa-check" />{/if}
</span>
<span class="mgv2-service-label">{service}</span>
</button>
{/each}
</div>
{#if errors.services}
<span class="mgv2-error">{errors.services}</span>
{/if}
{:else if step === 3}
<label class="mgv2-field" for="mgv2-message">
<span class="mgv2-label">Notes for us</span>
<textarea
bind:value={message}
id="mgv2-message"
rows="5"
placeholder="For example: nervous around bigger dogs, prefers shorter walks, recently rescued."
></textarea>
</label>
{:else if step === 4}
<div class="mgv2-grid-two">
<label class="mgv2-field" for="mgv2-name">
<span class="mgv2-label">Full name</span>
<input
bind:value={fullName}
on:input={() => clearError('fullName')}
id="mgv2-name"
type="text"
placeholder="Your full name"
class:mgv2-input-invalid={errors.fullName}
autocomplete="name"
/>
{#if errors.fullName}<span class="mgv2-error">{errors.fullName}</span>{/if}
</label>
<label class="mgv2-field" for="mgv2-email">
<span class="mgv2-label">Email</span>
<input
bind:value={email}
on:input={() => clearError('email')}
id="mgv2-email"
type="email"
placeholder="you@example.com"
class:mgv2-input-invalid={errors.email}
autocomplete="email"
/>
{#if errors.email}<span class="mgv2-error">{errors.email}</span>{/if}
</label>
<label class="mgv2-field" for="mgv2-phone">
<span class="mgv2-label">Phone</span>
<input
bind:value={phone}
on:input={() => clearError('phone')}
id="mgv2-phone"
type="tel"
placeholder="021 123 4567"
class:mgv2-input-invalid={errors.phone}
autocomplete="tel"
/>
{#if errors.phone}<span class="mgv2-error">{errors.phone}</span>{/if}
</label>
<label class="mgv2-field" for="mgv2-location">
<span class="mgv2-label">Suburb</span>
<input
bind:value={location}
on:input={() => clearError('location')}
id="mgv2-location"
type="text"
placeholder="For example, Grey Lynn"
class:mgv2-input-invalid={errors.location}
autocomplete="address-level2"
/>
{#if errors.location}<span class="mgv2-error">{errors.location}</span>{/if}
</label>
</div>
{/if}
<div class="mgv2-actions">
{#if step > 1}
<button type="button" class="mgv2-back" on:click={goBack}>
<Icon name="fas fa-arrow-left" />
Back
</button>
{:else}
<span class="mgv2-back-spacer" aria-hidden="true"></span>
{/if}
{#if step < totalSteps}
<button type="button" class="mgv2-next" on:click={goNext}>
Next
<Icon name="fas fa-arrow-right" />
</button>
{:else}
<button
type="button"
class="mgv2-next"
on:click={handleSubmit}
disabled={submitting}
>
{submitting ? 'Sending…' : 'Send Meet & Greet request'}
{#if !submitting}<Icon name="fas fa-arrow-right" />{/if}
</button>
{/if}
</div>
</div>
{/key}
</article>
</div>
<p class="mgv2-footnote">
No payment, no pressure. Reply from a real person within 24 hours.
</p>
</section>
</main>
<Footer footer={footerContent} />
<style>
:root {
--mgv2-yellow: #FFC700;
--mgv2-cream: #FAF8F1;
--mgv2-ink: #0b0b0b;
--mgv2-line: rgba(11, 11, 11, 0.08);
--mgv2-muted: rgba(11, 11, 11, 0.55);
}
.mgv2-page {
background: var(--mgv2-cream);
min-height: 100vh;
padding: 56px 24px 88px;
overflow-x: clip;
}
.mgv2-shell {
width: 100%;
max-width: 540px;
margin: 0 auto;
text-align: center;
}
.mgv2-intro {
margin: 0 auto 28px;
max-width: 480px;
}
.mgv2-kicker {
display: inline-block;
margin-bottom: 12px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(11, 11, 11, 0.05);
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mgv2-intro h1 {
margin: 0 0 8px;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: clamp(28px, 4vw, 38px);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
}
.mgv2-intro p {
margin: 0;
color: var(--mgv2-muted);
font-size: 15px;
line-height: 1.55;
}
.mgv2-progress {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 28px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(11, 11, 11, 0.04);
}
.mgv2-progress-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(11, 11, 11, 0.18);
transition:
width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
background 0.18s ease;
}
.mgv2-progress-dot.active {
width: 32px;
background: var(--mgv2-yellow);
}
.mgv2-stack {
position: relative;
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.mgv2-decoy {
position: absolute;
inset: 14px -8px auto -8px;
height: 100%;
border-radius: 16px;
background: #fff;
border: 1px solid var(--mgv2-line);
box-shadow: 0 18px 36px rgba(11, 11, 11, 0.08);
z-index: 0;
pointer-events: none;
}
.mgv2-decoy-left {
transform: rotate(-3deg) translateY(-6px);
}
.mgv2-decoy-right {
transform: rotate(2deg) translateY(2px);
}
.mgv2-card {
position: relative;
z-index: 2;
width: 100%;
padding: 36px 28px 28px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--mgv2-line);
box-shadow:
0 24px 60px rgba(11, 11, 11, 0.12),
0 4px 14px rgba(11, 11, 11, 0.04);
text-align: left;
}
.mgv2-badge {
position: absolute;
top: -20px;
right: 18px;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 999px;
background: var(--mgv2-yellow);
color: var(--mgv2-ink);
box-shadow:
0 12px 24px rgba(255, 199, 0, 0.36),
inset 0 0 0 2px rgba(11, 11, 11, 0.06);
transform: rotate(8deg);
}
.mgv2-badge :global(.icon) {
font-size: 22px;
}
.mgv2-honeypot {
position: absolute;
left: -10000px;
top: -10000px;
width: 1px;
height: 1px;
opacity: 0;
}
.mgv2-step {
display: grid;
gap: 14px;
}
.mgv2-eyebrow {
color: var(--mgv2-muted);
font-family: var(--font-head);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.mgv2-heading {
margin: -4px 0 0;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 24px;
font-weight: 800;
line-height: 1.16;
letter-spacing: -0.02em;
text-wrap: balance;
}
.mgv2-helper {
margin: -4px 0 6px;
color: var(--mgv2-muted);
font-size: 14px;
line-height: 1.55;
}
.mgv2-field {
display: grid;
gap: 6px;
}
.mgv2-label {
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mgv2-field input,
.mgv2-field textarea {
width: 100%;
padding: 13px 14px;
border: 1px solid var(--mgv2-line);
border-radius: 12px;
background: #fff;
color: var(--mgv2-ink);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.35;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.mgv2-field textarea {
resize: vertical;
min-height: 120px;
}
.mgv2-field input:focus,
.mgv2-field textarea:focus {
outline: none;
border-color: rgba(11, 11, 11, 0.45);
box-shadow: 0 0 0 4px rgba(255, 199, 0, 0.22);
}
.mgv2-input-invalid,
.mgv2-input-invalid:focus {
border-color: rgba(192, 32, 38, 0.6);
box-shadow: 0 0 0 4px rgba(192, 32, 38, 0.08);
}
.mgv2-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.mgv2-chip {
appearance: none;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--mgv2-line);
background: #fff;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
}
.mgv2-chip:hover {
background: rgba(255, 199, 0, 0.16);
border-color: rgba(255, 199, 0, 0.5);
transform: translateY(-1px);
}
.mgv2-service-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mgv2-service {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px;
border-radius: 14px;
border: 1px solid var(--mgv2-line);
background: #fff;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease;
}
.mgv2-service-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 7px;
border: 1.5px solid var(--mgv2-line);
background: #fff;
color: var(--mgv2-ink);
font-size: 11px;
transition: background 0.18s ease, border-color 0.18s ease;
}
.mgv2-service.active {
border-color: var(--mgv2-ink);
background: rgba(255, 199, 0, 0.14);
}
.mgv2-service.active .mgv2-service-check {
background: var(--mgv2-yellow);
border-color: var(--mgv2-yellow);
}
.mgv2-service:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(255, 199, 0, 0.22);
}
.mgv2-grid-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mgv2-grid-two .mgv2-field:nth-child(3),
.mgv2-grid-two .mgv2-field:nth-child(4) {
grid-column: span 1;
}
.mgv2-error {
color: #b1262d;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.mgv2-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.mgv2-back,
.mgv2-back-spacer {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
cursor: pointer;
border-radius: 999px;
transition: background 0.18s ease;
}
.mgv2-back-spacer {
visibility: hidden;
cursor: default;
}
.mgv2-back:hover {
background: rgba(11, 11, 11, 0.06);
}
.mgv2-next {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 10px 22px;
border: none;
border-radius: 999px;
background: var(--mgv2-yellow);
color: var(--mgv2-ink);
font-family: var(--font-head);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.01em;
cursor: pointer;
box-shadow:
inset 0 -2px 0 rgba(11, 11, 11, 0.08),
0 12px 24px rgba(255, 199, 0, 0.28);
transition:
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.18s ease,
filter 0.18s ease;
}
.mgv2-next:hover {
transform: translateY(-1px);
filter: brightness(1.02);
}
.mgv2-next:disabled {
opacity: 0.7;
cursor: progress;
}
.mgv2-footnote {
margin: 32px auto 0;
max-width: 360px;
color: var(--mgv2-muted);
font-size: 13px;
line-height: 1.55;
}
@media (max-width: 600px) {
.mgv2-page {
padding: 36px 16px 64px;
}
.mgv2-stack {
max-width: 100%;
}
.mgv2-decoy {
display: none;
}
.mgv2-card {
padding: 30px 20px 22px;
}
.mgv2-badge {
width: 48px;
height: 48px;
top: -16px;
right: 14px;
}
.mgv2-badge :global(.icon) {
font-size: 18px;
}
.mgv2-heading {
font-size: 22px;
}
.mgv2-service-grid,
.mgv2-grid-two {
grid-template-columns: 1fr;
}
.mgv2-next {
padding: 10px 18px;
font-size: 13px;
}
}
</style>