From 50746595e7f5fb0e3281eabbce4c02eec4f34587 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 12 Feb 2026 22:15:10 +0000 Subject: [PATCH] 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. --- backend/handlers/bookings/bookings.go | 106 +- backend/handlers/bookings/manage.go | 4 +- backend/handlers/user/profile.go | 61 +- backend/main.go | 4 +- .../account/UserBookingModal.svelte | 287 ++ .../lib/components/admin/ApprovalModal.svelte | 2 +- .../admin/BookingCreateModal.svelte | 1234 +++++- .../lib/components/admin/CallInBooking.svelte | 17 +- .../lib/components/admin/WalkInBooking.svelte | 211 + .../components/admin/WalkInCreateModal.svelte | 718 ++++ .../components/booking/BookingActions.svelte | 31 + .../lib/components/booking/BookingFlow.svelte | 942 +++++ .../components/booking/BookingSummary.svelte | 128 + .../lib/components/booking/DatePicker.svelte | 46 + .../lib/components/booking/ServiceCard.svelte | 48 + .../components/booking/ServiceSelector.svelte | 36 + .../components/booking/StepIndicator.svelte | 39 + .../components/booking/TimeSlotPicker.svelte | 121 + frontend/src/lib/lunchProtection.ts | 272 ++ frontend/src/lib/types/booking.ts | 39 + frontend/src/routes/+page.svelte | 7 +- frontend/src/routes/account/+page.svelte | 169 +- frontend/src/routes/admin/+page._svelte | 3404 ----------------- .../src/routes/api/[...path]/+server.ts.txt | 43 + frontend/src/routes/book/+page.svelte | 1017 +---- frontend/src/routes/book/canvas.md | 624 +++ frontend/src/routes/today/+page.svelte | 7 +- init-scripts/init-scrips.sql.txt | 1145 ++++++ llms.txt | 243 ++ local-dev-2.sh | 416 ++ local-dev-2.sh.txt | 416 ++ obsidian/.obsidian/workspace.json | 21 +- obsidian/Crussell/Backend/bookings.md | 522 --- obsidian/Crussell/Crussell Nails.md | 721 ++-- 34 files changed, 7688 insertions(+), 5413 deletions(-) create mode 100644 frontend/src/lib/components/account/UserBookingModal.svelte create mode 100644 frontend/src/lib/components/admin/WalkInBooking.svelte create mode 100644 frontend/src/lib/components/admin/WalkInCreateModal.svelte create mode 100644 frontend/src/lib/components/booking/BookingActions.svelte create mode 100644 frontend/src/lib/components/booking/BookingFlow.svelte create mode 100644 frontend/src/lib/components/booking/BookingSummary.svelte create mode 100644 frontend/src/lib/components/booking/DatePicker.svelte create mode 100644 frontend/src/lib/components/booking/ServiceCard.svelte create mode 100644 frontend/src/lib/components/booking/ServiceSelector.svelte create mode 100644 frontend/src/lib/components/booking/StepIndicator.svelte create mode 100644 frontend/src/lib/components/booking/TimeSlotPicker.svelte create mode 100644 frontend/src/lib/lunchProtection.ts create mode 100644 frontend/src/lib/types/booking.ts delete mode 100644 frontend/src/routes/admin/+page._svelte create mode 100644 frontend/src/routes/api/[...path]/+server.ts.txt create mode 100644 frontend/src/routes/book/canvas.md create mode 100644 init-scripts/init-scrips.sql.txt create mode 100644 llms.txt create mode 100755 local-dev-2.sh create mode 100755 local-dev-2.sh.txt delete mode 100644 obsidian/Crussell/Backend/bookings.md diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index e74e54f..f995f7b 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -257,11 +257,33 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { req := parseGetAllBookingsRequest(r) // Build base query with user filter + // We now calculate Total Amount, Amount Paid, AND Duration baseQuery := ` - SELECT id, start_time, status, notes, created_at, updated_at, created_by - FROM bookings - WHERE user_id = $1 - ` + SELECT + id, start_time, status, notes, created_at, updated_at, created_by, + -- Total Amount + (SELECT COALESCE(SUM(CASE + WHEN bs.override_price IS NOT NULL THEN bs.override_price + ELSE s.price + END), 0) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id) as total_amount, + -- Amount Paid + (SELECT COALESCE(SUM(amount), 0) + FROM payments + WHERE booking_id = bookings.id AND status = 'completed') as amount_paid, + -- Duration Minutes + (SELECT COALESCE(SUM(CASE + WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes + ELSE s.duration_minutes + END), 0) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id) as duration_minutes + FROM bookings + WHERE user_id = $1 + ` countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` var args []interface{} @@ -338,7 +360,16 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var b Booking var createdBy sql.NullString - err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy) + // Scan duration_minutes as well + var totalAmount, amountPaid float64 + var durationMinutes int + + err := rows.Scan( + &b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, + &totalAmount, + &amountPaid, + &durationMinutes, + ) if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -347,6 +378,12 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { if createdBy.Valid { b.CreatedBy = &createdBy.String } + + b.TotalAmount = totalAmount + b.AmountPaid = amountPaid + b.AmountDue = totalAmount - amountPaid + b.DurationMinutes = durationMinutes // Populate the struct + bookings = append(bookings, b) } @@ -1648,14 +1685,17 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { // 1. Fetch booking // ---------------------------- var booking Booking + // Initialize slices/maps to avoid null in JSON booking.Payments = []Payment{} + booking.Services = []BookingService{} booking.User = &UserSummary{} + var createdBy sql.NullString err := db.DB.QueryRow(r.Context(), ` - SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by - FROM bookings - WHERE id = $1 AND user_id = $2 - `, bookingID, userID).Scan( + SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by + FROM bookings + WHERE id = $1 AND user_id = $2 + `, bookingID, userID).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy, ) @@ -1713,21 +1753,29 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Calculate Totals based on overrides or base values + var priceToAdd float64 + var durationToAdd int + if overridePrice.Valid { s.OverridePrice = &overridePrice.Float64 - totalAmount += *s.OverridePrice + priceToAdd = overridePrice.Float64 } else if basePrice.Valid { - totalAmount += basePrice.Float64 + priceToAdd = basePrice.Float64 } if overrideDuration.Valid { d := int(overrideDuration.Int32) s.OverrideDurationMinutes = &d - durationMinutes += *s.OverrideDurationMinutes + durationToAdd = d } else if baseDuration.Valid { - durationMinutes += int(baseDuration.Int32) + durationToAdd = int(baseDuration.Int32) } + totalAmount += priceToAdd + durationMinutes += durationToAdd + + // Map nullable strings to pointers if name.Valid { s.ServiceName = &name.String } @@ -1810,27 +1858,23 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { booking.Payments = append(booking.Payments, p) } - amountDue := totalAmount - amountPaid - - // Create enhanced response with user-friendly totals - enhancedResponse := struct { - Booking Booking `json:"booking"` - TotalAmount float64 `json:"total_amount"` - AmountPaid float64 `json:"amount_paid"` - AmountDue float64 `json:"amount_due"` - DurationMinutes int `json:"duration_minutes"` - }{ - Booking: booking, - TotalAmount: totalAmount, - AmountPaid: amountPaid, - AmountDue: amountDue, - DurationMinutes: durationMinutes, - } + // ---------------------------- + // 4. Assign calculated totals to Booking Struct + // ---------------------------- + booking.TotalAmount = totalAmount + booking.AmountPaid = amountPaid + booking.AmountDue = totalAmount - amountPaid + booking.DurationMinutes = durationMinutes + // ---------------------------- + // 5. Return Response + // ---------------------------- w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(enhancedResponse); err != nil { + + // We encode the 'booking' object directly. + // This matches the frontend expectation: selectedBooking = data; + if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) - return } } diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 1ea81fe..fc4275e 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -173,10 +173,10 @@ type AdminCreateBookingForUserRequest struct { StartTime time.Time `json:"start_time" validate:"required"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` - Notes *string `json:"notes,omitempty"` // staff notes + Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff } -func oo(w http.ResponseWriter, r *http.Request) { +func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { // Admin identity (creator) adminID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || adminID == "" { diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 4481534..1c1e521 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -78,10 +78,11 @@ type SocialLogin struct { } type UserListItem struct { - ID string `json:"id"` - FullName string `json:"fullName"` - Email *string `json:"email,omitempty"` - Phone *string `json:"phone,omitempty"` + ID string `json:"id"` + FullName string `json:"fullName"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + AccountRole string `json:"account_role"` } type UserListResponse struct { @@ -386,35 +387,39 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { searchPattern := "%" + searchTerm + "%" countQuery = ` - SELECT COUNT(*) - FROM users - WHERE fn ILIKE $1 - OR email ILIKE $1 - OR phone ILIKE $1 - ` + SELECT COUNT(*) + FROM users + WHERE fn ILIKE $1 + OR email ILIKE $1 + OR phone ILIKE $1 + ` countArgs = []interface{}{searchPattern} listQuery = ` - SELECT id, fn, email, phone - FROM users - WHERE fn ILIKE $1 - OR email ILIKE $1 - OR phone ILIKE $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3 - ` + SELECT u.id, u.fn, u.email, u.phone, u.account_role + FROM users u + LEFT JOIN bookings b ON u.id = b.user_id + WHERE u.fn ILIKE $1 + OR u.email ILIKE $1 + OR u.phone ILIKE $1 + GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at + ORDER BY COUNT(b.id) DESC, u.created_at DESC + LIMIT $2 OFFSET $3 + ` listArgs = []interface{}{searchPattern, perPage, offset} } else { - // No search - get all users + // No search - get all users, sorted by booking count countQuery = `SELECT COUNT(*) FROM users` countArgs = []interface{}{} listQuery = ` - SELECT id, fn, email, phone - FROM users - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - ` + SELECT u.id, u.fn, u.email, u.phone, u.account_role + FROM users u + LEFT JOIN bookings b ON u.id = b.user_id + GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at + ORDER BY COUNT(b.id) DESC, u.created_at DESC + LIMIT $1 OFFSET $2 + ` listArgs = []interface{}{perPage, offset} } @@ -439,7 +444,13 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { var users []UserListItem for rows.Next() { var user UserListItem - err := rows.Scan(&user.ID, &user.FullName, &user.Email, &user.Phone) + err := rows.Scan( + &user.ID, + &user.FullName, + &user.Email, + &user.Phone, + &user.AccountRole, + ) if err != nil { log.Printf("Failed to scan user row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/backend/main.go b/backend/main.go index 579f5dd..dba9540 100644 --- a/backend/main.go +++ b/backend/main.go @@ -133,8 +133,8 @@ func main() { r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler) r.Get("/{id}", bookings.GetAdminBookingHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) - r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) - r.Post("/{id}/cancel", bookings.ConfirmBookingHandler) // todo + r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) // HERE + r.Post("/{id}/cancel", bookings.ConfirmBookingHandler) }) r.Route("/admin/users", func(r chi.Router) { diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte new file mode 100644 index 0000000..29b2c5f --- /dev/null +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -0,0 +1,287 @@ + + + + + +
+
+ Booking Details + {#if selectedBooking} +
ID: {selectedBooking.id}
+ {/if} +
+ + {#if selectedBooking} + + {@const isPastBooking = new Date(selectedBooking.start_time) < new Date()} + {@const isUnpaid = selectedBooking.amount_due > 0} + {@const showChip = !isPastBooking || isUnpaid} + + {#if showChip} + + {isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')} + + {/if} + {/if} +
+
+ + {#if loading} +
Loading...
+ {:else if selectedBooking} +
+ +
+

+ Appointment Details +

+
+
+
Scheduled Date & Time
+
+ {(() => { + const date = new SvelteDate(selectedBooking.start_time); + const dateStr = date.toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'short' + }); + const timeStr = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + return `${dateStr} at ${timeStr}`; + })()} +
+
+
+
Duration
+
{totalDuration} minutes
+
+ {#if selectedBooking.notes} +
+
Notes
+
+ {selectedBooking.notes} +
+
+ {/if} +
+
+ + + {#if selectedBooking.services && selectedBooking.services.length > 0} +
+

+ Services +

+
+ {#each selectedBooking.services as service, index (index)} +
+
{service.service_name || '—'}
+ {#if service.service_description} +
{service.service_description}
+ {/if} +
+ {service.duration_minutes} min + £{service.price?.toFixed(2) || '0.00'} +
+
+ {/each} +
+
+ {/if} + + +
+

+ Financial Summary +

+
+
+ Total Amount + £{selectedBooking.total_amount.toFixed(2)} +
+
+ Amount Paid + £{selectedBooking.amount_paid.toFixed(2)} +
+
+ Amount Due + + £{selectedBooking.amount_due.toFixed(2)} + +
+
+
+ + + {#if selectedBooking.payments && selectedBooking.payments.length > 0} +
+

+ Payment History +

+
+ {#each selectedBooking.payments as payment (payment.id)} +
+
+
+
+ {payment.payment_method.replace('_', ' ')} + + {payment.status} + +
+
+ {payment.payment_type.charAt(0).toUpperCase() + + payment.payment_type.slice(1)} +
+ {#if payment.is_vat_applicable} +
+
Net: £{payment.net_amount?.toFixed(2) || '0.00'}
+ {#if payment.vat_amount} +
+ VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed( + 2 + )} +
+ {/if} +
+ {/if} +
+ {new SvelteDate(payment.created_at).toLocaleString()} +
+
+
+ £{payment.amount.toFixed(2)} +
+
+
+ {/each} +
+
+ {/if} +
+ {/if} + + + + +
+
diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index 0ed1d6e..4825343 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -262,7 +262,7 @@ + + + + + + + + {/if} + + + {#if currentStep === 4} + + + Choose Date & Time + + {selectedServices.map((s) => s.name).join(', ')} • {formattedTotalDuration} total • £{getTotalPrice().toFixed( + 2 + )} + + + + {#if loadingWorkingHours} +
+

Loading available dates...

+
+ {:else} +
+ { + selectedDate = newDate; + selectedTime = null; + }} + onPlaceholderChange={(newPlaceholder) => { + placeholder = newPlaceholder; + }} + /> +
+ {/if} + + {#if loadingAvailableHours} +
+

Loading times...

+
+ {:else if selectedDate} +
+ {#if formattedSelectedDate} +
{formattedSelectedDate}
+ {/if} + + {#if groupedTimeSlots.length > 0} +
+ {#each groupedTimeSlots as slot (slot.startTime)} + {#if slot.type === 'available'} + {@const protection = lunchProtectionStatus().get(slot.startTime)} + {#if protection?.isBlocked} + + + {:else} + + {/if} + {:else} + + {/if} + {/each} +
+ {:else} +

No available slots

+ {/if} +
+ {:else} +
+

+ Select a date to see available times +

+
+ {/if} +
+ + + + + +
+ {/if} - - - - - diff --git a/frontend/src/lib/components/admin/CallInBooking.svelte b/frontend/src/lib/components/admin/CallInBooking.svelte index ace9599..b277cf0 100644 --- a/frontend/src/lib/components/admin/CallInBooking.svelte +++ b/frontend/src/lib/components/admin/CallInBooking.svelte @@ -1,28 +1,17 @@

- Call-In / Walk-In Booking + Call-In / Social Messaging Booking

- Create and confirm a booking immediately while speaking with the client. + Create a booking and check timeslots for a discussed appointed

@@ -31,5 +20,5 @@
{#if showCreateModal} - + {/if} diff --git a/frontend/src/lib/components/admin/WalkInBooking.svelte b/frontend/src/lib/components/admin/WalkInBooking.svelte new file mode 100644 index 0000000..7290189 --- /dev/null +++ b/frontend/src/lib/components/admin/WalkInBooking.svelte @@ -0,0 +1,211 @@ + + +
+

Walk-In Booking

+ + {#if loading} +
+ {:else if noSlotsToday} +

No slots available for walk-in today

+ {:else if slotInfo?.isAvailableNow} + {@const liveRemaining = getLiveRemainingMinutes()} + {#if liveRemaining !== null && liveRemaining > 0} +

+ Available now for {formatDuration(liveRemaining)} +

+ {:else} +

No slots available for walk-in today

+ {/if} + {:else if slotInfo && !slotInfo.isAvailableNow} + {@const liveWait = getLiveWaitMinutes()} + {#if liveWait !== null && liveWait > 0} +

+ Next slot available in {formatDuration(liveWait)} + at {formatTime(slotInfo.startTime!)}, for + {formatDuration(slotInfo.durationMinutes)} +

+ {:else} +

+ Available now for {formatDuration(slotInfo.durationMinutes)} +

+ {/if} + {/if} + +
+ +
+
+ +{#if showCreateModal} + +{/if} diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte new file mode 100644 index 0000000..fa921e6 --- /dev/null +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -0,0 +1,718 @@ + + + + + + Walk-In Booking + + Quickly book a walk-in customer with immediate time slot reservation + + + +
+ + {#if currentStep === 1} + + + Select Customer + Choose an existing member or create a guest booking + + + +
+ + +
+ + {#if userType === 'member'} + +
+
+
+ + + +
+ { + userQuery = e.currentTarget.value; + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + fetchUsers(); + } + }} + /> +
+ +
+ + +
+ {#if loadingUsers} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if users.length === 0} +
+ {userQuery + ? 'No users found. Try a different search.' + : 'Search for a user above to get started.'} +
+ {:else} +
    + {#each users.slice(0, 4) as user (user.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {:else} + +
+
+ + (guestName = e.currentTarget.value)} + /> +
+
+ + (guestPhone = e.currentTarget.value)} + /> +
+

+ Booking as a guest creates a temporary record. Encourage them to sign up for + loyalty benefits. +

+
+ {/if} +
+ + currentStep++} + /> + +
+ {/if} + + + {#if currentStep === 2} + + + Choose Services + Select one or more treatments for this appointment + + + {#if loadingServices} +
+ {#each Array(4) as _} + + {/each} +
+ {:else if services.length === 0} +

No services available.

+ {:else} + + {/if} + + {#if selectedServices.length > 0} +
+

Selected Services

+
+ {#each selectedServices as service (service.id)} +
+ {service.name} + {service.duration_minutes} mins • £{service.price} +
+ {/each} + +
+ Estimated Duration: + {formattedTotalDuration} +
+
+ Total Cost: + £{getTotalPrice()} +
+ {#if maxSlotDuration > 0} + +
+ Available Slot Duration: + + {formatDuration(maxSlotDuration)} + +
+ {#if isOverDuration} +
+ Warning: Selected services ({formattedTotalDuration}) + exceed available slot duration ({formatDuration(maxSlotDuration)}). Please + remove services or customize durations. +
+ {/if} + {/if} +
+
+ {/if} +
+ + + + +
+ {/if} + + + {#if currentStep === 3} + + + Customize Services + + Adjust pricing or duration if needed, and add appointment notes + + + +
+

Service Details

+

+ Override default pricing or duration for special cases (discounts, extended + sessions, etc.) +

+
+ {#each selectedServices as service} + + {#if serviceOverrides[service.id]} +
+
{service.name}
+
+
+ + + handlePriceInput(service.id, e.currentTarget.value)} + /> +
+
+ + + handleDurationInput(service.id, e.currentTarget.value)} + /> +
+
+ {#if serviceOverrides[service.id] && (Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 || parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration)} +
+ {#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01} + Price modified from £{serviceOverrides[ + service.id + ].originalPrice.toFixed(2)} + {/if} + + {#if Math.abs(parseFloat(serviceOverrides[service.id].price) - serviceOverrides[service.id].originalPrice) > 0.01 && parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration} + • + {/if} + + {#if parseInt(serviceOverrides[service.id].duration) !== serviceOverrides[service.id].originalDuration} + Duration modified from {serviceOverrides[service.id].originalDuration} mins + {/if} +
+ {/if} +
+ {/if} + {/each} +
+
+ +
+
+ Total Duration: + {formattedTotalDuration} +
+
+ Total Cost: + £{getTotalPrice().toFixed(2)} +
+
+ +
+ + + +
+
+ + + + +
+ {/if} +
+
+
diff --git a/frontend/src/lib/components/booking/BookingActions.svelte b/frontend/src/lib/components/booking/BookingActions.svelte new file mode 100644 index 0000000..d46d30f --- /dev/null +++ b/frontend/src/lib/components/booking/BookingActions.svelte @@ -0,0 +1,31 @@ + + +
+ + + {#if showSubmit} + + {:else} + + {/if} +
diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte new file mode 100644 index 0000000..d05b763 --- /dev/null +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -0,0 +1,942 @@ + + +
+
+

Book Your Appointment

+

Professional beauty treatments in a calm and friendly environment

+
+ + + + + {#if currentStep === 1} + + + Choose Your Services + Select one or more treatments for your appointment + + + + + {#if selectedServices.length > 0} +
+

Selected Services

+
+ {#each selectedServices as service (service.id)} +
+ {service.name} + {service.duration_minutes} mins • £{service.price} +
+ {/each} + +
+ Estimated Duration: + {formattedTotalDuration} +
+
+ Total Cost: + £{getTotalPrice()} +
+
+
+ {/if} +
+ + + +
+ {/if} + + + {#if currentStep === 2} + + + Choose Date & Time + + {selectedServices.map((s) => s.name).join(', ')} • {formattedTotalDuration} total • £{getTotalPrice()} + + + + + + {#if loadingWorkingHours} +
+

Loading available dates...

+
+ {:else} + { + selectedDate = newDate; + selectedTime = null; + }} + onPlaceholderChange={(newPlaceholder) => { + placeholder = newPlaceholder; + }} + /> + {/if} + + {#if loadingAvailableHours} +
+

Loading times...

+
+ {:else} + { + selectedTime = time; + }} + lunchProtectionStatus={lunchProtectionStatus()} + /> + {/if} +
+
+
+ + +
+ {#if selectedDate && selectedTime} + Appointment for + + {selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'short' + })} + +
at {selectedTime} + {:else} + Select a date and time + {/if} +
+ + + +
+ + + +
+
+
+ {/if} + + + {#if currentStep === 3} + + + Your Details + Please confirm your contact information + + + + + {#if !authStore.isAuthenticated} +

+ You are checking out as a guest, so you will miss out on a loyalty stamp. Please login + for full membership benefits. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + +
+ +