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

447 lines
15 KiB
Svelte
Raw Normal View History

2026-05-02 08:26:18 +12:00
<script lang="ts">
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;
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;
2026-05-02 09:43:32 +12:00
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 '';
}
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.';
$: 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';
2026-05-02 09:43:32 +12:00
const emailError = validateEmail(email);
if (emailError) next.email = emailError;
2026-05-02 08:26:18 +12:00
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;
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({
fullName, email, phone, petName, location, message,
services: selectedServices,
referrer: document.referrer,
page: window.location.href,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
2026-05-02 09:43:32 +12:00
const detail = typeof body?.detail === 'string'
? body.detail
: body?.detail?.message ?? body?.message ?? `Server responded with ${res.status}`;
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-02 09:43:32 +12:00
<section id="newlead" use:reveal={{ delay: 70 }} class="reveal-block">
2026-05-02 08:26:18 +12:00
<div class="form-inner">
{#if submitted}
<SuccessModal
firstName={fullName.split(' ')[0]}
{petName}
{email}
onClose={() => (submitted = false)}
/>
{/if}
2026-05-02 09:43:32 +12:00
{#if showErrorModal}
<ErrorModal
detail={submitErrorDetail}
onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)}
/>
{/if}
2026-05-02 08:26:18 +12:00
<div class="booking-header">
<h2 class="booking-title">
<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>
<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')}
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>
{#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>