General enquries feature
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
export let allowGeneralEnquiry = false;
|
||||
|
||||
const email = 'info@goodwalk.co.nz';
|
||||
const phone = '(022) 642 1011';
|
||||
@@ -12,8 +13,14 @@
|
||||
<main class="booking-page">
|
||||
<section class="booking-page-hero">
|
||||
<div class="booking-page-inner">
|
||||
<h1>Book a Meet & Greet</h1>
|
||||
<p class="booking-page-sub">Fill in the form below and we'll be in touch to arrange a free introduction.</p>
|
||||
<h1>Contact Us</h1>
|
||||
<p class="booking-page-sub">
|
||||
{#if allowGeneralEnquiry}
|
||||
Fill in the form below to book a Meet & Greet or send a general enquiry.
|
||||
{:else}
|
||||
Fill in the form below and we'll be in touch to arrange a free introduction.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="booking-page-contact">
|
||||
<a href="mailto:{email}" class="booking-contact-link">
|
||||
<Icon name="fas fa-envelope" />
|
||||
@@ -27,7 +34,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BookingSection {booking} />
|
||||
<BookingSection {booking} {allowGeneralEnquiry} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
export let allowGeneralEnquiry = false;
|
||||
type EnquiryType = 'booking' | 'general';
|
||||
|
||||
let step = 1;
|
||||
$: headingParts = splitBookingTitle(booking.title);
|
||||
|
||||
let enquiryType: EnquiryType = 'booking';
|
||||
let fullName = '';
|
||||
let email = '';
|
||||
let phone = '';
|
||||
@@ -59,12 +62,26 @@
|
||||
|
||||
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.';
|
||||
|
||||
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
|
||||
$: hasBanner = Boolean(booking.subtitle?.trim());
|
||||
$: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
|
||||
$: hasServices = booking.serviceOptions.length > 0;
|
||||
$: if (!allowGeneralEnquiry && enquiryType === 'general') {
|
||||
enquiryType = 'booking';
|
||||
}
|
||||
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
|
||||
$: ownerSubtitle = isGeneralEnquiry
|
||||
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
|
||||
: booking.subtitle;
|
||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
$: firstStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
|
||||
$: firstStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
|
||||
$: successPetName = petName.trim() || 'your dog';
|
||||
|
||||
onMount(() => {
|
||||
formStartedAt = Date.now();
|
||||
@@ -99,22 +116,47 @@
|
||||
selectedServices = selectedServices.filter((item) => item !== service);
|
||||
}
|
||||
|
||||
function validateDogStep(): boolean {
|
||||
function setEnquiryType(nextType: EnquiryType) {
|
||||
enquiryType = nextType;
|
||||
if (nextType === 'general') {
|
||||
petName = '';
|
||||
location = '';
|
||||
selectedServices = [];
|
||||
}
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function validateFirstStep(): boolean {
|
||||
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 (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.petName) {
|
||||
petNameInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.location) {
|
||||
locationInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function goToOwnerStep() {
|
||||
if (!validateDogStep()) return;
|
||||
if (!validateFirstStep()) return;
|
||||
errors = {};
|
||||
step = 2;
|
||||
}
|
||||
@@ -152,13 +194,19 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fullName, email, phone, petName, location, message,
|
||||
services: selectedServices,
|
||||
enquiryType,
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
petName: isGeneralEnquiry ? '' : petName,
|
||||
location: isGeneralEnquiry ? '' : location,
|
||||
message,
|
||||
services: isGeneralEnquiry ? [] : selectedServices,
|
||||
website,
|
||||
formStartedAt,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -185,8 +233,9 @@
|
||||
{#if submitted}
|
||||
<SuccessModal
|
||||
firstName={fullName.split(' ')[0]}
|
||||
{petName}
|
||||
petName={successPetName}
|
||||
{email}
|
||||
{enquiryType}
|
||||
onClose={() => (submitted = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -194,6 +243,7 @@
|
||||
{#if showErrorModal}
|
||||
<ErrorModal
|
||||
detail={submitErrorDetail}
|
||||
{enquiryType}
|
||||
onClose={() => (showErrorModal = false)}
|
||||
onRetry={() => (showErrorModal = false)}
|
||||
/>
|
||||
@@ -212,7 +262,7 @@
|
||||
on:click={() => (step = 1)}
|
||||
>
|
||||
<span class="booking-step-number">1</span>
|
||||
<span class="booking-step-label">{dogStepLabel}</span>
|
||||
<span class="booking-step-label">{firstStepLabel}</span>
|
||||
</button>
|
||||
<span class="booking-step-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
@@ -247,70 +297,120 @@
|
||||
|
||||
{#if step === 1}
|
||||
<div class="booking-panel">
|
||||
{#if dogIntro}
|
||||
<div class="booking-panel-banner">{dogIntro}</div>
|
||||
{#if firstStepIntro}
|
||||
<div class="booking-panel-banner">{firstStepIntro}</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}
|
||||
<div class:booking-card-grid-with-banner={Boolean(firstStepIntro)} 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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
{#if !isGeneralEnquiry}
|
||||
<div class="booking-field-card" class:booking-field-card-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-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>
|
||||
{/if}
|
||||
|
||||
<div class="booking-field-card booking-field-card-full" class:booking-field-card-invalid={errors.message}>
|
||||
<label for="message">
|
||||
<Icon name="fas fa-comment" /> {isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
|
||||
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
|
||||
</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."
|
||||
placeholder={isGeneralEnquiry
|
||||
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
|
||||
: 'Describe your pet, any special needs, or anything we should know.'}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{#if hasServices}
|
||||
{#if hasServices && !isGeneralEnquiry}
|
||||
<div class="booking-service-row">
|
||||
<span class="booking-service-label"><Icon name="fas fa-paw" /> Services</span>
|
||||
<div class="booking-service-options">
|
||||
@@ -340,19 +440,24 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input type="hidden" name="petName" value={petName} />
|
||||
<input type="hidden" name="location" value={location} />
|
||||
<input type="hidden" name="enquiryType" value={enquiryType} />
|
||||
{#if !isGeneralEnquiry}
|
||||
<input type="hidden" name="petName" value={petName} />
|
||||
<input type="hidden" name="location" value={location} />
|
||||
{/if}
|
||||
<input type="hidden" name="message" value={message} />
|
||||
{#each selectedServices as service}
|
||||
<input type="hidden" name="services" value={service} />
|
||||
{/each}
|
||||
{#if !isGeneralEnquiry}
|
||||
{#each selectedServices as service}
|
||||
<input type="hidden" name="services" value={service} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="booking-panel">
|
||||
{#if hasBanner}
|
||||
<div class="booking-panel-banner">{booking.subtitle}</div>
|
||||
{#if ownerSubtitle}
|
||||
<div class="booking-panel-banner">{ownerSubtitle}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={hasBanner} class="booking-card-grid booking-card-grid-owner">
|
||||
<div class:booking-card-grid-with-banner={Boolean(ownerSubtitle)} 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}>
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('BookingSection', () => {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
|
||||
@@ -27,7 +29,7 @@ describe('BookingSection', () => {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
@@ -56,7 +58,7 @@ describe('BookingSection', () => {
|
||||
await fireEvent.click(screen.getByLabelText('Pack Walks'));
|
||||
await fireEvent.click(screen.getByLabelText('Other Services'));
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
@@ -91,6 +93,7 @@ describe('BookingSection', () => {
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
enquiryType: 'booking',
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
@@ -113,6 +116,69 @@ describe('BookingSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('allows general enquiries without dog or service details', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking,
|
||||
allowGeneralEnquiry: true
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Grey Lynn' }
|
||||
});
|
||||
await fireEvent.click(screen.getByLabelText('Pack Walks'));
|
||||
await fireEvent.click(screen.getByLabelText(/General enquiry/i));
|
||||
|
||||
expect(screen.queryByLabelText(/Dog's Name/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pack Walks')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
expect(screen.getByText('Please tell us how we can help')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Your Message/i), {
|
||||
target: { value: 'I would like to discuss a business partnership.' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
|
||||
target: { value: 'Alex Walker' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/^Email/i), {
|
||||
target: { value: 'alex@example.com' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
|
||||
target: { value: '021 123 4567' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
enquiryType: 'general',
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
petName: '',
|
||||
location: '',
|
||||
message: 'I would like to discuss a business partnership.',
|
||||
services: []
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the API error message when submission fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
@@ -126,7 +192,7 @@ describe('BookingSection', () => {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let email = 'info@goodwalk.co.nz';
|
||||
export let enquiryType: 'booking' | 'general' = 'booking';
|
||||
export let onClose: () => void;
|
||||
export let onRetry: (() => void) | null = null;
|
||||
export let detail = '';
|
||||
|
||||
$: isGeneralEnquiry = enquiryType === 'general';
|
||||
$: mailtoHref =
|
||||
`mailto:${email}` +
|
||||
`?subject=${encodeURIComponent('Booking enquiry')}` +
|
||||
`?subject=${encodeURIComponent(isGeneralEnquiry ? 'General enquiry' : 'Booking enquiry')}` +
|
||||
`&body=${encodeURIComponent(
|
||||
'Hi Aless,\n\nI tried to submit the booking form but it didn’t go through. Here are my details:\n\nName:\nPhone:\nDog’s name:\nLocation:\n\nThanks!'
|
||||
isGeneralEnquiry
|
||||
? 'Hi Aless,\n\nI tried to submit the contact form but it didn’t go through. Here are my details:\n\nName:\nPhone:\nMessage:\n\nThanks!'
|
||||
: 'Hi Aless,\n\nI tried to submit the booking form but it didn’t go through. Here are my details:\n\nName:\nPhone:\nDog’s name:\nLocation:\n\nThanks!'
|
||||
)}`;
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
export let firstName: string;
|
||||
export let petName: string;
|
||||
export let email: string;
|
||||
export let enquiryType: 'booking' | 'general' = 'booking';
|
||||
export let onClose: () => void;
|
||||
|
||||
$: isGeneralEnquiry = enquiryType === 'general';
|
||||
|
||||
onMount(() => {
|
||||
const duration = 3200;
|
||||
const end = Date.now() + duration;
|
||||
@@ -44,7 +47,7 @@
|
||||
class="modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Booking confirmed"
|
||||
aria-label={isGeneralEnquiry ? 'Enquiry confirmed' : 'Booking confirmed'}
|
||||
on:click|self={onClose}
|
||||
on:keydown={(e) => e.key === 'Escape' && onClose()}
|
||||
tabindex="-1"
|
||||
@@ -56,18 +59,35 @@
|
||||
|
||||
<div class="modal-paw" aria-hidden="true">🐾</div>
|
||||
|
||||
<h2 class="modal-heading">You’re on our radar!</h2>
|
||||
<h2 class="modal-heading">
|
||||
{#if isGeneralEnquiry}
|
||||
Your message is with us!
|
||||
{:else}
|
||||
You’re on our radar!
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
||||
Meet & Greet with <strong>{petName}</strong>.
|
||||
</p>
|
||||
{#if isGeneralEnquiry}
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon about your enquiry.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
||||
Meet & Greet with <strong>{petName}</strong>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="modal-divider"></div>
|
||||
|
||||
<p class="modal-sub">
|
||||
In the meantime, feel free to follow along on Instagram for daily walks and happy dogs!
|
||||
{#if isGeneralEnquiry}
|
||||
We aim to reply within 1 business day.
|
||||
{:else}
|
||||
In the meantime, feel free to follow along on Instagram for daily walks and happy dogs!
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<button class="modal-btn" type="button" on:click={onClose}>
|
||||
|
||||
@@ -158,10 +158,14 @@ export const homepageContent: HomePageContent = {
|
||||
title: "Let's meet!",
|
||||
subtitle:
|
||||
"Almost there — just your contact details so we can reach out to arrange your free, no-obligation Meet & Greet.",
|
||||
generalSubtitle:
|
||||
"Almost there — just your contact details so we can reply properly to your message.",
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Your dog'
|
||||
dogStepLabel: 'Your dog',
|
||||
generalIntro:
|
||||
'Got feedback, a complaint, or a business enquiry? Choose general enquiry and send us the details without filling in dog or service information.'
|
||||
},
|
||||
info: {
|
||||
title: 'Locations & Hours',
|
||||
|
||||
@@ -37,7 +37,7 @@ export const staticPages = {
|
||||
},
|
||||
'contact-us': {
|
||||
title: 'Contact Us',
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.',
|
||||
description: 'Book a Meet & Greet or send a general enquiry to Goodwalk Auckland dog walking services.',
|
||||
canonicalPath: '/contact-us'
|
||||
},
|
||||
'terms-and-conditions': {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function parseBooleanFlag(value: string | undefined, defaultValue = false) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function isGeneralEnquiryEnabled() {
|
||||
return parseBooleanFlag(process.env.ENABLE_GENERAL_ENQUIRIES, false);
|
||||
}
|
||||
@@ -191,6 +191,13 @@
|
||||
color: #34363a;
|
||||
}
|
||||
|
||||
.booking-help-text {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.booking-required {
|
||||
color: var(--yellow);
|
||||
}
|
||||
@@ -238,6 +245,68 @@
|
||||
box-shadow: 0 10px 30px rgba(17, 20, 24, 0.04);
|
||||
}
|
||||
|
||||
.booking-toggle-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.booking-toggle-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
padding: 14px 18px;
|
||||
border: 3px solid #111;
|
||||
border-radius: 18px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #34363a;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.booking-toggle-option:hover {
|
||||
background: #f6f0e5;
|
||||
}
|
||||
|
||||
.booking-toggle-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.booking-toggle-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #111;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.booking-toggle-option input:checked + .booking-toggle-indicator {
|
||||
border-color: #111;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.booking-toggle-option input:checked + .booking-toggle-indicator::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
|
||||
.booking-service-options {
|
||||
display: flex;
|
||||
|
||||
@@ -469,6 +469,23 @@
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.booking-toggle-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.booking-toggle-option {
|
||||
width: 100%;
|
||||
border-width: 2px;
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.booking-toggle-indicator {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.booking-check-option {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -77,11 +77,13 @@ export interface TestimonialContent {
|
||||
export interface BookingContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
generalSubtitle?: string;
|
||||
formAction: string;
|
||||
serviceOptions: string[];
|
||||
ownerStepLabel?: string;
|
||||
dogStepLabel?: string;
|
||||
dogIntro?: string;
|
||||
generalIntro?: string;
|
||||
}
|
||||
|
||||
export interface ServicePricingPlan {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { staticPages, type StaticPageSlug } from '$lib/content/static-pages';
|
||||
import { isGeneralEnquiryEnabled } from '$lib/server/feature-flags';
|
||||
import { getSharedPageContent } from '$lib/server/content';
|
||||
|
||||
export async function load({ params }) {
|
||||
@@ -12,14 +13,24 @@ export async function load({ params }) {
|
||||
}
|
||||
|
||||
const slug = params.slug as StaticPageSlug;
|
||||
const page = staticPages[slug];
|
||||
const generalEnquiryEnabled = isGeneralEnquiryEnabled();
|
||||
const sourcePage = staticPages[slug];
|
||||
|
||||
if (!page) {
|
||||
if (!sourcePage) {
|
||||
throw error(404, 'Page not found');
|
||||
}
|
||||
|
||||
const page =
|
||||
slug === 'contact-us' && !generalEnquiryEnabled
|
||||
? {
|
||||
...sourcePage,
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.'
|
||||
}
|
||||
: sourcePage;
|
||||
|
||||
return {
|
||||
content: await getSharedPageContent(),
|
||||
generalEnquiryEnabled,
|
||||
page,
|
||||
slug
|
||||
};
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
{:else if data.slug === 'privacy-policy'}
|
||||
<LegalPage pageContent={privacyPolicyContent} />
|
||||
{:else if data.slug === 'contact-us'}
|
||||
<BookingPage booking={data.content.booking} />
|
||||
<BookingPage booking={data.content.booking} allowGeneralEnquiry={data.generalEnquiryEnabled} />
|
||||
{:else}
|
||||
<main class="static-page">
|
||||
<section class="static-page-hero">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { load } from './+page.server';
|
||||
describe('static slug page server load', () => {
|
||||
beforeEach(() => {
|
||||
getSharedPageContent.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('redirects the legacy about-us slug to /about', async () => {
|
||||
@@ -42,8 +43,35 @@ describe('static slug page server load', () => {
|
||||
|
||||
await expect(load({ params: { slug: 'pack-walks' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: false,
|
||||
page: staticPages['pack-walks'],
|
||||
slug: 'pack-walks'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps general enquiries disabled on contact-us by default', async () => {
|
||||
getSharedPageContent.mockResolvedValue(sharedPageContent);
|
||||
|
||||
await expect(load({ params: { slug: 'contact-us' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: false,
|
||||
page: {
|
||||
...staticPages['contact-us'],
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.'
|
||||
},
|
||||
slug: 'contact-us'
|
||||
});
|
||||
});
|
||||
|
||||
it('enables general enquiries on contact-us when the env flag is turned on', async () => {
|
||||
vi.stubEnv('ENABLE_GENERAL_ENQUIRIES', 'enabled');
|
||||
getSharedPageContent.mockResolvedValue(sharedPageContent);
|
||||
|
||||
await expect(load({ params: { slug: 'contact-us' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: true,
|
||||
page: staticPages['contact-us'],
|
||||
slug: 'contact-us'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,4 +43,21 @@ describe('static slug route page', () => {
|
||||
expect(screen.queryByText('Why people choose us!')).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('shows the general enquiry option on the contact page only', () => {
|
||||
const { rerender } = render(SlugPage, {
|
||||
data: {
|
||||
...createStaticRouteData('contact-us'),
|
||||
generalEnquiryEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/General enquiry/i)).toBeInTheDocument();
|
||||
|
||||
rerender({
|
||||
data: createStaticRouteData('pack-walks')
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('home page route', () => {
|
||||
expect(screen.getAllByText("Your Dog's Day!").length).toBeGreaterThan(0);
|
||||
expect(document.body.textContent).toContain('Happy pets,');
|
||||
expect(screen.getByText('Locations & Hours')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
expect(document.title).toBe('Home | Auckland Dog Walking | Goodwalk');
|
||||
expect(document.head.innerHTML).toContain('FAQPage');
|
||||
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png');
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createHomepageRouteData() {
|
||||
export function createStaticRouteData(slug: StaticPageSlug) {
|
||||
return {
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: false,
|
||||
page: staticPages[slug],
|
||||
slug
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user