812 lines
27 KiB
Svelte
812 lines
27 KiB
Svelte
<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 dog’s 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 puppy’s 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 doesn’t 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 we’ll point you in the right direction.'
|
||
: 'Tell us a little about your dog first. We’ll 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 & 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" /> 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 & 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" /> 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" /> 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" /> {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" /> 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" /> {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" /> 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')}
|
||
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" /> 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 & 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>
|