Files
gw-svelte/src/lib/components/BookingSection.svelte
T

1397 lines
43 KiB
Svelte
Raw Normal View History

2026-05-02 08:26:18 +12:00
<script lang="ts">
import { onMount } from 'svelte';
2026-05-02 08:26:18 +12:00
import Icon from '$lib/components/Icon.svelte';
import SuccessModal from '$lib/components/SuccessModal.svelte';
2026-05-02 09:43:32 +12:00
import ErrorModal from '$lib/components/ErrorModal.svelte';
2026-05-02 08:26:18 +12:00
import { reveal } from '$lib/actions/reveal';
import type { BookingContent } from '$lib/types';
export let booking: BookingContent;
2026-05-04 20:32:24 +12:00
export let allowGeneralEnquiry = false;
2026-05-15 16:27:39 +12:00
export let variant: 'default' | 'minimal-premium' | 'card-stepper' = 'default';
2026-05-04 20:32:24 +12:00
type EnquiryType = 'booking' | 'general';
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
2026-05-07 21:47:42 +12:00
const requestedServiceStorageKey = 'goodwalk_requested_service';
const maxJourneyEntries = 8;
2026-05-07 21:47:42 +12:00
const servicePrompts: Record<
string,
{
intro: string;
messageLabel: string;
messagePlaceholder: string;
}
> = {
2026-05-15 01:28:10 +12:00
'Tiny Gang Pack Walks': {
2026-05-07 21:47:42 +12:00
intro:
2026-05-15 01:28:10 +12:00
'Tell us about your dog, your area, and how they feel around other dogs so we can see if Tiny Gang Pack Walks are the right fit.',
messageLabel: 'Tiny Gang Pack Walks fit',
2026-05-07 21:47:42 +12:00
messagePlaceholder:
'How old is your dog, how do they feel in groups, and is there anything about confidence, recall, or social behaviour we should know?'
},
'1:1 Walks': {
intro:
'Tell us about your dog, your area, and what you want from one-on-one walks so we can plan the right routine.',
messageLabel: '1:1 walk needs',
messagePlaceholder:
'Tell us about your dogs size, pace, leash manners, confidence, and anything else that would help us tailor a one-on-one walk.'
},
'Puppy Visits': {
intro:
'Tell us about your puppy, your area, and the kind of support you need at home so we can plan the right visit.',
messageLabel: 'Puppy visit details',
messagePlaceholder:
'Tell us your puppys age, routine, toilet needs, feeding schedule, and anything important we should know before visiting.'
}
};
2026-05-15 16:27:39 +12:00
const bookingAvatarDogs = [
{ image: '/images/archie-auckland-dog-walking-review.jpg', alt: 'Archie' },
{ image: '/images/monty-auckland-dog-walking-review.jpg', alt: 'Monty' },
{ image: '/images/otis-auckland-dog-walking-review.jpg', alt: 'Otis' }
];
2026-05-02 08:26:18 +12:00
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
2026-05-04 20:32:24 +12:00
let enquiryType: EnquiryType = 'booking';
2026-05-02 08:26:18 +12:00
let fullName = '';
let email = '';
let phone = '';
let petName = '';
let location = '';
let message = '';
let selectedServices: string[] = [];
let website = '';
let formStartedAt = 0;
let visitStartedAt = 0;
let pageEnteredAt = 0;
let firstInteractionAt = 0;
let sendClickedAt = 0;
let stepChanges = 0;
let journey: string[] = [];
2026-05-02 08:26:18 +12:00
let fullNameInput: HTMLInputElement;
let emailInput: HTMLInputElement;
let phoneInput: HTMLInputElement;
let petNameInput: HTMLInputElement;
let locationInput: HTMLInputElement;
let errors: Record<string, string> = {};
let submitting = false;
let submitted = false;
2026-05-02 09:43:32 +12:00
let showErrorModal = false;
let submitErrorDetail = '';
2026-05-15 01:28:10 +12:00
let showServicePicker = false;
2026-05-02 09:43:32 +12:00
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 '';
}
2026-05-02 08:26:18 +12:00
const defaultDogIntro =
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.';
2026-05-04 20:32:24 +12:00
const defaultGeneralIntro =
'Need to send feedback, make a complaint, or ask a business question? Choose general enquiry and tell us what you need.';
const defaultGeneralSubtitle =
'Almost there — just your contact details so we can reply properly to your message.';
2026-05-02 08:26:18 +12:00
2026-05-07 21:47:42 +12:00
$: primarySelectedService = selectedServices[0] ?? '';
$: activeServicePrompt = servicePrompts[primarySelectedService];
$: dogIntro = activeServicePrompt?.intro || booking.dogIntro?.trim() || defaultDogIntro;
2026-05-04 20:32:24 +12:00
$: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
2026-05-02 08:26:18 +12:00
$: hasServices = booking.serviceOptions.length > 0;
2026-05-04 20:32:24 +12:00
$: if (!allowGeneralEnquiry && enquiryType === 'general') {
enquiryType = 'booking';
}
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
2026-05-06 11:36:19 +12:00
$: ownerIntro = isGeneralEnquiry
2026-05-04 20:32:24 +12:00
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
: booking.subtitle;
2026-05-02 08:26:18 +12:00
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
2026-05-06 11:36:19 +12:00
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
2026-05-07 21:47:42 +12:00
$: bookingEyebrow = isGeneralEnquiry ? 'Friendly contact' : primarySelectedService || 'Free Meet & Greet';
$: bookingIntro = isGeneralEnquiry
? 'Send us the details and well point you in the right direction.'
: 'Tell us a little about your dog first. Well come back within 24 hours with the right next step.';
$: detailsMessageLabel = isGeneralEnquiry
? 'Your Message'
: activeServicePrompt?.messageLabel || 'About Your Dog';
$: detailsMessagePlaceholder = isGeneralEnquiry
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
: activeServicePrompt?.messagePlaceholder || 'Describe your pet, any special needs, or anything we should know.';
2026-05-04 20:32:24 +12:00
$: successPetName = petName.trim() || 'your dog';
2026-05-15 01:28:10 +12:00
$: serviceChoiceLocked = !isGeneralEnquiry && selectedServices.length === 1 && !showServicePicker;
2026-05-15 16:27:39 +12:00
$: isCardStepper = variant === 'card-stepper';
2026-05-02 08:26:18 +12:00
onMount(() => {
const now = Date.now();
formStartedAt = now;
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
2026-05-07 21:47:42 +12:00
applyRequestedService();
const handleRequestedService = (event: Event) => {
const customEvent = event as CustomEvent<{ service?: string }>;
applyRequestedService(customEvent.detail?.service?.trim());
};
window.addEventListener('goodwalk:service-selected', handleRequestedService as EventListener);
return () => {
window.removeEventListener('goodwalk:service-selected', handleRequestedService as EventListener);
};
});
2026-05-02 08:26:18 +12:00
function splitBookingTitle(title: string) {
const trimmed = title.trim();
const lastSpace = trimmed.lastIndexOf(' ');
if (lastSpace === -1) {
return { plain: trimmed, highlight: '' };
}
return {
plain: trimmed.slice(0, lastSpace),
highlight: trimmed.slice(lastSpace + 1)
};
}
function clearError(field: string) {
if (errors[field]) {
errors = { ...errors, [field]: '' };
}
}
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];
}
}
function noteInteraction() {
if (!firstInteractionAt) {
firstInteractionAt = Date.now();
}
}
function setStep(nextStep: number, trackTransition = false) {
if (step !== nextStep && trackTransition) {
stepChanges += 1;
}
step = nextStep;
errors = {};
}
2026-05-07 21:47:42 +12:00
function applyRequestedService(service?: string) {
const requestedService =
service ||
(() => {
try {
return window.sessionStorage.getItem(requestedServiceStorageKey)?.trim() || '';
} catch {
return '';
}
})();
if (!requestedService || !booking.serviceOptions.includes(requestedService)) {
return;
}
selectedServices = [requestedService];
2026-05-15 01:28:10 +12:00
showServicePicker = false;
2026-05-07 21:47:42 +12:00
try {
window.sessionStorage.removeItem(requestedServiceStorageKey);
} catch {
// Ignore storage cleanup failures.
}
}
function sortSelectedServices(services: string[]) {
return [...services].sort((a, b) => {
const indexA = booking.serviceOptions.indexOf(a);
const indexB = booking.serviceOptions.indexOf(b);
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}
2026-05-02 08:26:18 +12:00
function toggleService(service: string, checked: boolean) {
noteInteraction();
2026-05-02 08:26:18 +12:00
if (checked) {
selectedServices = sortSelectedServices([
...selectedServices.filter((item) => item !== service),
service
]);
2026-05-02 08:26:18 +12:00
return;
}
selectedServices = selectedServices.filter((item) => item !== service);
}
2026-05-04 20:32:24 +12:00
function setEnquiryType(nextType: EnquiryType) {
noteInteraction();
2026-05-04 20:32:24 +12:00
enquiryType = nextType;
if (nextType === 'general') {
petName = '';
location = '';
selectedServices = [];
2026-05-15 01:28:10 +12:00
showServicePicker = false;
2026-05-04 20:32:24 +12:00
}
errors = {};
}
2026-05-06 11:36:19 +12:00
function validateOwnerStep(): boolean {
const next: Record<string, string> = {};
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';
errors = next;
if (next.fullName) {
fullNameInput?.focus();
return false;
}
if (next.email) {
emailInput?.focus();
return false;
}
if (next.phone) {
phoneInput?.focus();
return false;
}
return true;
}
function validateDetailsStep(): boolean {
2026-05-02 08:26:18 +12:00
const next: Record<string, string> = {};
2026-05-04 20:32:24 +12:00
if (isGeneralEnquiry) {
if (!message.trim()) next.message = 'Please tell us how we can help';
} else {
if (!petName.trim()) next.petName = "Please enter your dog's name";
if (!location.trim()) next.location = 'Please enter your location';
}
2026-05-02 08:26:18 +12:00
errors = next;
2026-05-04 20:32:24 +12:00
if (next.petName) {
petNameInput?.focus();
return false;
}
if (next.location) {
locationInput?.focus();
return false;
}
if (next.message) {
return false;
}
2026-05-02 08:26:18 +12:00
return true;
}
2026-05-03 11:49:59 +12:00
function goToOwnerStep() {
noteInteraction();
2026-05-06 11:36:19 +12:00
if (!validateDetailsStep()) return;
setStep(2, true);
2026-05-02 08:26:18 +12:00
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (step === 1) {
2026-05-03 11:49:59 +12:00
goToOwnerStep();
2026-05-02 08:26:18 +12:00
return;
}
2026-05-06 11:36:19 +12:00
if (!validateOwnerStep()) {
2026-05-02 08:26:18 +12:00
return;
}
2026-05-06 11:36:19 +12:00
errors = {};
noteInteraction();
sendClickedAt = Date.now();
2026-05-02 08:26:18 +12:00
submitting = true;
2026-05-02 09:43:32 +12:00
submitErrorDetail = '';
showErrorModal = false;
2026-05-02 08:26:18 +12:00
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
2026-05-06 11:36:19 +12:00
enquiryType,
fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
2026-05-06 11:36:19 +12:00
referrer: document.referrer,
page: window.location.href
})
2026-05-02 08:26:18 +12:00
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
2026-05-06 11:36:19 +12:00
const detail =
typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
2026-05-02 09:43:32 +12:00
throw new Error(detail);
2026-05-02 08:26:18 +12:00
}
submitted = true;
} catch (err: unknown) {
2026-05-02 09:43:32 +12:00
submitErrorDetail = err instanceof Error ? err.message : String(err);
showErrorModal = true;
2026-05-02 08:26:18 +12:00
} finally {
submitting = false;
}
}
</script>
2026-05-15 16:27:39 +12:00
<section
id="newlead"
use:reveal={{ delay: 70 }}
class="booking-shell reveal-block"
class:booking-shell--minimal-premium={variant === 'minimal-premium'}
class:booking-shell--card-stepper={variant === 'card-stepper'}
>
2026-05-02 08:26:18 +12:00
<div class="form-inner">
{#if submitted}
<SuccessModal
firstName={fullName.split(' ')[0]}
2026-05-04 20:32:24 +12:00
petName={successPetName}
2026-05-02 08:26:18 +12:00
{email}
2026-05-04 20:32:24 +12:00
{enquiryType}
2026-05-02 08:26:18 +12:00
onClose={() => (submitted = false)}
/>
{/if}
2026-05-02 09:43:32 +12:00
{#if showErrorModal}
<ErrorModal
detail={submitErrorDetail}
2026-05-04 20:32:24 +12:00
{enquiryType}
2026-05-02 09:43:32 +12:00
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
2026-05-02 08:26:18 +12:00
<div class="booking-header">
2026-05-15 01:28:10 +12:00
<span class="eyebrow booking-eyebrow">{bookingEyebrow}</span>
2026-05-02 08:26:18 +12:00
<h2 class="booking-title">
2026-05-06 11:36:19 +12:00
<span class="booking-title-plain">{headingParts.plain}</span>{' '}
<span class="booking-title-highlight">{headingParts.highlight}</span>
2026-05-02 08:26:18 +12:00
</h2>
2026-05-07 21:47:42 +12:00
<p class="booking-intro">{bookingIntro}</p>
2026-05-15 16:27:39 +12:00
{#if !isCardStepper}
<div class="booking-trust-row" aria-label="Booking highlights">
<span class="booking-trust-chip">
<Icon name="fas fa-comment-dots" />
Reply within 24 hours
</span>
<span class="booking-trust-chip">
<Icon name="fas fa-paw" />
Free, no-obligation Meet &amp; Greet
</span>
</div>
2026-05-02 08:26:18 +12:00
2026-05-15 16:27:39 +12:00
<div class="booking-stepper" aria-label="Booking form steps">
<button
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => setStep(1, step !== 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{detailsStepLabel}</span>
</button>
<span class="booking-step-divider" aria-hidden="true"></span>
<button
type="button"
class:active={step === 2}
class="booking-step"
on:click={goToOwnerStep}
>
<span class="booking-step-number">2</span>
<span class="booking-step-label">{ownerStepLabel}</span>
</button>
</div>
{/if}
2026-05-02 08:26:18 +12:00
</div>
<form
class="booking-form"
id="bookingForm"
novalidate
on:submit={handleSubmit}
on:focusin={noteInteraction}
on:input={noteInteraction}
on:change={noteInteraction}
>
<div class="booking-honeypot" aria-hidden="true">
<label for="website">Website</label>
<input
bind:value={website}
type="text"
id="website"
name="website"
tabindex="-1"
autocomplete="new-password"
/>
</div>
2026-05-15 16:27:39 +12:00
<div class:booking-form-shell={isCardStepper}>
{#if isCardStepper}
<div class="booking-social-proof" aria-label="Goodwalk dog families">
<div class="booking-avatar-group" aria-hidden="true">
{#each bookingAvatarDogs as dog}
<span class="booking-avatar-bubble">
<img src={dog.image} alt="" loading="lazy" />
</span>
{/each}
</div>
<p>Join other happy Goodwalk dog owners across Auckland.</p>
</div>
<div class="booking-form-shell-top">
<div class="booking-stepper" aria-label="Booking form steps">
<button
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => setStep(1, step !== 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{detailsStepLabel}</span>
</button>
<span class="booking-step-divider" aria-hidden="true"></span>
<button
type="button"
class:active={step === 2}
class="booking-step"
on:click={goToOwnerStep}
>
<span class="booking-step-number">2</span>
<span class="booking-step-label">{ownerStepLabel}</span>
</button>
</div>
</div>
{/if}
2026-05-02 08:26:18 +12:00
{#if step === 1}
2026-05-06 11:36:19 +12:00
<input type="hidden" name="enquiryType" value={enquiryType} />
2026-05-03 11:49:59 +12:00
<div class="booking-panel">
2026-05-15 16:27:39 +12:00
{#if detailsStepIntro && !isCardStepper}
2026-05-06 11:36:19 +12:00
<div class="booking-panel-banner">{detailsStepIntro}</div>
2026-05-03 11:49:59 +12:00
{/if}
2026-05-06 11:36:19 +12:00
<div
2026-05-15 16:27:39 +12:00
class:booking-card-grid-with-banner={Boolean(detailsStepIntro) && !isCardStepper}
2026-05-06 11:36:19 +12:00
class="booking-card-grid booking-card-grid-dog"
>
2026-05-15 01:28:10 +12:00
{#if allowGeneralEnquiry && !isGeneralEnquiry}
<div class="booking-inline-switch booking-field-card-full">
<span>Need help with something else?</span>
<button type="button" class="booking-inline-link" on:click={() => setEnquiryType('general')}>
Send a general enquiry
</button>
2026-05-04 20:32:24 +12:00
</div>
{/if}
{#if !isGeneralEnquiry}
2026-05-07 21:47:42 +12:00
<div class="booking-field-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-dog">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Dog's Name <span class="booking-required">*</span>
</label>
<input
bind:this={petNameInput}
bind:value={petName}
type="text"
id="petName"
name="petName"
required
placeholder="Your dog's name"
class:input-invalid={errors.petName}
on:input={() => clearError('petName')}
/>
{#if errors.petName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.petName}
</p>
{/if}
</div>
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.location}>
<label for="location">
<Icon name="fas fa-location-dot" />&nbsp;Location <span class="booking-required">*</span>
</label>
<input
bind:this={locationInput}
bind:value={location}
type="text"
id="location"
name="location"
required
placeholder="Suburb, street..."
class:input-invalid={errors.location}
on:input={() => clearError('location')}
/>
{#if errors.location}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.location}
</p>
{/if}
</div>
2026-05-15 01:28:10 +12:00
{#if hasServices}
<div class="booking-field-stack booking-field-stack-full">
<div class="booking-selected-service-row">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Service</span>
{#if serviceChoiceLocked}
<button type="button" class="booking-inline-link" on:click={() => (showServicePicker = true)}>
Change
</button>
{/if}
</div>
{#if serviceChoiceLocked}
<div class="booking-selected-service-chip">{selectedServices[0]}</div>
{:else}
<div class="booking-service-options">
{#each booking.serviceOptions as service}
<label class="booking-check-option">
<input
type="checkbox"
name="services"
value={service}
checked={selectedServices.includes(service)}
on:change={(event) =>
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
/>
<span class="booking-check-box" aria-hidden="true"></span>
<span>{service}</span>
</label>
{/each}
</div>
{/if}
</div>
{/if}
2026-05-07 21:47:42 +12:00
<div
class="booking-field-stack booking-field-stack-full"
class:booking-field-stack-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={detailsMessagePlaceholder}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</p>
{/if}
</div>
</div>
2026-05-04 20:32:24 +12:00
</div>
2026-05-07 21:47:42 +12:00
{/if}
2026-05-03 11:49:59 +12:00
2026-05-07 21:47:42 +12:00
{#if isGeneralEnquiry}
2026-05-15 01:28:10 +12:00
<div class="booking-inline-switch booking-field-card-full">
<span>Want to book a Meet &amp; Greet instead?</span>
<button type="button" class="booking-inline-link" on:click={() => setEnquiryType('booking')}>
Switch back to booking
</button>
</div>
2026-05-06 11:36:19 +12:00
<div
2026-05-07 21:47:42 +12:00
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
2026-05-06 11:36:19 +12:00
>
2026-05-07 21:47:42 +12:00
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
<span class="booking-required">*</span>
2026-05-04 20:32:24 +12:00
</label>
2026-05-07 21:47:42 +12:00
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder={detailsMessagePlaceholder}
class:input-invalid={errors.message}
on:input={() => clearError('message')}
></textarea>
{#if errors.message}
2026-05-04 20:32:24 +12:00
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
2026-05-07 21:47:42 +12:00
{errors.message}
2026-05-04 20:32:24 +12:00
</p>
{/if}
</div>
{/if}
2026-05-03 11:49:59 +12:00
</div>
</div>
<div class="booking-actions booking-actions-next">
2026-05-15 01:28:10 +12:00
<button type="button" class="btn btn-yellow btn-with-arrow booking-next-button" on:click={goToOwnerStep}>
2026-05-07 21:47:42 +12:00
Next: {ownerStepLabel.toLowerCase()}
2026-05-03 11:49:59 +12:00
<Icon name="fas fa-arrow-right" />
</button>
2026-05-07 21:47:42 +12:00
<p class="booking-next-note">No payment, no pressure, just the right starting point for your dog.</p>
2026-05-03 11:49:59 +12:00
</div>
{:else}
2026-05-06 11:36:19 +12:00
<input type="hidden" name="fullName" value={fullName} />
<input type="hidden" name="email" value={email} />
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} />
2026-05-03 11:49:59 +12:00
<input type="hidden" name="message" value={message} />
2026-05-02 08:26:18 +12:00
<div class="booking-panel">
2026-05-15 16:27:39 +12:00
{#if ownerIntro && !isCardStepper}
2026-05-06 11:36:19 +12:00
<div class="booking-panel-banner">{ownerIntro}</div>
2026-05-02 08:26:18 +12:00
{/if}
2026-05-06 11:36:19 +12:00
<div
2026-05-15 16:27:39 +12:00
class:booking-card-grid-with-banner={Boolean(ownerIntro) && !isCardStepper}
2026-05-06 11:36:19 +12:00
class="booking-card-grid booking-card-grid-owner"
>
2026-05-02 08:26:18 +12:00
<div class="booking-field-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-owner">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
<label for="fullName">
<Icon name="fas fa-user" />&nbsp;Full Name <span class="booking-required">*</span>
</label>
<input
bind:this={fullNameInput}
bind:value={fullName}
type="text"
id="fullName"
name="fullName"
required
placeholder="Enter full name"
class:input-invalid={errors.fullName}
on:input={() => clearError('fullName')}
/>
{#if errors.fullName}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.fullName}
</p>
{/if}
</div>
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.email}>
<label for="email">
<Icon name="fas fa-envelope" />&nbsp;Email <span class="booking-required">*</span>
</label>
<input
bind:this={emailInput}
bind:value={email}
type="email"
id="email"
name="email"
required
placeholder="Email"
class:input-invalid={errors.email}
on:input={() => clearError('email')}
2026-05-02 09:43:32 +12:00
on:blur={() => {
if (!email.trim()) return;
const msg = validateEmail(email);
errors = { ...errors, email: msg };
}}
2026-05-02 08:26:18 +12:00
/>
{#if errors.email}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.email}
</p>
{/if}
</div>
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.phone}>
<label for="phone">
<Icon name="fas fa-phone" />&nbsp;Contact # <span class="booking-required">*</span>
</label>
<input
bind:this={phoneInput}
bind:value={phone}
type="tel"
id="phone"
name="phone"
required
placeholder="E.g. 021 1234567"
class:input-invalid={errors.phone}
on:input={() => clearError('phone')}
/>
{#if errors.phone}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.phone}
</p>
{/if}
</div>
</div>
</div>
</div>
</div>
<div class="booking-actions booking-actions-final">
<button
type="button"
class="btn btn-outline btn-outline-green"
on:click={() => setStep(1, true)}
2026-05-02 08:26:18 +12:00
>
Back
</button>
2026-05-15 01:28:10 +12:00
<button type="submit" class="btn btn-yellow btn-with-arrow booking-submit-button" disabled={submitting}>
2026-05-07 21:47:42 +12:00
{#if submitting}
Sending…
{:else if isGeneralEnquiry}
Send enquiry <Icon name="fas fa-arrow-right" />
{:else}
Request Meet &amp; Greet <Icon name="fas fa-arrow-right" />
{/if}
2026-05-02 08:26:18 +12:00
</button>
</div>
{/if}
2026-05-15 16:27:39 +12:00
</div>
2026-05-02 08:26:18 +12:00
</form>
</div>
</section>
<style>
:global(.reveal-ready.reveal-block) {
opacity: 0;
transform: translate3d(0, var(--reveal-distance, 24px), 0);
transition:
opacity 0.55s ease,
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--reveal-delay, 0ms);
}
:global(.reveal-visible.reveal-block) {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.booking-honeypot {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
2026-05-15 16:27:39 +12:00
.booking-shell--card-stepper {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(248, 247, 242, 0.9));
}
.booking-shell--card-stepper :global(.form-inner) {
max-width: 1040px;
}
.booking-shell--card-stepper .booking-header {
max-width: 840px;
margin: 0 auto 34px;
}
.booking-shell--card-stepper .booking-eyebrow {
padding: 7px 14px;
background: rgba(33, 48, 33, 0.05);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
text-transform: none;
letter-spacing: 0.04em;
}
.booking-shell--card-stepper .booking-title {
margin-bottom: 20px;
font-size: clamp(38px, 5vw, 58px);
line-height: 1.02;
}
.booking-shell--card-stepper .booking-intro {
max-width: 42rem;
margin: 0 auto;
}
.booking-shell--card-stepper .booking-form-shell {
max-width: 900px;
margin: 0 auto;
padding: 0 24px 28px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.94);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 22px 52px rgba(17, 20, 24, 0.08);
}
.booking-shell--card-stepper .booking-social-proof {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 0 -24px;
padding: 16px 24px;
border-radius: 30px 30px 0 0;
background:
linear-gradient(180deg, rgba(33, 48, 33, 0.06), rgba(33, 48, 33, 0.035));
box-shadow: inset 0 -1px 0 rgba(17, 20, 24, 0.08);
}
.booking-shell--card-stepper .booking-form-shell-top {
padding: 22px 0;
border-bottom: 1px solid rgba(17, 20, 24, 0.08);
}
.booking-shell--card-stepper .booking-social-proof p {
margin: 0;
color: var(--gw-green);
font-family: var(--font-head);
font-size: 17px;
font-weight: 600;
line-height: 1.3;
text-wrap: balance;
text-align: center;
}
.booking-shell--card-stepper .booking-avatar-group {
display: flex;
align-items: center;
flex: none;
padding-left: 14px;
}
.booking-shell--card-stepper .booking-avatar-bubble {
display: inline-flex;
width: 50px;
height: 50px;
margin-left: -14px;
overflow: hidden;
border: 3px solid rgba(255, 255, 255, 0.96);
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
box-shadow: 0 8px 18px rgba(17, 20, 24, 0.08);
}
.booking-shell--card-stepper .booking-avatar-bubble img {
width: 100%;
height: 100%;
object-fit: cover;
}
.booking-shell--card-stepper .booking-stepper {
gap: 20px;
margin-top: 24px;
padding: 0;
background: transparent;
box-shadow: none;
}
.booking-shell--card-stepper .booking-step {
min-height: 50px;
padding: 10px 16px;
border-radius: 999px;
}
.booking-shell--card-stepper .booking-step.active {
background: rgba(33, 48, 33, 0.06);
}
.booking-shell--card-stepper .booking-step-number {
width: 42px;
height: 42px;
border: none;
border-radius: 13px;
background: var(--gw-green);
color: var(--yellow);
font-size: 18px;
}
.booking-shell--card-stepper .booking-step.active .booking-step-number {
background: linear-gradient(135deg, var(--gw-green), var(--green-mid));
border: none;
}
.booking-shell--card-stepper .booking-step-divider {
width: 48px;
height: 1px;
background: rgba(17, 20, 24, 0.12);
}
.booking-shell--card-stepper .booking-panel {
max-width: none;
margin: 24px 0 0;
padding: 0;
background: transparent;
box-shadow: none;
}
.booking-shell--card-stepper .booking-panel-banner {
margin-bottom: 18px;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
color: #4f555c;
font-size: 16px;
line-height: 1.65;
}
.booking-shell--card-stepper .booking-card-grid {
gap: 14px;
}
.booking-shell--card-stepper .booking-card-grid-with-banner .booking-field-card {
border-radius: 26px;
}
.booking-shell--card-stepper .booking-field-card {
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 0;
}
.booking-shell--card-stepper .booking-field-card-group {
padding: 0;
}
.booking-shell--card-stepper .booking-inline-switch {
justify-content: flex-start;
padding: 0 4px;
margin-bottom: 0;
color: #5d636a;
background: transparent;
box-shadow: none;
}
.booking-shell--card-stepper .booking-field-card label,
.booking-shell--card-stepper .booking-service-label {
color: #171b20;
font-size: 15px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.booking-shell--card-stepper .booking-field-card input,
.booking-shell--card-stepper .booking-field-card textarea {
border: 1px solid rgba(17, 20, 24, 0.1);
background: #fff;
box-shadow: none;
}
.booking-shell--card-stepper .booking-field-card input:hover,
.booking-shell--card-stepper .booking-field-card textarea:hover {
border-color: rgba(17, 20, 24, 0.18);
background: #fff;
box-shadow: none;
}
.booking-shell--card-stepper .booking-field-card input:focus,
.booking-shell--card-stepper .booking-field-card textarea:focus {
border-color: rgba(33, 48, 33, 0.45);
background: #fff;
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.12);
}
.booking-shell--card-stepper .booking-selected-service-chip,
.booking-shell--card-stepper .booking-check-option {
background: transparent;
}
.booking-shell--card-stepper .booking-actions {
max-width: none;
margin-left: 0;
margin-right: 0;
}
.booking-shell--card-stepper .booking-actions-next,
.booking-shell--card-stepper .booking-actions-final {
margin-top: 18px;
}
.booking-shell--card-stepper .booking-next-note {
color: #687076;
}
.booking-shell--card-stepper .booking-submit-button,
.booking-shell--card-stepper .booking-next-button {
box-shadow:
inset 0 -2px 0 rgba(0, 0, 0, 0.08),
0 12px 24px rgba(17, 20, 24, 0.1);
}
.booking-shell--minimal-premium {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 247, 242, 0.72));
}
.booking-shell--minimal-premium :global(.form-inner) {
max-width: 980px;
}
.booking-shell--minimal-premium .booking-header {
max-width: 760px;
margin: 0 auto 34px;
}
.booking-shell--minimal-premium .booking-eyebrow {
background: rgba(33, 48, 33, 0.06);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
color: var(--gw-green);
}
.booking-shell--minimal-premium .booking-title {
margin-bottom: 18px;
color: #11171b;
}
.booking-shell--minimal-premium .booking-intro {
max-width: 38rem;
margin: 0 auto;
color: #59606d;
font-size: 16px;
line-height: 1.65;
}
.booking-shell--minimal-premium .booking-trust-row {
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.booking-shell--minimal-premium .booking-trust-chip {
background: rgba(33, 48, 33, 0.05);
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
color: var(--gw-green);
}
.booking-shell--minimal-premium .booking-stepper {
justify-content: center;
margin-top: 24px;
}
.booking-shell--minimal-premium .booking-step {
background: rgba(255, 255, 255, 0.86);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
0 8px 18px rgba(17, 20, 24, 0.05);
}
.booking-shell--minimal-premium .booking-step.active {
background: rgba(33, 48, 33, 0.08);
}
.booking-shell--minimal-premium .booking-panel {
max-width: 860px;
margin: 0 auto;
padding: 30px;
border-radius: 32px;
background: rgba(251, 251, 251, 0.92);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 22px 52px rgba(17, 20, 24, 0.08);
}
.booking-shell--minimal-premium .booking-panel-banner {
margin-bottom: 22px;
padding: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: #59606d;
font-size: 15px;
line-height: 1.65;
}
.booking-shell--minimal-premium .booking-card-grid {
gap: 18px;
}
.booking-shell--minimal-premium .booking-field-card,
.booking-shell--minimal-premium .booking-inline-switch {
border-radius: 24px;
background: linear-gradient(180deg, rgba(251, 251, 251, 0.98), rgba(247, 248, 246, 1));
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
0 12px 26px rgba(17, 20, 24, 0.05);
}
.booking-shell--minimal-premium .booking-field-card {
padding: 24px;
}
.booking-shell--minimal-premium .booking-field-card-group {
padding: 24px;
}
.booking-shell--minimal-premium .booking-inline-switch {
padding: 16px 18px;
}
.booking-shell--minimal-premium .booking-field-card label,
.booking-shell--minimal-premium .booking-service-label {
color: #171b20;
}
.booking-shell--minimal-premium .booking-field-card input,
.booking-shell--minimal-premium .booking-field-card textarea {
border: none;
background: rgba(255, 255, 255, 0.96);
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.08),
0 1px 0 rgba(255, 255, 255, 0.3);
}
.booking-shell--minimal-premium .booking-field-card input:hover,
.booking-shell--minimal-premium .booking-field-card textarea:hover {
border: none;
background: #fff;
box-shadow:
inset 0 0 0 1px rgba(17, 20, 24, 0.12),
0 4px 12px rgba(17, 20, 24, 0.04);
}
.booking-shell--minimal-premium .booking-field-card input:focus,
.booking-shell--minimal-premium .booking-field-card textarea:focus {
border: none;
background: #fff;
box-shadow:
inset 0 0 0 2px rgba(255, 209, 0, 0.6),
0 0 0 4px rgba(255, 209, 0, 0.14);
}
.booking-shell--minimal-premium .booking-selected-service-chip,
.booking-shell--minimal-premium .booking-check-option {
background: rgba(255, 255, 255, 0.9);
}
.booking-shell--minimal-premium .booking-actions {
max-width: 860px;
margin-left: auto;
margin-right: auto;
}
.booking-shell--minimal-premium .booking-actions-next {
margin-top: 18px;
}
.booking-shell--minimal-premium .booking-actions-final {
margin-top: 18px;
}
.booking-shell--minimal-premium .booking-next-note {
color: #687076;
}
.booking-shell--minimal-premium .booking-submit-button,
.booking-shell--minimal-premium .booking-next-button {
box-shadow:
inset 0 -2px 0 rgba(0, 0, 0, 0.08),
0 12px 24px rgba(17, 20, 24, 0.1);
}
@media (max-width: 768px) {
.booking-shell--card-stepper .booking-header {
margin-bottom: 26px;
}
.booking-shell--card-stepper .booking-title {
margin-bottom: 18px;
font-size: 34px;
}
.booking-shell--card-stepper .booking-form-shell {
padding: 0 18px 22px;
border-radius: 24px;
}
.booking-shell--card-stepper .booking-social-proof {
align-items: center;
flex-direction: column;
gap: 10px;
margin: 0 -18px;
padding: 14px 18px;
border-radius: 24px 24px 0 0;
}
.booking-shell--card-stepper .booking-form-shell-top {
padding: 18px 0;
}
.booking-shell--card-stepper .booking-social-proof p {
font-size: 16px;
}
.booking-shell--card-stepper .booking-stepper {
gap: 10px;
padding: 0;
}
.booking-shell--card-stepper .booking-step {
min-width: 0;
flex: 1 1 0;
justify-content: center;
gap: 8px;
padding: 10px;
}
.booking-shell--card-stepper .booking-step-number {
width: 38px;
height: 38px;
font-size: 15px;
}
.booking-shell--card-stepper .booking-step-label {
font-size: 13px;
}
.booking-shell--card-stepper .booking-step-divider {
display: none;
}
.booking-shell--card-stepper .booking-panel {
margin-top: 20px;
}
.booking-shell--card-stepper .booking-panel-banner {
font-size: 15px;
}
.booking-shell--card-stepper .booking-field-card,
.booking-shell--card-stepper .booking-field-card-group {
padding: 0;
border-radius: 0;
}
.booking-shell--card-stepper .booking-inline-switch {
border-radius: 20px;
}
.booking-shell--minimal-premium .booking-header {
margin-bottom: 26px;
}
.booking-shell--minimal-premium .booking-intro {
font-size: 15px;
line-height: 1.58;
}
.booking-shell--minimal-premium .booking-stepper {
gap: 8px;
}
.booking-shell--minimal-premium .booking-panel {
padding: 18px;
border-radius: 24px;
}
.booking-shell--minimal-premium .booking-field-card,
.booking-shell--minimal-premium .booking-field-card-group {
padding: 20px;
border-radius: 22px;
}
.booking-shell--minimal-premium .booking-inline-switch {
border-radius: 20px;
}
}
2026-05-02 08:26:18 +12:00
</style>