11 KiB
Goal
Decompose +page.svelte into focused, testable components with clear data flow and minimal shared state.
High‑level structure
/routes/book/+page.svelte
/lib/components/booking/
BookingFlow.svelte
StepIndicator.svelte
ServiceSelector.svelte
ServiceCard.svelte
DatePicker.svelte
TimeSlotPicker.svelte
CustomerDetailsForm.svelte
BookingSummary.svelte
BookingActions.svelte
/lib/stores/booking.ts
/lib/types/booking.ts
State ownership (important)
Single source of truth: BookingFlow.svelte
- currentStep
- selectedServices
- selectedDate
- selectedTime
- customerInfo
- pricing / totals
Everything else receives props + dispatches events. No hidden global coupling. 🧠
Components
1. BookingFlow.svelte
Role: Orchestrator
- Holds booking state
- Validates transitions between steps
- Calls API + handles toasts
Props: none Emits: none
This is the only component allowed to know everything.
2. StepIndicator.svelte
Role: Visual progress
Props
currentStep: number;
totalSteps: number;
Pure UI. Zero logic.
3. ServiceSelector.svelte
Role: Choose services
Props
services: Service[]
selected: Service[]
Emits
select(service);
deselect(service);
Internally renders multiple ServiceCards.
4. ServiceCard.svelte
Role: One service tile
Props
service: Service;
selected: boolean;
No awareness of booking steps or pricing totals.
5. DatePicker.svelte
Role: Calendar selection
Wraps your existing Calendar usage.
Props
date: CalendarDate | undefined;
Emits
change(date);
6. TimeSlotPicker.svelte
Role: Pick available time
Props
date: CalendarDate
selectedTime: string | null
availability: TimeSlot[]
Emits
select(time);
7. CustomerDetailsForm.svelte
Role: Collect user info
Props
value: CustomerInfo;
Emits
update(value);
No submit button here. Forms shouldn’t decide flow control.
8. BookingSummary.svelte
Role: Read‑only confirmation
Props
services: Service[]
date: CalendarDate
time: string
customer: CustomerInfo
total: number
Zero mutation. Snapshot only.
9. BookingActions.svelte
Role: Navigation + submit
Props
canBack: boolean;
canNext: boolean;
isSubmitting: boolean;
Emits
back();
next();
submit();
Shared types
Move all interfaces out of +page.svelte:
// lib/types/booking.ts
export interface Service { ... }
export interface CustomerInfo { ... }
export interface TimeSlot { ... }
Optional store (recommended)
// lib/stores/booking.ts
export const bookingStore = $state({ ... })
Use only if the flow must persist across routes or reloads. Otherwise keep it local.
Result
- Smaller files
- Predictable data flow
- Each component explainable in one sentence
- Easier testing and future changes
Entropy reduced. ✂️🧩
Step 1 — Extract the brain (no store yet)
We start by wrapping the existing logic, not rewriting it.
1. Create BookingFlow.svelte
Move all booking‑related state and logic out of +page.svelte into this file.
<script lang="ts">
import StepIndicator from './StepIndicator.svelte';
import ServiceSelector from './ServiceSelector.svelte';
import DatePicker from './DatePicker.svelte';
import TimeSlotPicker from './TimeSlotPicker.svelte';
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
import BookingSummary from './BookingSummary.svelte';
import BookingActions from './BookingActions.svelte';
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
// --- State (copied verbatim from +page.svelte) ---
let currentStep = 1;
let selectedServices: Service[] = [];
let selectedDate;
let selectedTime: string | null = null;
let customerInfo: CustomerInfo = {
/* unchanged */
};
// pricing, derived values, API calls stay here
</script>
<StepIndicator {currentStep} totalSteps={4} />
{#if currentStep === 1}
<ServiceSelector
{services}
selected={selectedServices}
on:select={(e) => selectedServices.push(e.detail)}
on:deselect={(e) => (selectedServices = selectedServices.filter((s) => s.id !== e.detail.id))}
/>
{:else if currentStep === 2}
<DatePicker bind:date={selectedDate} />
<TimeSlotPicker date={selectedDate} {availability} bind:selectedTime />
{:else if currentStep === 3}
<CustomerDetailsForm bind:value={customerInfo} />
{:else if currentStep === 4}
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={customerInfo}
{total}
/>
{/if}
<BookingActions
canBack={currentStep > 1}
canNext={currentStep < 4}
on:back={() => currentStep--}
on:next={() => currentStep++}
on:submit={submitBooking}
/>
Nothing clever yet. This is a containerization step, not a refactor.
2. Reduce +page.svelte to glue
<script lang="ts">
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
</script>
<BookingFlow />
At this point:
- Behaviour is identical
- No store introduced
- You have a single, explicit "brain"
Step 2 — Extract ServiceCard (low risk, high reward)
This is the safest cut: pure UI, minimal state, zero flow control.
2.1 Identify the slice
In ServiceSelector, find the repeated markup that:
- Displays service name / price / duration
- Highlights selected state
- Handles click / toggle
If it renders one service, it becomes a card.
2.2 Create ServiceCard.svelte
<script lang="ts">
import type { Service } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
export let service: Service;
export let selected = false;
const dispatch = createEventDispatcher();
function toggle() {
dispatch(selected ? 'deselect' : 'select', service);
}
</script>
<button class:selected on:click={toggle}>
<h3>{service.name}</h3>
<p>{service.duration} min</p>
<p>£{service.price}</p>
</button>
<style>
button {
/* existing styles */
}
.selected {
/* existing selected styles */
}
</style>
No booking logic. No totals. No step awareness.
2.3 Simplify ServiceSelector.svelte
<script lang="ts">
import ServiceCard from './ServiceCard.svelte';
import type { Service } from '$lib/types/booking';
export let services: Service[] = [];
export let selected: Service[] = [];
</script>
<div class="grid">
{#each services as service (service.id)}
<ServiceCard
{service}
selected={selected.some((s) => s.id === service.id)}
on:select
on:deselect
/>
{/each}
</div>
Selection state still lives above. This is critical.
Validation checkpoint
At this point:
ServiceCardis dumbServiceSelectorcoordinates cardsBookingFlowowns truth
If this feels boring, good. Boring code is stable code.
Step 3 — Extract CustomerDetailsForm (controlled input boundary)
This step removes a classic source of entropy: forms that secretly control flow.
The rule here is strict:
Forms collect data. Parents decide what to do with it.
3.1 Identify the form logic
In BookingFlow, locate:
- Name / email / phone inputs
- Validation messages
on:inputhandlers
Anything that mutates customerInfo belongs in the form except submission.
3.2 Create CustomerDetailsForm.svelte
<script lang="ts">
import type { CustomerInfo } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
export let value: CustomerInfo;
const dispatch = createEventDispatcher();
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
dispatch('update', { ...value, [key]: val });
}
</script>
<div class="space-y-4">
<input
type="text"
placeholder="Name"
value={value.name}
on:input={(e) => update('name', e.currentTarget.value)}
/>
<input
type="email"
placeholder="Email"
value={value.email}
on:input={(e) => update('email', e.currentTarget.value)}
/>
<input
type="tel"
placeholder="Phone"
value={value.phone}
on:input={(e) => update('phone', e.currentTarget.value)}
/>
</div>
No submit button. No step logic. No API calls.
3.3 Wire it back into BookingFlow
Replace inline inputs with:
<CustomerDetailsForm value={customerInfo} on:update={(e) => (customerInfo = e.detail)} />
Validation still lives in BookingFlow:
- Can we go to the next step?
- Is submit enabled?
Validation checkpoint
You should now observe:
- The form is reusable
- BookingFlow got smaller
- Step logic is easier to read
If you can’t explain where a rule lives in one sentence, it’s in the wrong place.
Step 4 — Split DatePicker and TimeSlotPicker (explicit dependency)
This is the first step where ordering matters. Time is meaningless without a date. We make that dependency obvious and one‑directional.
Rule of the step:
Date flows down. Time flows up.
4.1 Extract DatePicker.svelte
This component selects only a date. No availability logic. No time awareness.
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CalendarDate } from '@internationalized/date';
export let date: CalendarDate | undefined;
const dispatch = createEventDispatcher();
</script>
<Calendar value={date} on:change={(e) => dispatch('change', e.detail)} />
It emits intent. That’s it.
4.2 Extract TimeSlotPicker.svelte
Time slots depend on inputs, never globals.
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { TimeSlot } from '$lib/types/booking';
export let date; // required
export let availability: TimeSlot[] = [];
export let selectedTime: string | null = null;
const dispatch = createEventDispatcher();
</script>
{#if !date}
<p class="text-muted">Select a date first</p>
{:else}
<div class="grid">
{#each availability as slot (slot.time)}
<button
class:selected={slot.time === selectedTime}
disabled={!slot.available}
on:click={() => dispatch('select', slot.time)}
>
{slot.time}
</button>
{/each}
</div>
{/if}
No fetching. No step logic. Just rendering.
4.3 Wire in BookingFlow
Here is the complete and correct wiring, including guards and reset logic:
<DatePicker
date={selectedDate}
on:change={(e) => {
const newDate = e.detail;
selectedDate = newDate;
// changing date invalidates time
selectedTime = null;
// fetch / recompute availability here
loadAvailability(newDate);
}}
/>
<TimeSlotPicker
date={selectedDate}
{availability}
{selectedTime}
on:select={(e) => {
selectedTime = e.detail;
}}
/>
All temporal logic lives here. Children stay honest.