417 lines
14 KiB
Svelte
417 lines
14 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import Icon from '$lib/components/Icon.svelte';
|
||
|
|
import SuccessModal from '$lib/components/SuccessModal.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 submitError = '';
|
||
|
|
|
||
|
|
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';
|
||
|
|
if (!email.trim() || !emailInput?.checkValidity()) next.email = 'Please enter a valid email address';
|
||
|
|
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;
|
||
|
|
submitError = '';
|
||
|
|
|
||
|
|
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(() => ({}));
|
||
|
|
throw new Error(body.detail ?? 'Something went wrong. Please try again.');
|
||
|
|
}
|
||
|
|
|
||
|
|
submitted = true;
|
||
|
|
} catch (err: unknown) {
|
||
|
|
submitError = err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
||
|
|
} finally {
|
||
|
|
submitting = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<section id="reservation" use:reveal={{ delay: 70 }} class="reveal-block">
|
||
|
|
<div class="form-inner">
|
||
|
|
|
||
|
|
{#if submitted}
|
||
|
|
<SuccessModal
|
||
|
|
firstName={fullName.split(' ')[0]}
|
||
|
|
{petName}
|
||
|
|
{email}
|
||
|
|
onClose={() => (submitted = 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" /> 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" /> 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')}
|
||
|
|
/>
|
||
|
|
{#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" /> 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" /> 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" /> 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" /> 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" /> 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 submitError}
|
||
|
|
<p class="booking-submit-error">
|
||
|
|
<Icon name="fas fa-circle-exclamation" />
|
||
|
|
{submitError}
|
||
|
|
</p>
|
||
|
|
{/if}
|
||
|
|
{/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-submit-error {
|
||
|
|
margin: 16px 0 0;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
color: #c0392b;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
</style>
|