Files
gw-svelte/src/lib/components/BookingSection.svelte
T
2026-05-07 21:47:42 +12:00

812 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
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';
export let booking: BookingContent;
export let allowGeneralEnquiry = false;
type EnquiryType = 'booking' | 'general';
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const requestedServiceStorageKey = 'goodwalk_requested_service';
const maxJourneyEntries = 8;
const servicePrompts: Record<
string,
{
intro: string;
messageLabel: string;
messagePlaceholder: string;
}
> = {
'Pack Walks': {
intro:
'Tell us about your dog, your area, and how they feel around other dogs so we can see if Pack Walks are the right fit.',
messageLabel: 'Pack Walks fit',
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.'
}
};
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
let enquiryType: EnquiryType = 'booking';
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[] = [];
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;
let showErrorModal = false;
let submitErrorDetail = '';
function validateEmail(raw: string): string {
const value = raw.trim();
if (!value) return 'Please enter your email address';
if (!value.includes('@')) return 'Email is missing the @ sign';
const [local, ...domainParts] = value.split('@');
const domain = domainParts.join('@');
if (domainParts.length > 1) return 'Email can only contain one @ sign';
if (!local) return 'Please add the part before the @';
if (!domain) return 'Please add a domain after the @, like @gmail.com';
if (!domain.includes('.')) return 'Please include a domain ending, like @gmail.com';
const tld = domain.split('.').pop() ?? '';
if (tld.length < 2) return 'That domain ending looks too short';
if (/\s/.test(value)) return 'Email cannot contain spaces';
const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
if (!re.test(value)) return 'That email doesnt look quite right';
return '';
}
const defaultDogIntro =
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.';
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.';
$: primarySelectedService = selectedServices[0] ?? '';
$: activeServicePrompt = servicePrompts[primarySelectedService];
$: dogIntro = activeServicePrompt?.intro || booking.dogIntro?.trim() || defaultDogIntro;
$: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
$: hasServices = booking.serviceOptions.length > 0;
$: if (!allowGeneralEnquiry && enquiryType === 'general') {
enquiryType = 'booking';
}
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
$: ownerIntro = isGeneralEnquiry
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
: booking.subtitle;
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
$: detailsStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: detailsStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: 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.';
$: successPetName = petName.trim() || 'your dog';
onMount(() => {
const now = Date.now();
formStartedAt = now;
pageEnteredAt = now;
visitStartedAt = readOrCreateVisitStartedAt(now);
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
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);
};
});
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 = {};
}
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];
try {
window.sessionStorage.removeItem(requestedServiceStorageKey);
} catch {
// Ignore storage cleanup failures.
}
}
function toggleService(service: string, checked: boolean) {
noteInteraction();
if (checked) {
selectedServices = [service, ...selectedServices.filter((item) => item !== service)];
return;
}
selectedServices = selectedServices.filter((item) => item !== service);
}
function setEnquiryType(nextType: EnquiryType) {
noteInteraction();
enquiryType = nextType;
if (nextType === 'general') {
petName = '';
location = '';
selectedServices = [];
}
errors = {};
}
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 {
const next: Record<string, string> = {};
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';
}
errors = next;
if (next.petName) {
petNameInput?.focus();
return false;
}
if (next.location) {
locationInput?.focus();
return false;
}
if (next.message) {
return false;
}
return true;
}
function goToOwnerStep() {
noteInteraction();
if (!validateDetailsStep()) return;
setStep(2, true);
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (step === 1) {
goToOwnerStep();
return;
}
if (!validateOwnerStep()) {
return;
}
errors = {};
noteInteraction();
sendClickedAt = Date.now();
submitting = true;
submitErrorDetail = '';
showErrorModal = false;
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enquiryType,
fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
referrer: document.referrer,
page: window.location.href
})
});
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>
<section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
<div class="form-inner">
{#if submitted}
<SuccessModal
firstName={fullName.split(' ')[0]}
petName={successPetName}
{email}
{enquiryType}
onClose={() => (submitted = false)}
/>
{/if}
{#if showErrorModal}
<ErrorModal
detail={submitErrorDetail}
{enquiryType}
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
<div class="booking-header">
<span class="booking-eyebrow">{bookingEyebrow}</span>
<h2 class="booking-title">
<span class="booking-title-plain">{headingParts.plain}</span>{' '}
<span class="booking-title-highlight">{headingParts.highlight}</span>
</h2>
<p class="booking-intro">{bookingIntro}</p>
<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>
<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>
<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>
{#if step === 1}
<input type="hidden" name="enquiryType" value={enquiryType} />
<div class="booking-panel">
{#if detailsStepIntro}
<div class="booking-panel-banner">{detailsStepIntro}</div>
{/if}
<div
class:booking-card-grid-with-banner={Boolean(detailsStepIntro)}
class="booking-card-grid booking-card-grid-dog"
>
{#if allowGeneralEnquiry}
<div class="booking-field-card booking-field-card-full">
<label>
<Icon name="fas fa-comments" />&nbsp;Enquiry type
</label>
<div class="booking-toggle-group" role="radiogroup" aria-label="Enquiry type">
<label class="booking-toggle-option">
<input
type="radio"
name="enquiryType"
value="booking"
checked={enquiryType === 'booking'}
on:change={() => setEnquiryType('booking')}
/>
<span class="booking-toggle-indicator" aria-hidden="true"></span>
<span>Book a Meet &amp; Greet</span>
</label>
<label class="booking-toggle-option">
<input
type="radio"
name="enquiryType"
value="general"
checked={enquiryType === 'general'}
on:change={() => setEnquiryType('general')}
/>
<span class="booking-toggle-indicator" aria-hidden="true"></span>
<span>General enquiry</span>
</label>
</div>
<p class="booking-help-text">
General enquiries cover feedback, complaints, business enquiries, and other non-booking messages.
</p>
</div>
{/if}
{#if !isGeneralEnquiry}
<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>
<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>
{#if hasServices}
<div class="booking-field-stack booking-field-stack-full">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<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>
</div>
{/if}
</div>
</div>
{/if}
{#if isGeneralEnquiry}
<div
class="booking-field-card booking-field-card-full"
class:booking-field-card-invalid={errors.message}
>
<label for="message">
<Icon name="fas fa-comment" />&nbsp;{detailsMessageLabel}
<span class="booking-required">*</span>
</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>
{/if}
</div>
</div>
<div class="booking-actions booking-actions-next">
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToOwnerStep}>
Next: {ownerStepLabel.toLowerCase()}
<Icon name="fas fa-arrow-right" />
</button>
<p class="booking-next-note">No payment, no pressure, just the right starting point for your dog.</p>
</div>
{:else}
<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} />
<input type="hidden" name="message" value={message} />
<div class="booking-panel">
{#if ownerIntro}
<div class="booking-panel-banner">{ownerIntro}</div>
{/if}
<div
class:booking-card-grid-with-banner={Boolean(ownerIntro)}
class="booking-card-grid booking-card-grid-owner"
>
<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')}
on:blur={() => {
if (!email.trim()) return;
const msg = validateEmail(email);
errors = { ...errors, email: msg };
}}
/>
{#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)}
>
Back
</button>
<button type="submit" class="btn btn-yellow booking-submit-button" disabled={submitting}>
{#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}
</button>
</div>
{/if}
</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;
}
</style>