feat(bookings): improve admin booking wizard and user dashboard
Backend: - Enriched GetAllUserBookings response with calculated total_amount, amount_paid, and duration_minutes. - Refactored GetBookingHandler to return a flat booking object matching frontend expectations. - Added account_role to admin user list response and sorted users by booking activity. - Corrected function name oo to AdminCreateBookingForUserHandler. Frontend: - Rebuilt BookingCreateModal into a 4-step wizard supporting guest bookings, service overrides, and real-time availability checks. - Fixed account dashboard logic to correctly identify upcoming vs past bookings and sort unpaid items to the top. - Extracted booking flow into a shared BookingFlow component. - Redirected admin users from home page to /today.
This commit is contained in:
@@ -37,7 +37,12 @@
|
||||
<p class="mb-8 text-lg text-gray-600">
|
||||
Professional beauty treatments in a calm and friendly environment.
|
||||
</p>
|
||||
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button>
|
||||
|
||||
{#if authStore.currentUser?.role === 'admin'}
|
||||
<Button href="/today" class="px-6 py-3 text-lg">View your day</Button>
|
||||
{:else}
|
||||
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -110,7 +111,9 @@
|
||||
loadingUpcoming = true;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=3&page=1`, {
|
||||
|
||||
// Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past
|
||||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -125,7 +128,18 @@
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
upcomingBookings = data.bookings || [];
|
||||
const now = new Date();
|
||||
|
||||
// Filter: Calculate end time (Start + Duration) and check if it's in the future
|
||||
const activeOrFutureBookings = (data.bookings || []).filter((b: any) => {
|
||||
const startTime = new Date(b.start_time);
|
||||
// Add duration (in ms)
|
||||
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||
return endTime > now;
|
||||
});
|
||||
|
||||
// Take only the top 3
|
||||
upcomingBookings = activeOrFutureBookings.slice(0, 3);
|
||||
} catch (err) {
|
||||
console.error('Error fetching upcoming bookings:', err);
|
||||
toast.error('Network error loading upcoming bookings');
|
||||
@@ -140,7 +154,7 @@
|
||||
|
||||
loadingPast = true;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -156,7 +170,35 @@
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
pastBookings = data.bookings || [];
|
||||
let bookings = data.bookings || [];
|
||||
|
||||
// FIX: Manually calculate amount_due for the list
|
||||
// The list API often returns 0 for amount_due/amount_paid,
|
||||
// so we derive it from total_amount.
|
||||
bookings = bookings.map((b: any) => {
|
||||
const total = b.total_amount || 0;
|
||||
const paid = b.amount_paid || 0;
|
||||
return {
|
||||
...b,
|
||||
amount_due: total - paid // Force calculate the balance
|
||||
};
|
||||
});
|
||||
|
||||
// SORT LOGIC: Unpaid first, then by most recent
|
||||
bookings.sort((a: any, b: any) => {
|
||||
const aUnpaid = (a.amount_due || 0) > 0;
|
||||
const bUnpaid = (b.amount_due || 0) > 0;
|
||||
|
||||
// If A is unpaid and B is not, A comes first
|
||||
if (aUnpaid && !bUnpaid) return -1;
|
||||
// If B is unpaid and A is not, B comes first
|
||||
if (!aUnpaid && bUnpaid) return 1;
|
||||
|
||||
// If both have same payment status, sort by Date DESC (newest first)
|
||||
return new Date(b.start_time).getTime() - new Date(a.start_time).getTime();
|
||||
});
|
||||
|
||||
pastBookings = bookings;
|
||||
pastPage = data.page || page;
|
||||
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
|
||||
} catch (err) {
|
||||
@@ -233,6 +275,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Booking modal ==============
|
||||
// =============== Modal State ===============
|
||||
let showBookingModal = $state(false);
|
||||
let selectedBookingId = $state<string | null>(null);
|
||||
|
||||
function openBookingModal(id: string) {
|
||||
selectedBookingId = id;
|
||||
showBookingModal = true;
|
||||
}
|
||||
|
||||
// =============== Account Deletion ===============
|
||||
let showDeleteAlert = $state(false);
|
||||
let deleteConfirmText = $state('');
|
||||
@@ -504,14 +556,13 @@
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{/each}
|
||||
{:else if upcomingBookings.length === 0}
|
||||
<div class="py-4 text-center text-gray-500">No upcoming bookings</div>
|
||||
{:else}
|
||||
{#each upcomingBookings as b (b.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<!-- Show Status Chip for Upcoming -->
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
||||
'confirmed'
|
||||
@@ -520,38 +571,27 @@
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
||||
? 'bg-emerald-600'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-600'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-600'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{b.status}
|
||||
</span>
|
||||
<span>
|
||||
- {(() => {
|
||||
const services = (b.services || []).map(
|
||||
(s) => s.service_name || 'Unknown Service'
|
||||
);
|
||||
if (services.length === 0) return 'No services';
|
||||
if (services.length === 1) return services[0];
|
||||
if (services.length === 2) return services.join(' and ');
|
||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||
})()}
|
||||
</span>
|
||||
|
||||
<!-- Services: Only show if data exists -->
|
||||
{#if b.services && b.services.length > 0}
|
||||
<span>
|
||||
- {(() => {
|
||||
const services = b.services.map(
|
||||
(s) => s.service_name || 'Unknown Service'
|
||||
);
|
||||
if (services.length === 1) return services[0];
|
||||
if (services.length === 2) return services.join(' and ');
|
||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||
})()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button
|
||||
>
|
||||
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -578,45 +618,31 @@
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
||||
'confirmed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<!-- Unpaid Chip: Matches the 'Confirmed' chip style but uses Red for urgency -->
|
||||
{#if (b.amount_due || 0) > 0}
|
||||
<span
|
||||
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
||||
? 'bg-emerald-600'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-600'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-600'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{b.status}
|
||||
</span>
|
||||
<span>
|
||||
- {(() => {
|
||||
const services = (b.services || []).map(
|
||||
(s) => s.service_name || 'Unknown Service'
|
||||
);
|
||||
if (services.length === 0) return 'No services';
|
||||
if (services.length === 1) return services[0];
|
||||
if (services.length === 2) return services.join(' and ');
|
||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||
})()}
|
||||
</span>
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
Unpaid
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Services: Hidden if empty -->
|
||||
{#if b.services && b.services.length > 0}
|
||||
<span>
|
||||
- {(() => {
|
||||
const services = b.services.map(
|
||||
(s) => s.service_name || 'Unknown Service'
|
||||
);
|
||||
if (services.length === 1) return services[0];
|
||||
if (services.length === 2) return services.join(' and ');
|
||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||
})()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
|
||||
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -991,6 +1017,11 @@
|
||||
</AlertDialog.Root>
|
||||
{/if}
|
||||
|
||||
<!-- User Booking Modal -->
|
||||
{#if showBookingModal && selectedBookingId}
|
||||
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Mobile Tab Menu Styles */
|
||||
.mobile-tab-menu {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const BACKEND_URL =
|
||||
import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
|
||||
|
||||
async function proxyRequest(request: Request, path: string) {
|
||||
const incomingUrl = new URL(request.url);
|
||||
|
||||
const queryString = incomingUrl.search;
|
||||
|
||||
const url = `${BACKEND_URL}/api/${path}${queryString}`;
|
||||
|
||||
try {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete('host');
|
||||
|
||||
const backendRes = await fetch(url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ['GET', 'HEAD'].includes(request.method)
|
||||
? undefined
|
||||
: await request.text()
|
||||
});
|
||||
|
||||
// Forward everything transparently
|
||||
const resHeaders = new Headers(backendRes.headers);
|
||||
return new Response(backendRes.body, {
|
||||
status: backendRes.status,
|
||||
headers: resHeaders
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Proxy error:', err);
|
||||
return error(502, 'Backend unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const GET: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
|
||||
export const POST: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
|
||||
export const PUT: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
|
||||
export const DELETE: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
|
||||
export const PATCH: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,624 @@
|
||||
## 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**
|
||||
|
||||
```ts
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
```
|
||||
|
||||
Pure UI. Zero logic.
|
||||
|
||||
---
|
||||
|
||||
### 3. `ServiceSelector.svelte`
|
||||
|
||||
**Role:** Choose services
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
services: Service[]
|
||||
selected: Service[]
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
select(service)
|
||||
deselect(service)
|
||||
```
|
||||
|
||||
Internally renders multiple `ServiceCard`s.
|
||||
|
||||
---
|
||||
|
||||
### 4. `ServiceCard.svelte`
|
||||
|
||||
**Role:** One service tile
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
service: Service
|
||||
selected: boolean
|
||||
```
|
||||
|
||||
No awareness of booking steps or pricing totals.
|
||||
|
||||
---
|
||||
|
||||
### 5. `DatePicker.svelte`
|
||||
|
||||
**Role:** Calendar selection
|
||||
|
||||
Wraps your existing `Calendar` usage.
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
date: CalendarDate | undefined
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
change(date)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `TimeSlotPicker.svelte`
|
||||
|
||||
**Role:** Pick available time
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
date: CalendarDate
|
||||
selectedTime: string | null
|
||||
availability: TimeSlot[]
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
select(time)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. `CustomerDetailsForm.svelte`
|
||||
|
||||
**Role:** Collect user info
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
value: CustomerInfo
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
update(value)
|
||||
```
|
||||
|
||||
No submit button here. Forms shouldn’t decide flow control.
|
||||
|
||||
---
|
||||
|
||||
### 8. `BookingSummary.svelte`
|
||||
|
||||
**Role:** Read‑only confirmation
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
services: Service[]
|
||||
date: CalendarDate
|
||||
time: string
|
||||
customer: CustomerInfo
|
||||
total: number
|
||||
```
|
||||
|
||||
Zero mutation. Snapshot only.
|
||||
|
||||
---
|
||||
|
||||
### 9. `BookingActions.svelte`
|
||||
|
||||
**Role:** Navigation + submit
|
||||
|
||||
**Props**
|
||||
|
||||
```ts
|
||||
canBack: boolean
|
||||
canNext: boolean
|
||||
isSubmitting: boolean
|
||||
```
|
||||
|
||||
**Emits**
|
||||
|
||||
```ts
|
||||
back()
|
||||
next()
|
||||
submit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared types
|
||||
|
||||
Move all interfaces out of `+page.svelte`:
|
||||
|
||||
```ts
|
||||
// lib/types/booking.ts
|
||||
export interface Service { ... }
|
||||
export interface CustomerInfo { ... }
|
||||
export interface TimeSlot { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional store (recommended)
|
||||
|
||||
```ts
|
||||
// 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.
|
||||
|
||||
```svelte
|
||||
<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={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={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={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
|
||||
|
||||
```svelte
|
||||
<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`
|
||||
|
||||
```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`
|
||||
|
||||
```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:
|
||||
|
||||
* `ServiceCard` is dumb
|
||||
* `ServiceSelector` coordinates cards
|
||||
* `BookingFlow` owns 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:input` handlers
|
||||
|
||||
Anything that mutates `customerInfo` belongs in the form *except* submission.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Create `CustomerDetailsForm.svelte`
|
||||
|
||||
```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:
|
||||
|
||||
```svelte
|
||||
<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.
|
||||
|
||||
```svelte
|
||||
<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.
|
||||
|
||||
```svelte
|
||||
<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:
|
||||
|
||||
```svelte
|
||||
<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={availability}
|
||||
selectedTime={selectedTime}
|
||||
on:select={(e) => {
|
||||
selectedTime = e.detail;
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
All temporal logic lives here. Children stay honest.
|
||||
@@ -9,9 +9,8 @@
|
||||
import CurrentAppointment from '$lib/components/today/CurrentAppointment.svelte';
|
||||
import TodayCalendar from '$lib/components/today/TodayCalendar.svelte';
|
||||
import PendingApprovals from '$lib/components/today/PendingApprovals.svelte';
|
||||
// import TodayRevenue from '$lib/components/today/TodayRevenue.svelte';
|
||||
// import LoyaltyStats from '$lib/components/today/LoyaltyStats.svelte';
|
||||
import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
|
||||
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
|
||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||
import UserModal from '$lib/components/admin/UserModal.svelte';
|
||||
|
||||
@@ -111,12 +110,12 @@
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column: Create a booking for a call in or messaging user (2/3 width on large screens) -->
|
||||
<div class="lg:col-span-2">
|
||||
<CallInBooking />
|
||||
<WalkInBooking />
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Similar to the above but immediately block out my next availible working space while I talk over the walk-in users needs and chat etc (1/3 width on large screens) -->
|
||||
<div class="space-y-6">
|
||||
<!-- <WalkInBooking /> -->
|
||||
<CallInBooking />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user