Files
gw-svelte/src/lib/components/BookingSection.svelte
T
2026-05-02 09:43:32 +12:00

447 lines
15 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 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;
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
let fullName = '';
let email = '';
let phone = '';
let petName = '';
let location = '';
let message = '';
let selectedServices: 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.';
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
$: hasBanner = Boolean(booking.subtitle?.trim());
$: hasServices = booking.serviceOptions.length > 0;
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
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 toggleService(service: string, checked: boolean) {
if (checked) {
selectedServices = [...selectedServices, service];
return;
}
selectedServices = selectedServices.filter((item) => item !== service);
}
function validateStepOne(): 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 goToDogStep() {
if (!validateStepOne()) return;
errors = {};
step = 2;
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (step === 1) {
goToDogStep();
return;
}
const next: Record<string, string> = {};
if (!petName.trim()) next.petName = "Please enter your dog's name";
if (!location.trim()) next.location = 'Please enter your location';
if (Object.keys(next).length > 0) {
errors = next;
if (next.petName) petNameInput?.focus();
else if (next.location) locationInput?.focus();
return;
}
submitting = true;
submitErrorDetail = '';
showErrorModal = false;
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullName, email, phone, petName, location, message,
services: selectedServices,
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}
{email}
onClose={() => (submitted = false)}
/>
{/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>
</h2>
<div class="booking-stepper" aria-label="Booking form steps">
<button
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => (step = 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{ownerStepLabel}</span>
</button>
<span class="booking-step-divider" aria-hidden="true"></span>
<button
type="button"
class:active={step === 2}
class="booking-step"
on:click={goToDogStep}
>
<span class="booking-step-number">2</span>
<span class="booking-step-label">{dogStepLabel}</span>
</button>
</div>
</div>
<form
class="booking-form"
id="bookingForm"
novalidate
on:submit={handleSubmit}
>
{#if step === 1}
<div class="booking-panel">
{#if hasBanner}
<div class="booking-panel-banner">{booking.subtitle}</div>
{/if}
<div class:booking-card-grid-with-banner={hasBanner} 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>
{#if hasServices}
<div class="booking-service-row">
<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 class="booking-actions booking-actions-next">
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToDogStep}>
{dogStepLabel}
<Icon name="fas fa-arrow-right" />
</button>
</div>
{:else}
<input type="hidden" name="fullName" value={fullName} />
<input type="hidden" name="email" value={email} />
<input type="hidden" name="phone" value={phone} />
{#each selectedServices as service}
<input type="hidden" name="services" value={service} />
{/each}
<div class="booking-panel">
{#if dogIntro}
<div class="booking-panel-banner">{dogIntro}</div>
{/if}
<div class:booking-card-grid-with-banner={Boolean(dogIntro)} class="booking-card-grid booking-card-grid-dog">
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
<label for="petName">
<Icon name="fas fa-dog" />&nbsp;Pet'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-card booking-field-card-wide" class:booking-field-card-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="Neighborhood, 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-card booking-field-card-full">
<label for="message"><Icon name="fas fa-comment" />&nbsp;About Your Dog</label>
<textarea
bind:value={message}
id="message"
name="message"
rows="4"
placeholder="Describe your pet, any special needs, or anything we should know."
></textarea>
</div>
</div>
</div>
<div class="booking-actions booking-actions-final">
<button
type="button"
class="btn btn-outline btn-outline-green"
on:click={() => { step = 1; errors = {}; }}
>
Back
</button>
<button type="submit" class="btn btn-yellow booking-submit-button" disabled={submitting}>
{#if submitting}Sending…{:else}Send <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);
}
</style>