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:
@@ -257,11 +257,33 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
req := parseGetAllBookingsRequest(r)
|
req := parseGetAllBookingsRequest(r)
|
||||||
|
|
||||||
// Build base query with user filter
|
// Build base query with user filter
|
||||||
|
// We now calculate Total Amount, Amount Paid, AND Duration
|
||||||
baseQuery := `
|
baseQuery := `
|
||||||
SELECT id, start_time, status, notes, created_at, updated_at, created_by
|
SELECT
|
||||||
FROM bookings
|
id, start_time, status, notes, created_at, updated_at, created_by,
|
||||||
WHERE user_id = $1
|
-- 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`
|
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
@@ -338,7 +360,16 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var b Booking
|
var b Booking
|
||||||
var createdBy sql.NullString
|
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 {
|
if err != nil {
|
||||||
log.Printf("Failed to scan booking row: %v", err)
|
log.Printf("Failed to scan booking row: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -347,6 +378,12 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if createdBy.Valid {
|
if createdBy.Valid {
|
||||||
b.CreatedBy = &createdBy.String
|
b.CreatedBy = &createdBy.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
b.TotalAmount = totalAmount
|
||||||
|
b.AmountPaid = amountPaid
|
||||||
|
b.AmountDue = totalAmount - amountPaid
|
||||||
|
b.DurationMinutes = durationMinutes // Populate the struct
|
||||||
|
|
||||||
bookings = append(bookings, b)
|
bookings = append(bookings, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1648,14 +1685,17 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 1. Fetch booking
|
// 1. Fetch booking
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
var booking Booking
|
var booking Booking
|
||||||
|
// Initialize slices/maps to avoid null in JSON
|
||||||
booking.Payments = []Payment{}
|
booking.Payments = []Payment{}
|
||||||
|
booking.Services = []BookingService{}
|
||||||
booking.User = &UserSummary{}
|
booking.User = &UserSummary{}
|
||||||
|
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
err := db.DB.QueryRow(r.Context(), `
|
err := db.DB.QueryRow(r.Context(), `
|
||||||
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`, bookingID, userID).Scan(
|
`, bookingID, userID).Scan(
|
||||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
|
||||||
)
|
)
|
||||||
@@ -1713,21 +1753,29 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate Totals based on overrides or base values
|
||||||
|
var priceToAdd float64
|
||||||
|
var durationToAdd int
|
||||||
|
|
||||||
if overridePrice.Valid {
|
if overridePrice.Valid {
|
||||||
s.OverridePrice = &overridePrice.Float64
|
s.OverridePrice = &overridePrice.Float64
|
||||||
totalAmount += *s.OverridePrice
|
priceToAdd = overridePrice.Float64
|
||||||
} else if basePrice.Valid {
|
} else if basePrice.Valid {
|
||||||
totalAmount += basePrice.Float64
|
priceToAdd = basePrice.Float64
|
||||||
}
|
}
|
||||||
|
|
||||||
if overrideDuration.Valid {
|
if overrideDuration.Valid {
|
||||||
d := int(overrideDuration.Int32)
|
d := int(overrideDuration.Int32)
|
||||||
s.OverrideDurationMinutes = &d
|
s.OverrideDurationMinutes = &d
|
||||||
durationMinutes += *s.OverrideDurationMinutes
|
durationToAdd = d
|
||||||
} else if baseDuration.Valid {
|
} else if baseDuration.Valid {
|
||||||
durationMinutes += int(baseDuration.Int32)
|
durationToAdd = int(baseDuration.Int32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalAmount += priceToAdd
|
||||||
|
durationMinutes += durationToAdd
|
||||||
|
|
||||||
|
// Map nullable strings to pointers
|
||||||
if name.Valid {
|
if name.Valid {
|
||||||
s.ServiceName = &name.String
|
s.ServiceName = &name.String
|
||||||
}
|
}
|
||||||
@@ -1810,27 +1858,23 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
booking.Payments = append(booking.Payments, p)
|
booking.Payments = append(booking.Payments, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
amountDue := totalAmount - amountPaid
|
// ----------------------------
|
||||||
|
// 4. Assign calculated totals to Booking Struct
|
||||||
// Create enhanced response with user-friendly totals
|
// ----------------------------
|
||||||
enhancedResponse := struct {
|
booking.TotalAmount = totalAmount
|
||||||
Booking Booking `json:"booking"`
|
booking.AmountPaid = amountPaid
|
||||||
TotalAmount float64 `json:"total_amount"`
|
booking.AmountDue = totalAmount - amountPaid
|
||||||
AmountPaid float64 `json:"amount_paid"`
|
booking.DurationMinutes = durationMinutes
|
||||||
AmountDue float64 `json:"amount_due"`
|
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
|
||||||
}{
|
|
||||||
Booking: booking,
|
|
||||||
TotalAmount: totalAmount,
|
|
||||||
AmountPaid: amountPaid,
|
|
||||||
AmountDue: amountDue,
|
|
||||||
DurationMinutes: durationMinutes,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// 5. Return Response
|
||||||
|
// ----------------------------
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
log.Printf("Failed to encode booking response: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,10 +173,10 @@ type AdminCreateBookingForUserRequest struct {
|
|||||||
StartTime time.Time `json:"start_time" validate:"required"`
|
StartTime time.Time `json:"start_time" validate:"required"`
|
||||||
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
||||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
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)
|
// Admin identity (creator)
|
||||||
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||||
if !ok || adminID == "" {
|
if !ok || adminID == "" {
|
||||||
|
|||||||
@@ -78,10 +78,11 @@ type SocialLogin struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserListItem struct {
|
type UserListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
AccountRole string `json:"account_role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserListResponse struct {
|
type UserListResponse struct {
|
||||||
@@ -386,35 +387,39 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
searchPattern := "%" + searchTerm + "%"
|
searchPattern := "%" + searchTerm + "%"
|
||||||
|
|
||||||
countQuery = `
|
countQuery = `
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM users
|
FROM users
|
||||||
WHERE fn ILIKE $1
|
WHERE fn ILIKE $1
|
||||||
OR email ILIKE $1
|
OR email ILIKE $1
|
||||||
OR phone ILIKE $1
|
OR phone ILIKE $1
|
||||||
`
|
`
|
||||||
countArgs = []interface{}{searchPattern}
|
countArgs = []interface{}{searchPattern}
|
||||||
|
|
||||||
listQuery = `
|
listQuery = `
|
||||||
SELECT id, fn, email, phone
|
SELECT u.id, u.fn, u.email, u.phone, u.account_role
|
||||||
FROM users
|
FROM users u
|
||||||
WHERE fn ILIKE $1
|
LEFT JOIN bookings b ON u.id = b.user_id
|
||||||
OR email ILIKE $1
|
WHERE u.fn ILIKE $1
|
||||||
OR phone ILIKE $1
|
OR u.email ILIKE $1
|
||||||
ORDER BY created_at DESC
|
OR u.phone ILIKE $1
|
||||||
LIMIT $2 OFFSET $3
|
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}
|
listArgs = []interface{}{searchPattern, perPage, offset}
|
||||||
} else {
|
} else {
|
||||||
// No search - get all users
|
// No search - get all users, sorted by booking count
|
||||||
countQuery = `SELECT COUNT(*) FROM users`
|
countQuery = `SELECT COUNT(*) FROM users`
|
||||||
countArgs = []interface{}{}
|
countArgs = []interface{}{}
|
||||||
|
|
||||||
listQuery = `
|
listQuery = `
|
||||||
SELECT id, fn, email, phone
|
SELECT u.id, u.fn, u.email, u.phone, u.account_role
|
||||||
FROM users
|
FROM users u
|
||||||
ORDER BY created_at DESC
|
LEFT JOIN bookings b ON u.id = b.user_id
|
||||||
LIMIT $1 OFFSET $2
|
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}
|
listArgs = []interface{}{perPage, offset}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +444,13 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var users []UserListItem
|
var users []UserListItem
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user UserListItem
|
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 {
|
if err != nil {
|
||||||
log.Printf("Failed to scan user row: %v", err)
|
log.Printf("Failed to scan user row: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|||||||
+2
-2
@@ -133,8 +133,8 @@ func main() {
|
|||||||
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
|
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
|
||||||
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
||||||
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
||||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) // HERE
|
||||||
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler) // todo
|
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/admin/users", func(r chi.Router) {
|
r.Route("/admin/users", func(r chi.Router) {
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
bookingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(), bookingId }: Props = $props();
|
||||||
|
|
||||||
|
type Booking = {
|
||||||
|
id: string;
|
||||||
|
start_time: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
services: Array<{
|
||||||
|
service_name?: string;
|
||||||
|
service_description?: string;
|
||||||
|
price?: number;
|
||||||
|
duration_minutes?: number;
|
||||||
|
}>;
|
||||||
|
payments: Array<{
|
||||||
|
id: string;
|
||||||
|
payment_type: string;
|
||||||
|
payment_method: string;
|
||||||
|
status: string;
|
||||||
|
amount: number;
|
||||||
|
created_at: string;
|
||||||
|
invoice_number?: number;
|
||||||
|
is_vat_applicable: boolean;
|
||||||
|
vat_amount?: number;
|
||||||
|
net_amount?: number;
|
||||||
|
vat_rate?: number;
|
||||||
|
}>;
|
||||||
|
total_amount: number;
|
||||||
|
amount_paid: number;
|
||||||
|
amount_due: number;
|
||||||
|
duration_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let selectedBooking = $state<Booking | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
let totalDuration = $derived(
|
||||||
|
selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) ||
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchBookingDetails() {
|
||||||
|
if (!bookingId) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookings/${bookingId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
selectedBooking = data;
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
toast.error('Failed to load booking: ' + text);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching booking:', err);
|
||||||
|
toast.error('Network error');
|
||||||
|
open = false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTimeout(() => (selectedBooking = null), 200);
|
||||||
|
} else if (bookingId && !selectedBooking) {
|
||||||
|
fetchBookingDetails();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal.Root bind:open>
|
||||||
|
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
|
||||||
|
<Modal.Header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
|
||||||
|
{#if selectedBooking}
|
||||||
|
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedBooking}
|
||||||
|
<!-- Logic: Only show chip if Booking is Future OR (Past AND Unpaid) -->
|
||||||
|
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
|
||||||
|
{@const isUnpaid = selectedBooking.amount_due > 0}
|
||||||
|
{@const showChip = !isPastBooking || isUnpaid}
|
||||||
|
|
||||||
|
{#if showChip}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||||
|
{isPastBooking
|
||||||
|
? 'bg-red-100 text-red-800' // Red if past & unpaid
|
||||||
|
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
|
||||||
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
|
: selectedBooking.status === 'pending'
|
||||||
|
? 'bg-amber-100 text-amber-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'}"
|
||||||
|
>
|
||||||
|
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center p-8 text-gray-500">Loading...</div>
|
||||||
|
{:else if selectedBooking}
|
||||||
|
<div class="space-y-6 px-4 pb-4">
|
||||||
|
<!-- Appointment Details -->
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
|
Appointment Details
|
||||||
|
</h3>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{(() => {
|
||||||
|
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}`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Duration</div>
|
||||||
|
<div class="font-medium">{totalDuration} minutes</div>
|
||||||
|
</div>
|
||||||
|
{#if selectedBooking.notes}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-xs text-gray-500">Notes</div>
|
||||||
|
<div class="mt-1 rounded-md border border-gray-300 bg-white p-2 text-sm">
|
||||||
|
{selectedBooking.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
{#if selectedBooking.services && selectedBooking.services.length > 0}
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
|
Services
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each selectedBooking.services as service, index (index)}
|
||||||
|
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||||
|
<div class="font-medium">{service.service_name || '—'}</div>
|
||||||
|
{#if service.service_description}
|
||||||
|
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-2 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600">{service.duration_minutes} min</span>
|
||||||
|
<span class="font-semibold">£{service.price?.toFixed(2) || '0.00'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Financial Summary -->
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
|
Financial Summary
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Total Amount</span>
|
||||||
|
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Amount Paid</span>
|
||||||
|
<span class="font-semibold text-green-700"
|
||||||
|
>£{selectedBooking.amount_paid.toFixed(2)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||||
|
<span class="font-medium text-gray-900">Amount Due</span>
|
||||||
|
<span
|
||||||
|
class="text-lg font-bold {selectedBooking.amount_due > 0
|
||||||
|
? 'text-red-600'
|
||||||
|
: 'text-green-600'}"
|
||||||
|
>
|
||||||
|
£{selectedBooking.amount_due.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payments -->
|
||||||
|
{#if selectedBooking.payments && selectedBooking.payments.length > 0}
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
|
Payment History
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each selectedBooking.payments as payment (payment.id)}
|
||||||
|
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium capitalize"
|
||||||
|
>{payment.payment_method.replace('_', ' ')}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||||
|
{payment.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: payment.status === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'}"
|
||||||
|
>
|
||||||
|
{payment.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500">
|
||||||
|
{payment.payment_type.charAt(0).toUpperCase() +
|
||||||
|
payment.payment_type.slice(1)}
|
||||||
|
</div>
|
||||||
|
{#if payment.is_vat_applicable}
|
||||||
|
<div class="mt-2 text-xs text-gray-600">
|
||||||
|
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||||
|
{#if payment.vat_amount}
|
||||||
|
<div>
|
||||||
|
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed(
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-1 text-xs text-gray-400">
|
||||||
|
{new SvelteDate(payment.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right font-semibold">
|
||||||
|
£{payment.amount.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
|
<Button onclick={() => (open = false)}>Close</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="booking-notes"
|
id="booking-notes"
|
||||||
bind:value={notes}
|
bind:value={notes}
|
||||||
placeholder="Add any notes about this booking... (client will see this)"
|
placeholder="Add any notes about this booking... (client will see this, appears on receipt)"
|
||||||
rows={3}
|
rows={3}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
|
||||||
import BookingCreateModal from '$lib/components/admin/BookingCreateModal.svelte';
|
import BookingCreateModal from '$lib/components/admin/BookingCreateModal.svelte';
|
||||||
|
|
||||||
let showCreateModal = false;
|
let showCreateModal = false;
|
||||||
let selectedUserId: string | null = null;
|
|
||||||
|
|
||||||
function openForUser(userId: string) {
|
|
||||||
selectedUserId = userId;
|
|
||||||
showCreateModal = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Call-In / Walk-In Booking
|
Call-In / Social Messaging Booking
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="mb-4 text-sm text-gray-500">
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
Create and confirm a booking immediately while speaking with the client.
|
Create a booking and check timeslots for a discussed appointed
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -31,5 +20,5 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateModal}
|
{#if showCreateModal}
|
||||||
<BookingCreateModal bind:open={showCreateModal} initialUserId={selectedUserId} />
|
<BookingCreateModal bind:open={showCreateModal} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import WalkInCreateModal from '$lib/components/admin/WalkInCreateModal.svelte';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { CalendarDate } from '@internationalized/date';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import type { AvailableHoursDay } from '$lib/types/booking';
|
||||||
|
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let slotInfo = $state<{
|
||||||
|
isAvailableNow: boolean;
|
||||||
|
waitMinutes?: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
startTime?: string;
|
||||||
|
slotEndMinutes?: number; // Store for live countdown
|
||||||
|
} | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let noSlotsToday = $state(false);
|
||||||
|
let currentTime = $state(new Date());
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
calculateSlotAvailability();
|
||||||
|
|
||||||
|
// Update current time every minute for live countdown
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
currentTime = new Date();
|
||||||
|
}, 60000); // Update every minute
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts "HH:MM" or "HH:MM:SS" time string to minutes since midnight
|
||||||
|
*/
|
||||||
|
function timeToMinutes(time: string): number {
|
||||||
|
const parts = time.split(':').map(Number);
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts minutes to hours and minutes for display
|
||||||
|
*/
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0 && mins > 0) {
|
||||||
|
return `${hours} hour${hours !== 1 ? 's' : ''}, ${mins} minute${mins !== 1 ? 's' : ''}`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
return `${mins} minute${mins !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate live remaining time based on current time
|
||||||
|
*/
|
||||||
|
function getLiveRemainingMinutes(): number | null {
|
||||||
|
if (!slotInfo?.isAvailableNow || !slotInfo.slotEndMinutes) return null;
|
||||||
|
|
||||||
|
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||||
|
const remaining = slotInfo.slotEndMinutes - now;
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate live wait time based on current time
|
||||||
|
*/
|
||||||
|
function getLiveWaitMinutes(): number | null {
|
||||||
|
if (slotInfo?.isAvailableNow || !slotInfo?.startTime) return null;
|
||||||
|
|
||||||
|
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||||
|
const slotStartMinutes = timeToMinutes(slotInfo.startTime);
|
||||||
|
const wait = slotStartMinutes - now;
|
||||||
|
return Math.max(0, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calculateSlotAvailability() {
|
||||||
|
loading = true;
|
||||||
|
noSlotsToday = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new SvelteDate();
|
||||||
|
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||||
|
|
||||||
|
// Fetch today's available hours
|
||||||
|
const response = await fetch(`/api/scheduling/available-hours?start=${today}&end=${today}`, {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
noSlotsToday = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: AvailableHoursDay[] = await response.json();
|
||||||
|
const todayData = data[0];
|
||||||
|
|
||||||
|
if (!todayData || !todayData.isOpen || !todayData.slots || todayData.slots.length === 0) {
|
||||||
|
noSlotsToday = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current time in minutes since midnight
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
|
// Check if we're currently in an available slot
|
||||||
|
for (const slot of todayData.slots) {
|
||||||
|
const slotStartMinutes = timeToMinutes(slot.startTime);
|
||||||
|
const slotEndMinutes = timeToMinutes(slot.endTime);
|
||||||
|
|
||||||
|
// Are we currently within this slot?
|
||||||
|
if (currentMinutes >= slotStartMinutes && currentMinutes < slotEndMinutes) {
|
||||||
|
const remainingMinutes = slotEndMinutes - currentMinutes;
|
||||||
|
slotInfo = {
|
||||||
|
isAvailableNow: true,
|
||||||
|
durationMinutes: remainingMinutes,
|
||||||
|
slotEndMinutes: slotEndMinutes // Store for live countdown
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this a future slot?
|
||||||
|
if (slotStartMinutes > currentMinutes) {
|
||||||
|
const waitMinutes = slotStartMinutes - currentMinutes;
|
||||||
|
const durationMinutes = slotEndMinutes - slotStartMinutes;
|
||||||
|
slotInfo = {
|
||||||
|
isAvailableNow: false,
|
||||||
|
waitMinutes,
|
||||||
|
durationMinutes,
|
||||||
|
startTime: slot.startTime
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No current or future slots available
|
||||||
|
noSlotsToday = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to calculate slot availability', err);
|
||||||
|
noSlotsToday = true;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string): string {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">Walk-In Booking</h3>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="mb-4 h-12 animate-pulse rounded bg-gray-100"></div>
|
||||||
|
{:else if noSlotsToday}
|
||||||
|
<p class="mb-4 text-sm text-gray-500">No slots available for walk-in today</p>
|
||||||
|
{:else if slotInfo?.isAvailableNow}
|
||||||
|
{@const liveRemaining = getLiveRemainingMinutes()}
|
||||||
|
{#if liveRemaining !== null && liveRemaining > 0}
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
Available now for <span class="font-semibold text-gray-700"
|
||||||
|
>{formatDuration(liveRemaining)}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-sm text-gray-500">No slots available for walk-in today</p>
|
||||||
|
{/if}
|
||||||
|
{:else if slotInfo && !slotInfo.isAvailableNow}
|
||||||
|
{@const liveWait = getLiveWaitMinutes()}
|
||||||
|
{#if liveWait !== null && liveWait > 0}
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
Next slot available in <span class="font-semibold text-gray-700"
|
||||||
|
>{formatDuration(liveWait)}</span
|
||||||
|
>
|
||||||
|
at {formatTime(slotInfo.startTime!)}, for
|
||||||
|
<span class="font-semibold text-gray-700">{formatDuration(slotInfo.durationMinutes)}</span>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
Available now for <span class="font-semibold text-gray-700"
|
||||||
|
>{formatDuration(slotInfo.durationMinutes)}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onclick={() => (showCreateModal = true)}
|
||||||
|
disabled={noSlotsToday || (slotInfo?.isAvailableNow && (getLiveRemainingMinutes() ?? 0) <= 0)}
|
||||||
|
>
|
||||||
|
Start Walk-In Session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateModal}
|
||||||
|
<WalkInCreateModal
|
||||||
|
bind:open={showCreateModal}
|
||||||
|
maxSlotDuration={slotInfo?.durationMinutes ?? 0}
|
||||||
|
availableStartTime={slotInfo?.isAvailableNow ? undefined : slotInfo?.startTime}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { getLocalTimeZone } from '@internationalized/date';
|
||||||
|
|
||||||
|
// UI Components
|
||||||
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
|
||||||
|
// Booking Components
|
||||||
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
|
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Service } from '$lib/types/booking';
|
||||||
|
|
||||||
|
// =============== Props ===============
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
maxSlotDuration?: number;
|
||||||
|
availableStartTime?: string; // "HH:MM" or "HH:MM:SS" format from the available slot
|
||||||
|
onBookingCreated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
maxSlotDuration = 0,
|
||||||
|
availableStartTime,
|
||||||
|
onBookingCreated
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// =============== State ===============
|
||||||
|
let currentStep = $state(1);
|
||||||
|
|
||||||
|
// Step 1: Customer Selection
|
||||||
|
let userType = $state<'member' | 'guest'>('member');
|
||||||
|
let userQuery = $state('');
|
||||||
|
let users = $state<
|
||||||
|
Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string }>
|
||||||
|
>([]);
|
||||||
|
let selectedUserId = $state<string | null>(null);
|
||||||
|
let guestName = $state('');
|
||||||
|
let guestPhone = $state('');
|
||||||
|
let loadingUsers = $state(false);
|
||||||
|
|
||||||
|
// Step 2: Services
|
||||||
|
let services = $state<Service[]>([]);
|
||||||
|
let selectedServices = $state<Service[]>([]);
|
||||||
|
let loadingServices = $state(true);
|
||||||
|
|
||||||
|
// Step 3: Service Overrides & Notes
|
||||||
|
let notes = $state('');
|
||||||
|
let serviceOverrides = $state<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{ price: string; duration: string; originalPrice: number; originalDuration: number }
|
||||||
|
>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// =============== Derived Helpers ===============
|
||||||
|
function getTotalDuration() {
|
||||||
|
return selectedServices.reduce((total, service) => {
|
||||||
|
const override = serviceOverrides[service.id];
|
||||||
|
const duration =
|
||||||
|
override && override.duration ? parseInt(override.duration) : service.duration_minutes;
|
||||||
|
return total + (isNaN(duration) ? 0 : duration);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalPrice() {
|
||||||
|
return selectedServices.reduce((total, service) => {
|
||||||
|
const override = serviceOverrides[service.id];
|
||||||
|
const price = override && override.price ? parseFloat(override.price) : service.price;
|
||||||
|
return total + (isNaN(price) ? 0 : price);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0 && mins > 0) {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else {
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||||
|
|
||||||
|
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
|
||||||
|
|
||||||
|
const canProceedStep1 = $derived(
|
||||||
|
userType === 'member' ? !!selectedUserId : !!(guestName.trim() && guestPhone.trim())
|
||||||
|
);
|
||||||
|
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
|
||||||
|
|
||||||
|
// =============== Effects ===============
|
||||||
|
let wasOpen = false;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && !wasOpen) {
|
||||||
|
resetState();
|
||||||
|
fetchServices();
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
wasOpen = open;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
currentStep = 1;
|
||||||
|
userType = 'member';
|
||||||
|
userQuery = '';
|
||||||
|
users = [];
|
||||||
|
selectedUserId = null;
|
||||||
|
guestName = '';
|
||||||
|
guestPhone = '';
|
||||||
|
selectedServices = [];
|
||||||
|
notes = '';
|
||||||
|
serviceOverrides = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Data Fetching ===============
|
||||||
|
async function fetchUsers() {
|
||||||
|
loadingUsers = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Filter out specific roles
|
||||||
|
const excludedRoles = ['admin', 'guest', 'affiliate'];
|
||||||
|
users = (data.users || []).filter(
|
||||||
|
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch users', err);
|
||||||
|
toast.error('Failed to load users');
|
||||||
|
} finally {
|
||||||
|
loadingUsers = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchServices() {
|
||||||
|
loadingServices = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/services', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
services = await response.json();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch services', err);
|
||||||
|
toast.error('Failed to load services');
|
||||||
|
} finally {
|
||||||
|
loadingServices = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Logic ===============
|
||||||
|
function toggleService(service: Service) {
|
||||||
|
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||||
|
const newOverrides = { ...serviceOverrides };
|
||||||
|
delete newOverrides[service.id];
|
||||||
|
serviceOverrides = newOverrides;
|
||||||
|
} else {
|
||||||
|
selectedServices = [...selectedServices, service];
|
||||||
|
serviceOverrides = {
|
||||||
|
...serviceOverrides,
|
||||||
|
[service.id]: {
|
||||||
|
price: service.price.toFixed(2),
|
||||||
|
duration: service.duration_minutes.toString(),
|
||||||
|
originalPrice: service.price,
|
||||||
|
originalDuration: service.duration_minutes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Submission ===============
|
||||||
|
async function submitBooking() {
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate duration doesn't exceed available slot
|
||||||
|
if (maxSlotDuration > 0 && getTotalDuration() > maxSlotDuration) {
|
||||||
|
toast.error(
|
||||||
|
`Selected services (${formattedTotalDuration}) exceed available slot (${formatDuration(maxSlotDuration)})`
|
||||||
|
);
|
||||||
|
submitting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalUserId = selectedUserId;
|
||||||
|
|
||||||
|
if (userType === 'guest') {
|
||||||
|
// TODO: Implement /api/users/guest endpoint
|
||||||
|
// For now, show error
|
||||||
|
toast.error('Guest booking not yet implemented');
|
||||||
|
return;
|
||||||
|
|
||||||
|
// const createRes = await fetch('/api/users/guest', {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({
|
||||||
|
// name: guestName,
|
||||||
|
// phone: guestPhone
|
||||||
|
// })
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!createRes.ok) {
|
||||||
|
// toast.error('Failed to create guest user');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const guestUser = await createRes.json();
|
||||||
|
// finalUserId = guestUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalUserId) throw new Error('User ID required');
|
||||||
|
|
||||||
|
// Use the available slot start time from the widget
|
||||||
|
let start: Date;
|
||||||
|
|
||||||
|
if (availableStartTime) {
|
||||||
|
// Parse the time from the widget (format: "HH:MM" or "HH:MM:SS")
|
||||||
|
const [hours, minutes] = availableStartTime.split(':').map(Number);
|
||||||
|
const now = new SvelteDate();
|
||||||
|
start = new SvelteDate(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback: Calculate immediate start time (rounded to next 15 min)
|
||||||
|
const now = new SvelteDate();
|
||||||
|
start = new SvelteDate(now);
|
||||||
|
const minutes = start.getMinutes();
|
||||||
|
const remainder = 15 - (minutes % 15);
|
||||||
|
if (remainder !== 15 && remainder !== 0) {
|
||||||
|
start.setMinutes(minutes + remainder);
|
||||||
|
}
|
||||||
|
start.setSeconds(0);
|
||||||
|
start.setMilliseconds(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTimeStr = start.toISOString();
|
||||||
|
|
||||||
|
const overrides = [];
|
||||||
|
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
|
||||||
|
const priceChanged = Math.abs(parseFloat(data.price) - data.originalPrice) > 0.01;
|
||||||
|
const durationChanged = parseInt(data.duration) !== data.originalDuration;
|
||||||
|
|
||||||
|
if (priceChanged || durationChanged) {
|
||||||
|
overrides.push({
|
||||||
|
service_id: serviceId,
|
||||||
|
override_price: priceChanged ? parseFloat(data.price) : null,
|
||||||
|
override_duration_minutes: durationChanged ? parseInt(data.duration) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
user_id: finalUserId,
|
||||||
|
start_time: dateTimeStr,
|
||||||
|
service_ids: selectedServices.map((s) => s.id),
|
||||||
|
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||||
|
notes: notes.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Booking created successfully!');
|
||||||
|
open = false;
|
||||||
|
onBookingCreated?.();
|
||||||
|
} else {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error('Booking creation failed:', errorText);
|
||||||
|
toast.error(`Failed to create booking: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Booking submission error:', err);
|
||||||
|
toast.error('An error occurred while creating booking');
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handlers
|
||||||
|
function handlePriceInput(serviceId: string, value: string) {
|
||||||
|
const override = serviceOverrides[serviceId];
|
||||||
|
if (!override) return;
|
||||||
|
|
||||||
|
let cleaned = value.replace(/[^\d.]/g, '');
|
||||||
|
const parts = cleaned.split('.');
|
||||||
|
if (parts.length > 2) cleaned = parts[0] + '.' + parts.slice(1).join('');
|
||||||
|
if (cleaned.includes('.')) {
|
||||||
|
const [int, dec] = cleaned.split('.');
|
||||||
|
cleaned = int + '.' + dec.substring(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, price: cleaned } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDurationInput(serviceId: string, value: string) {
|
||||||
|
const override = serviceOverrides[serviceId];
|
||||||
|
if (!override) return;
|
||||||
|
|
||||||
|
const cleaned = value.replace(/\D/g, '');
|
||||||
|
serviceOverrides = { ...serviceOverrides, [serviceId]: { ...override, duration: cleaned } };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal.Root bind:open>
|
||||||
|
<Modal.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Walk-In Booking</Modal.Title>
|
||||||
|
<Modal.Description>
|
||||||
|
Quickly book a walk-in customer with immediate time slot reservation
|
||||||
|
</Modal.Description>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<div class="px-6 pb-4">
|
||||||
|
<!-- Step 1: Customer Selection -->
|
||||||
|
{#if currentStep === 1}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Select Customer</Card.Title>
|
||||||
|
<Card.Description>Choose an existing member or create a guest booking</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex gap-6 border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
class="pb-2 text-sm font-medium transition-colors {userType === 'member'
|
||||||
|
? 'border-b-2 border-primary text-primary'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'}"
|
||||||
|
onclick={() => {
|
||||||
|
userType = 'member';
|
||||||
|
selectedUserId = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Member
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pb-2 text-sm font-medium transition-colors {userType === 'guest'
|
||||||
|
? 'border-b-2 border-primary text-primary'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'}"
|
||||||
|
onclick={() => {
|
||||||
|
userType = 'guest';
|
||||||
|
guestName = '';
|
||||||
|
guestPhone = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Guest / Non-Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if userType === 'member'}
|
||||||
|
<!-- Native Input using oninput to prevent reactivity bugs -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
placeholder="Search by name, email or phone..."
|
||||||
|
value={userQuery}
|
||||||
|
oninput={(e) => {
|
||||||
|
userQuery = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onclick={fetchUsers} disabled={loadingUsers}>
|
||||||
|
{loadingUsers ? '...' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Results List -->
|
||||||
|
<div class="max-h-[300px] overflow-y-auto rounded-md border border-gray-200">
|
||||||
|
{#if loadingUsers}
|
||||||
|
<div class="space-y-2 p-2">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if users.length === 0}
|
||||||
|
<div class="flex items-center justify-center p-8 text-sm text-gray-500">
|
||||||
|
{userQuery
|
||||||
|
? 'No users found. Try a different search.'
|
||||||
|
: 'Search for a user above to get started.'}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
{#each users.slice(0, 4) as user (user.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-left transition-colors hover:bg-fuchsia-50 {selectedUserId ===
|
||||||
|
user.id
|
||||||
|
? 'bg-fuchsia-100 font-medium'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => (selectedUserId = user.id)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-medium">{user.full_name}</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{#if user.email && user.phone}
|
||||||
|
{user.email} • {user.phone}
|
||||||
|
{:else if user.email}
|
||||||
|
{user.email}
|
||||||
|
{:else if user.phone}
|
||||||
|
{user.phone}
|
||||||
|
{:else}
|
||||||
|
No contact info
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if selectedUserId === user.id}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 text-primary"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Guest Form - Using Native Input -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="guest-name">Guest Name *</Label>
|
||||||
|
<input
|
||||||
|
id="guest-name"
|
||||||
|
type="text"
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
value={guestName}
|
||||||
|
oninput={(e) => (guestName = e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="guest-phone">Phone Number *</Label>
|
||||||
|
<input
|
||||||
|
id="guest-phone"
|
||||||
|
type="tel"
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
placeholder="07700 900000"
|
||||||
|
value={guestPhone}
|
||||||
|
oninput={(e) => (guestPhone = e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
|
||||||
|
Booking as a guest creates a temporary record. Encourage them to sign up for
|
||||||
|
loyalty benefits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex justify-end">
|
||||||
|
<BookingActions
|
||||||
|
canBack={false}
|
||||||
|
canNext={canProceedStep1}
|
||||||
|
nextLabel="Next: Choose Services"
|
||||||
|
on:next={() => currentStep++}
|
||||||
|
/>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 2: Service Selection -->
|
||||||
|
{#if currentStep === 2}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Choose Services</Card.Title>
|
||||||
|
<Card.Description>Select one or more treatments for this appointment</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
{#if loadingServices}
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<Skeleton class="h-28 w-full" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if services.length === 0}
|
||||||
|
<p class="py-8 text-center text-gray-500">No services available.</p>
|
||||||
|
{:else}
|
||||||
|
<ServiceSelector
|
||||||
|
{services}
|
||||||
|
selected={selectedServices}
|
||||||
|
loading={loadingServices}
|
||||||
|
ontoggle={toggleService}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedServices.length > 0}
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4">
|
||||||
|
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each selectedServices as service (service.id)}
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>{service.name}</span>
|
||||||
|
<span>{service.duration_minutes} mins • £{service.price}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Separator class="my-2" />
|
||||||
|
<div class="flex justify-between text-sm font-semibold">
|
||||||
|
<span>Estimated Duration:</span>
|
||||||
|
<span>{formattedTotalDuration}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm font-semibold">
|
||||||
|
<span>Total Cost:</span>
|
||||||
|
<span>£{getTotalPrice()}</span>
|
||||||
|
</div>
|
||||||
|
{#if maxSlotDuration > 0}
|
||||||
|
<Separator class="my-2" />
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>Available Slot Duration:</span>
|
||||||
|
<span class={isOverDuration ? 'font-semibold text-red-600' : ''}>
|
||||||
|
{formatDuration(maxSlotDuration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if isOverDuration}
|
||||||
|
<div class="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
<strong>Warning:</strong> Selected services ({formattedTotalDuration})
|
||||||
|
exceed available slot duration ({formatDuration(maxSlotDuration)}). Please
|
||||||
|
remove services or customize durations.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex justify-between">
|
||||||
|
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
||||||
|
<Button disabled={!canProceedStep2} onclick={() => currentStep++}>
|
||||||
|
Next: Customize Services
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 3: Service Overrides & Notes -->
|
||||||
|
{#if currentStep === 3}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Customize Services</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Adjust pricing or duration if needed, and add appointment notes
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-3 font-semibold">Service Details</h4>
|
||||||
|
<p class="mb-4 text-sm text-gray-600">
|
||||||
|
Override default pricing or duration for special cases (discounts, extended
|
||||||
|
sessions, etc.)
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each selectedServices as service}
|
||||||
|
<!-- Safety check to ensure override exists -->
|
||||||
|
{#if serviceOverrides[service.id]}
|
||||||
|
<div class="rounded-lg border bg-white p-4">
|
||||||
|
<div class="mb-3 font-medium">{service.name}</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="price-{service.id}" class="text-xs text-gray-600"
|
||||||
|
>Price (£)</Label
|
||||||
|
>
|
||||||
|
<!-- Native Input with oninput -->
|
||||||
|
<input
|
||||||
|
id="price-{service.id}"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
value={serviceOverrides[service.id]?.price || service.price.toFixed(2)}
|
||||||
|
oninput={(e) => handlePriceInput(service.id, e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="duration-{service.id}" class="text-xs text-gray-600"
|
||||||
|
>Duration (min)</Label
|
||||||
|
>
|
||||||
|
<!-- Native Input with oninput -->
|
||||||
|
<input
|
||||||
|
id="duration-{service.id}"
|
||||||
|
type="number"
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
value={serviceOverrides[service.id]?.duration ||
|
||||||
|
service.duration_minutes}
|
||||||
|
oninput={(e) => handleDurationInput(service.id, e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#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)}
|
||||||
|
<div class="mt-2 text-xs text-amber-600">
|
||||||
|
{#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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4">
|
||||||
|
<div class="flex justify-between text-sm font-semibold">
|
||||||
|
<span>Total Duration:</span>
|
||||||
|
<span>{formattedTotalDuration}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex justify-between text-sm font-semibold">
|
||||||
|
<span>Total Cost:</span>
|
||||||
|
<span>£{getTotalPrice().toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="notes">Appointment Notes (extras only, client will see this)</Label>
|
||||||
|
<!-- Native Textarea -->
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
bind:value={notes}
|
||||||
|
placeholder="Any special requirements, preferences, or notes about this booking..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex justify-between">
|
||||||
|
<Button variant="outline" onclick={() => currentStep--}>Back</Button>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
onclick={submitBooking}
|
||||||
|
class="bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating Booking...' : 'Create Walk-In Booking'}
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
export let canBack = false;
|
||||||
|
export let canNext = false;
|
||||||
|
export let isSubmitting = false;
|
||||||
|
export let nextLabel = 'Next';
|
||||||
|
export let submitLabel = 'Submit';
|
||||||
|
export let showSubmit = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<Button variant="outline" disabled={!canBack} onclick={() => dispatch('back')}>Back</Button>
|
||||||
|
|
||||||
|
{#if showSubmit}
|
||||||
|
<Button
|
||||||
|
disabled={!canNext || isSubmitting}
|
||||||
|
onclick={() => dispatch('submit')}
|
||||||
|
class="bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Processing...' : submitLabel}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button disabled={!canNext} onclick={() => dispatch('next')}>
|
||||||
|
{nextLabel}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { SvelteMap, SvelteDate } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import BookingActions from '$lib/components/booking/BookingActions.svelte';
|
||||||
|
import BookingSummary from '$lib/components/booking/BookingSummary.svelte';
|
||||||
|
import StepIndicator from '$lib/components/booking/StepIndicator.svelte';
|
||||||
|
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||||
|
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
||||||
|
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||||
|
import {
|
||||||
|
extractBookedSlots,
|
||||||
|
getLunchProtectionForSlots,
|
||||||
|
type TimeSlot
|
||||||
|
} from '$lib/lunchProtection';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Service,
|
||||||
|
CustomerInfo,
|
||||||
|
WorkingHoursDay,
|
||||||
|
AvailableHoursDay
|
||||||
|
} from '$lib/types/booking';
|
||||||
|
|
||||||
|
// =============== State Management ===============
|
||||||
|
let currentStep = $state<number>(1);
|
||||||
|
let selectedServices = $state<Service[]>([]);
|
||||||
|
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||||
|
let selectedTime = $state<string | null>(null);
|
||||||
|
let customerInfo = $state<CustomerInfo>({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
specialRequests: ''
|
||||||
|
});
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
// =============== Services Management ===============
|
||||||
|
let services = $state<Service[]>([]);
|
||||||
|
let servicesLoading = $state(true);
|
||||||
|
|
||||||
|
async function fetchServices() {
|
||||||
|
servicesLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/services', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: Service[] = await response.json();
|
||||||
|
services = data;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch services:', response.status);
|
||||||
|
toast.error('Failed to load services');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching services:', err);
|
||||||
|
toast.error('Network error loading services');
|
||||||
|
} finally {
|
||||||
|
servicesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== ADD: Lunch Protection ===============
|
||||||
|
const lunchProtectionStatus = $derived(() => {
|
||||||
|
if (!selectedDate || !workingHours || !availableHours || selectedServices.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = selectedDate.toString();
|
||||||
|
const dayWorkingHours = workingHours[dateStr];
|
||||||
|
const dayAvailableHours = availableHours[dateStr];
|
||||||
|
|
||||||
|
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract existing bookings from the gap between working hours and available hours
|
||||||
|
const existingBookings = extractBookedSlots(
|
||||||
|
dayWorkingHours.startTime,
|
||||||
|
dayWorkingHours.endTime,
|
||||||
|
dayAvailableHours.slots
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get lunch protection status for all slots
|
||||||
|
return getLunchProtectionForSlots(
|
||||||
|
dayWorkingHours.startTime,
|
||||||
|
dayWorkingHours.endTime,
|
||||||
|
existingBookings,
|
||||||
|
getTotalDuration(),
|
||||||
|
15, // 15 minute slot intervals
|
||||||
|
false // User journey - requires 1h minimum
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============== Working Hours & Available Hours ===============
|
||||||
|
let workingHours = $state<Record<
|
||||||
|
string,
|
||||||
|
{ isOpen: boolean; startTime: string; endTime: string }
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
let availableHours = $state<Record<
|
||||||
|
string,
|
||||||
|
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
let loadingWorkingHours = $state<boolean>(false);
|
||||||
|
let loadingAvailableHours = $state<boolean>(false);
|
||||||
|
|
||||||
|
const workingHoursCache = new SvelteMap<
|
||||||
|
string,
|
||||||
|
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const availableHoursCache = new SvelteMap<
|
||||||
|
string,
|
||||||
|
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
workingHoursCache.clear();
|
||||||
|
availableHoursCache.clear();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize date boundaries
|
||||||
|
const today = new SvelteDate();
|
||||||
|
const tomorrow = new SvelteDate(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
const maxDate = new SvelteDate();
|
||||||
|
maxDate.setMonth(today.getMonth() + 6);
|
||||||
|
|
||||||
|
// Create CalendarDate objects
|
||||||
|
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||||
|
const maxCalendarDate = new CalendarDate(
|
||||||
|
maxDate.getFullYear(),
|
||||||
|
maxDate.getMonth() + 1,
|
||||||
|
maxDate.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
let placeholder = $state<CalendarDate>(minDate);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchServices();
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||||
|
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
||||||
|
fetchHoursForMonth(placeholder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchHoursForMonth(date: CalendarDate) {
|
||||||
|
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
||||||
|
workingHours = workingHoursCache.get(monthKey)!;
|
||||||
|
availableHours = availableHoursCache.get(monthKey)!;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingWorkingHours = true;
|
||||||
|
loadingAvailableHours = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||||
|
const endOfMonth = new CalendarDate(
|
||||||
|
date.year,
|
||||||
|
date.month,
|
||||||
|
date.calendar.getDaysInMonth(date)
|
||||||
|
);
|
||||||
|
|
||||||
|
const startStr = startOfMonth.toString();
|
||||||
|
const endStr = endOfMonth.toString();
|
||||||
|
|
||||||
|
// Fetch working hours
|
||||||
|
const workingHoursResponse = await fetch(
|
||||||
|
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`
|
||||||
|
);
|
||||||
|
if (!workingHoursResponse.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingHoursData: Array<WorkingHoursDay> = await workingHoursResponse.json();
|
||||||
|
const workingHoursMap: Record<
|
||||||
|
string,
|
||||||
|
{ isOpen: boolean; startTime: string; endTime: string }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
workingHoursData.forEach((day) => {
|
||||||
|
workingHoursMap[day.date] = {
|
||||||
|
isOpen: day.isOpen,
|
||||||
|
startTime: day.startTime,
|
||||||
|
endTime: day.endTime
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
workingHoursCache.set(monthKey, workingHoursMap);
|
||||||
|
workingHours = workingHoursMap;
|
||||||
|
|
||||||
|
// Fetch available hours
|
||||||
|
const availableHoursResponse = await fetch(
|
||||||
|
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`
|
||||||
|
);
|
||||||
|
if (!availableHoursResponse.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableHoursData: Array<AvailableHoursDay> = await availableHoursResponse.json();
|
||||||
|
const availableHoursMap: Record<
|
||||||
|
string,
|
||||||
|
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
availableHoursData.forEach((day) => {
|
||||||
|
availableHoursMap[day.date] = {
|
||||||
|
isOpen: day.isOpen,
|
||||||
|
slots: day.slots
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
availableHoursCache.set(monthKey, availableHoursMap);
|
||||||
|
availableHours = availableHoursMap;
|
||||||
|
|
||||||
|
if (!selectedDate) {
|
||||||
|
setDefaultSelectedDate(workingHoursMap);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch hours:', error);
|
||||||
|
if (!selectedDate) {
|
||||||
|
selectedDate = minDate;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingWorkingHours = false;
|
||||||
|
loadingAvailableHours = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultSelectedDate(
|
||||||
|
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||||
|
) {
|
||||||
|
const currentDate = new SvelteDate();
|
||||||
|
const maxDateJs = new SvelteDate(
|
||||||
|
maxCalendarDate.year,
|
||||||
|
maxCalendarDate.month - 1,
|
||||||
|
maxCalendarDate.day
|
||||||
|
);
|
||||||
|
|
||||||
|
const daysDifference = Math.floor(
|
||||||
|
(maxDateJs.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const daysToCheck = Math.min(daysDifference, 180);
|
||||||
|
|
||||||
|
for (let i = 1; i <= daysToCheck; i++) {
|
||||||
|
const nextDate = new SvelteDate(currentDate);
|
||||||
|
nextDate.setDate(currentDate.getDate() + i);
|
||||||
|
const dateStr = nextDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (hoursMap[dateStr]?.isOpen) {
|
||||||
|
selectedDate = new CalendarDate(
|
||||||
|
nextDate.getFullYear(),
|
||||||
|
nextDate.getMonth() + 1,
|
||||||
|
nextDate.getDate()
|
||||||
|
);
|
||||||
|
// Also update placeholder to show the month with first available date
|
||||||
|
placeholder = new CalendarDate(
|
||||||
|
nextDate.getFullYear(),
|
||||||
|
nextDate.getMonth() + 1,
|
||||||
|
1 // First day of the month
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedDate) {
|
||||||
|
const tomorrow = new SvelteDate();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
selectedDate = new CalendarDate(
|
||||||
|
tomorrow.getFullYear(),
|
||||||
|
tomorrow.getMonth() + 1,
|
||||||
|
tomorrow.getDate()
|
||||||
|
);
|
||||||
|
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Time Slot Generation ===============
|
||||||
|
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||||
|
const [hours, minutes] = startTime.split(':').map(Number);
|
||||||
|
const date = new SvelteDate();
|
||||||
|
date.setHours(hours, minutes, 0, 0);
|
||||||
|
date.setMinutes(date.getMinutes() + durationMinutes);
|
||||||
|
const endHours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const endMinutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${endHours}:${endMinutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(time: string): number {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePreviousTime(time: string): string {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
let totalMinutes = hours * 60 + minutes;
|
||||||
|
totalMinutes -= 15;
|
||||||
|
|
||||||
|
const prevHours = Math.floor(totalMinutes / 60);
|
||||||
|
const prevMinutes = totalMinutes % 60;
|
||||||
|
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
|
||||||
|
if (!date || !workingHours || !availableHours) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = date.toString();
|
||||||
|
const dayWorkingHours = workingHours[dateStr];
|
||||||
|
const dayAvailableHours = availableHours[dateStr];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!dayWorkingHours ||
|
||||||
|
!dayWorkingHours.isOpen ||
|
||||||
|
!dayAvailableHours ||
|
||||||
|
!dayAvailableHours.slots
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots: string[] = [];
|
||||||
|
const now = new SvelteDate();
|
||||||
|
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||||
|
const isToday = date.compare(today) === 0;
|
||||||
|
|
||||||
|
for (const slot of dayAvailableHours.slots) {
|
||||||
|
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
let startTotalMinutes = startHour * 60 + startMinute;
|
||||||
|
const endTotalMinutes = endHour * 60 + endMinute;
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const minimumStartMinutes = currentMinutes + 120;
|
||||||
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||||
|
const slotEndMinutes = minutes + duration;
|
||||||
|
|
||||||
|
if (slotEndMinutes <= endTotalMinutes) {
|
||||||
|
const hour = Math.floor(minutes / 60);
|
||||||
|
const minute = minutes % 60;
|
||||||
|
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
slots.push(timeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateGroupedTimeSlots(
|
||||||
|
duration: number,
|
||||||
|
date: CalendarDate | undefined
|
||||||
|
): Array<{
|
||||||
|
type: 'available' | 'unavailable';
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isGrouped?: boolean;
|
||||||
|
}> {
|
||||||
|
if (!date || !workingHours) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = date.toString();
|
||||||
|
const dayWorkingHours = workingHours[dateStr];
|
||||||
|
|
||||||
|
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSlots: Array<{
|
||||||
|
type: 'available' | 'unavailable';
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isGrouped?: boolean;
|
||||||
|
}> = [];
|
||||||
|
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
let startTotalMinutes = startHour * 60 + startMinute;
|
||||||
|
const endTotalMinutes = endHour * 60 + endMinute;
|
||||||
|
|
||||||
|
const now = new SvelteDate();
|
||||||
|
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||||
|
const isToday = date.compare(today) === 0;
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const minimumStartMinutes = currentMinutes + 120;
|
||||||
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||||
|
|
||||||
|
let currentUnavailableStart: string | null = null;
|
||||||
|
let lastAvailableEndTime: string | null = null;
|
||||||
|
|
||||||
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||||
|
const hour = Math.floor(minutes / 60);
|
||||||
|
const minute = minutes % 60;
|
||||||
|
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const isAvailable = availableSlots.includes(timeStr);
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
if (currentUnavailableStart !== null) {
|
||||||
|
// Use the end time of the last available slot as the start of unavailable period
|
||||||
|
const unavailableStartTime = lastAvailableEndTime || currentUnavailableStart;
|
||||||
|
const groupEndTime = calculatePreviousTime(timeStr);
|
||||||
|
groupedSlots.push({
|
||||||
|
type: 'unavailable',
|
||||||
|
startTime: unavailableStartTime,
|
||||||
|
endTime: groupEndTime,
|
||||||
|
isGrouped: true
|
||||||
|
});
|
||||||
|
currentUnavailableStart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotEndTime = calculateEndTime(timeStr, duration);
|
||||||
|
lastAvailableEndTime = slotEndTime;
|
||||||
|
groupedSlots.push({
|
||||||
|
type: 'available',
|
||||||
|
startTime: timeStr,
|
||||||
|
endTime: slotEndTime
|
||||||
|
});
|
||||||
|
|
||||||
|
if (timeToMinutes(slotEndTime) >= endTotalMinutes) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentUnavailableStart === null) {
|
||||||
|
currentUnavailableStart = timeStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUnavailableStart !== null) {
|
||||||
|
const lastAvailableSlot = groupedSlots.filter((s) => s.type === 'available').pop();
|
||||||
|
const lastAvailableEnd = lastAvailableSlot ? timeToMinutes(lastAvailableSlot.endTime) : 0;
|
||||||
|
|
||||||
|
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
|
||||||
|
|
||||||
|
if (unavailableStartMinutes < endTotalMinutes && lastAvailableEnd < endTotalMinutes) {
|
||||||
|
// Use the end time of the last available slot for the final unavailable period
|
||||||
|
const unavailableStartTime = lastAvailableSlot
|
||||||
|
? lastAvailableSlot.endTime
|
||||||
|
: currentUnavailableStart;
|
||||||
|
groupedSlots.push({
|
||||||
|
type: 'unavailable',
|
||||||
|
startTime: unavailableStartTime,
|
||||||
|
endTime: dayWorkingHours.endTime,
|
||||||
|
isGrouped: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Date Availability Check ===============
|
||||||
|
function isDateUnavailable(date: DateValue): boolean {
|
||||||
|
if (!(date instanceof CalendarDate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workingHours) return true;
|
||||||
|
|
||||||
|
const dateStr = date.toString();
|
||||||
|
const dayHours = workingHours[dateStr];
|
||||||
|
|
||||||
|
if (!dayHours) return true;
|
||||||
|
if (!dayHours.isOpen) return true;
|
||||||
|
|
||||||
|
// If no services selected, don't check availability slots
|
||||||
|
// This allows calendar to show open/closed days
|
||||||
|
if (selectedServices.length === 0) {
|
||||||
|
return false; // Show all working days as available
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = getTotalDuration();
|
||||||
|
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||||
|
if (availableSlots.length === 0) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Helper Functions ===============
|
||||||
|
function getTotalDuration() {
|
||||||
|
return selectedServices.reduce(
|
||||||
|
(total, service: Service) => total + service.duration_minutes,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalPrice() {
|
||||||
|
return selectedServices.reduce((total, service: Service) => total + service.price, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleService(service: Service) {
|
||||||
|
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||||
|
const wasSelected = index >= 0;
|
||||||
|
|
||||||
|
if (wasSelected) {
|
||||||
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||||
|
} else {
|
||||||
|
selectedServices = [...selectedServices, service];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear if we're on the date/time selection step
|
||||||
|
if (currentStep === 2 && selectedDate) {
|
||||||
|
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||||
|
availableHoursCache.delete(monthKey); // Only delete current month
|
||||||
|
fetchHoursForMonth(selectedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${remainingMinutes} minutes`;
|
||||||
|
} else if (remainingMinutes === 0) {
|
||||||
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayWithOrdinal(date: CalendarDate): string {
|
||||||
|
const monthName = new SvelteDate(date.year, date.month - 1, date.day).toLocaleDateString(
|
||||||
|
'en-GB',
|
||||||
|
{
|
||||||
|
month: 'long'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const day = date.day;
|
||||||
|
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
|
||||||
|
switch (day % 10) {
|
||||||
|
case 1:
|
||||||
|
return monthName + ' ' + day + 'st';
|
||||||
|
case 2:
|
||||||
|
return monthName + ' ' + day + 'nd';
|
||||||
|
case 3:
|
||||||
|
return monthName + ' ' + day + 'rd';
|
||||||
|
default:
|
||||||
|
return monthName + ' ' + day + 'th';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Derived Values ===============
|
||||||
|
const formattedTotalDuration = $derived(formatDuration(getTotalDuration()));
|
||||||
|
const groupedTimeSlots = $derived(
|
||||||
|
currentStep === 2 && selectedServices.length > 0 && selectedDate
|
||||||
|
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const formattedSelectedDate = $derived(
|
||||||
|
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============== Navigation ===============
|
||||||
|
function nextStep() {
|
||||||
|
if (currentStep < 4) {
|
||||||
|
currentStep++;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
currentStep--;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Validation ===============
|
||||||
|
const canProceedStep1 = $derived(selectedServices.length > 0);
|
||||||
|
const canProceedStep2 = $derived(!!(selectedDate && selectedTime));
|
||||||
|
const canProceedStep3 = $derived(
|
||||||
|
authStore.isAuthenticated
|
||||||
|
? !!(
|
||||||
|
authStore.currentUser?.firstName &&
|
||||||
|
authStore.currentUser?.lastName &&
|
||||||
|
authStore.currentUser?.email &&
|
||||||
|
authStore.currentUser?.phone
|
||||||
|
)
|
||||||
|
: !!(
|
||||||
|
customerInfo.firstName &&
|
||||||
|
customerInfo.lastName &&
|
||||||
|
customerInfo.email &&
|
||||||
|
customerInfo.phone
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============== Submission ===============
|
||||||
|
async function submitBooking() {
|
||||||
|
isSubmitting = true;
|
||||||
|
try {
|
||||||
|
console.log('Submitting booking:', {
|
||||||
|
services: selectedServices,
|
||||||
|
date: selectedDate,
|
||||||
|
time: selectedTime,
|
||||||
|
customer: authStore.isAuthenticated ? authStore.currentUser : customerInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Booking submitted successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Booking submission failed:', error);
|
||||||
|
toast.error('Failed to submit booking. Please try again.');
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl p-6">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="mb-2 text-3xl font-bold">Book Your Appointment</h1>
|
||||||
|
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator {currentStep} />
|
||||||
|
|
||||||
|
<!-- Step 1: Service Selection -->
|
||||||
|
{#if currentStep === 1}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Choose Your Services</Card.Title>
|
||||||
|
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<ServiceSelector
|
||||||
|
{services}
|
||||||
|
selected={selectedServices}
|
||||||
|
loading={servicesLoading}
|
||||||
|
ontoggle={toggleService}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if selectedServices.length > 0}
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4">
|
||||||
|
<h4 class="mb-2 font-semibold">Selected Services</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each selectedServices as service (service.id)}
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>{service.name}</span>
|
||||||
|
<span>{service.duration_minutes} mins • £{service.price}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Separator class="my-2" />
|
||||||
|
<div class="flex justify-between text-sm font-semibold">
|
||||||
|
<span>Estimated Duration:</span>
|
||||||
|
<span>{formattedTotalDuration}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm font-semibold">
|
||||||
|
<span>Total Cost:</span>
|
||||||
|
<span>£{getTotalPrice()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex justify-end">
|
||||||
|
<BookingActions
|
||||||
|
canBack={false}
|
||||||
|
canNext={canProceedStep1}
|
||||||
|
nextLabel="Next: Select Date & Time"
|
||||||
|
on:next={nextStep}
|
||||||
|
/>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 2: Date & Time Selection -->
|
||||||
|
{#if currentStep === 2}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Choose Date & Time</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
{selectedServices.map((s) => s.name).join(', ')} • {formattedTotalDuration} total • £{getTotalPrice()}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="p-0">
|
||||||
|
<Card.Root class="gap-0 border-0 p-0">
|
||||||
|
<Card.Content class="relative p-0 md:pr-56">
|
||||||
|
{#if loadingWorkingHours}
|
||||||
|
<div class="flex items-center justify-center p-6">
|
||||||
|
<p>Loading available dates...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<DatePicker
|
||||||
|
date={selectedDate}
|
||||||
|
{placeholder}
|
||||||
|
minValue={minDate}
|
||||||
|
maxValue={maxCalendarDate}
|
||||||
|
{isDateUnavailable}
|
||||||
|
onchange={(newDate) => {
|
||||||
|
selectedDate = newDate;
|
||||||
|
selectedTime = null;
|
||||||
|
}}
|
||||||
|
onPlaceholderChange={(newPlaceholder) => {
|
||||||
|
placeholder = newPlaceholder;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadingAvailableHours}
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 flex w-56 items-center justify-center border-l p-6"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500">Loading times...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TimeSlotPicker
|
||||||
|
date={selectedDate}
|
||||||
|
{groupedTimeSlots}
|
||||||
|
{selectedTime}
|
||||||
|
formattedDate={formattedSelectedDate}
|
||||||
|
onselect={(time) => {
|
||||||
|
selectedTime = time;
|
||||||
|
}}
|
||||||
|
lunchProtectionStatus={lunchProtectionStatus()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
<!-- Mobile appointment summary -->
|
||||||
|
<div class="border-t px-6 py-4 text-center text-sm md:hidden">
|
||||||
|
{#if selectedDate && selectedTime}
|
||||||
|
Appointment for
|
||||||
|
<span class="font-medium">
|
||||||
|
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<br />at <span class="font-medium">{selectedTime}</span>
|
||||||
|
{:else}
|
||||||
|
Select a date and time
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Footer class="flex justify-between border-t px-6 !py-5">
|
||||||
|
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Desktop appointment summary -->
|
||||||
|
<div class="hidden text-sm md:block">
|
||||||
|
{#if selectedDate && selectedTime}
|
||||||
|
Appointment for
|
||||||
|
<span class="font-medium">
|
||||||
|
{selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
at <span class="font-medium">{selectedTime}</span>
|
||||||
|
{:else}
|
||||||
|
Select a date and time
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button disabled={!canProceedStep2} onclick={nextStep}>Next: Your Details</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 3: Customer Details -->
|
||||||
|
{#if currentStep === 3}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Your Details</Card.Title>
|
||||||
|
<Card.Description>Please confirm your contact information</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-6">
|
||||||
|
<BookingSummary
|
||||||
|
services={selectedServices}
|
||||||
|
date={selectedDate}
|
||||||
|
time={selectedTime}
|
||||||
|
showCustomer={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if !authStore.isAuthenticated}
|
||||||
|
<p class="mb-4 text-center text-sm text-yellow-600">
|
||||||
|
You are checking out as a guest, so you will miss out on a loyalty stamp. Please login
|
||||||
|
for full membership benefits.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="firstName">First Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
bind:value={customerInfo.firstName}
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="lastName">Last Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
bind:value={customerInfo.lastName}
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="email">Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={customerInfo.email}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="phone">Phone Number *</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
bind:value={customerInfo.phone}
|
||||||
|
placeholder="Enter your phone number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="requests">Special Requests (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="requests"
|
||||||
|
bind:value={customerInfo.specialRequests}
|
||||||
|
placeholder="Any allergies, preferences, or special requirements..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{#if !authStore.isAuthenticated}
|
||||||
|
<p>* Required fields</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-2">
|
||||||
|
By booking, you agree to our Terms & Conditions and Privacy Policy. We'll send you
|
||||||
|
appointment reminders via email and/or SMS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex justify-between">
|
||||||
|
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!canProceedStep3}
|
||||||
|
onclick={nextStep}
|
||||||
|
class="bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
Next: Payment
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 4: Payment (complete) -->
|
||||||
|
{#if currentStep === 4}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Payment Confirmation</Card.Title>
|
||||||
|
<Card.Description>Review and complete your booking</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-6">
|
||||||
|
<BookingSummary
|
||||||
|
services={selectedServices}
|
||||||
|
date={selectedDate}
|
||||||
|
time={selectedTime}
|
||||||
|
customer={authStore.isAuthenticated
|
||||||
|
? {
|
||||||
|
firstName: authStore.currentUser?.firstName ?? '',
|
||||||
|
lastName: authStore.currentUser?.lastName ?? '',
|
||||||
|
email: authStore.currentUser?.email ?? '',
|
||||||
|
phone: authStore.currentUser?.phone ?? '',
|
||||||
|
specialRequests: customerInfo.specialRequests
|
||||||
|
}
|
||||||
|
: customerInfo}
|
||||||
|
showCustomer={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Payment form placeholder -->
|
||||||
|
<div class="rounded-lg bg-white p-6">
|
||||||
|
<h2 class="mb-4 text-2xl font-semibold">Payment</h2>
|
||||||
|
<p class="text-gray-600">Square payment integration will be added here.</p>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex justify-between">
|
||||||
|
<Button variant="outline" onclick={prevStep}>Back</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!canProceedStep3 || isSubmitting}
|
||||||
|
onclick={submitBooking}
|
||||||
|
class="bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Processing...' : 'Complete Booking'}
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Service, CustomerInfo } from '$lib/types/booking';
|
||||||
|
import type { CalendarDate } from '@internationalized/date';
|
||||||
|
import { getLocalTimeZone } from '@internationalized/date';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
|
let {
|
||||||
|
services = [],
|
||||||
|
date = undefined,
|
||||||
|
time = null,
|
||||||
|
customer = null,
|
||||||
|
showCustomer = false
|
||||||
|
}: {
|
||||||
|
services?: Service[];
|
||||||
|
date?: CalendarDate;
|
||||||
|
time?: string | null;
|
||||||
|
customer?: CustomerInfo | null;
|
||||||
|
showCustomer?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
const totalPrice = $derived(services.reduce((sum, service) => sum + service.price, 0));
|
||||||
|
|
||||||
|
const totalDuration = $derived(
|
||||||
|
services.reduce((sum, service) => sum + service.duration_minutes, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${remainingMinutes} minutes`;
|
||||||
|
} else if (remainingMinutes === 0) {
|
||||||
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
} else {
|
||||||
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${remainingMinutes} minutes`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: string): string {
|
||||||
|
const parts = time.split(':').map(Number);
|
||||||
|
const hours = parts[0];
|
||||||
|
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||||
|
|
||||||
|
if (hours === 12 && minutes === 0) {
|
||||||
|
return 'Noon';
|
||||||
|
} else if (hours === 0 && minutes === 0) {
|
||||||
|
return 'Midnight';
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 p-4">
|
||||||
|
<h4 class="mb-2 font-semibold">Booking Summary</h4>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
{#if services.length > 0}
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Services:</span>
|
||||||
|
<div class="mt-1 ml-4 space-y-1">
|
||||||
|
{#each services as service (service.id)}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>{service.name}</span>
|
||||||
|
<span>£{service.price}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if date}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Date:</span>
|
||||||
|
<span class="font-medium">
|
||||||
|
{date.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if time}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Time:</span>
|
||||||
|
<span class="font-medium">{formatTime(time)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if services.length > 0}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Estimated Duration:</span>
|
||||||
|
<span class="font-medium">{formatDuration(totalDuration)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCustomer && customer}
|
||||||
|
<Separator class="my-2" />
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Contact Information:</span>
|
||||||
|
<div class="mt-1 ml-4 space-y-1">
|
||||||
|
<div>{customer.firstName} {customer.lastName}</div>
|
||||||
|
<div>{customer.email}</div>
|
||||||
|
<div>{customer.phone}</div>
|
||||||
|
{#if customer.specialRequests}
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="font-medium">Special Requests:</span>
|
||||||
|
<div class="text-gray-600">{customer.specialRequests}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if services.length > 0}
|
||||||
|
<Separator class="my-2" />
|
||||||
|
<div class="flex justify-between font-semibold">
|
||||||
|
<span>Total Cost:</span>
|
||||||
|
<span>£{totalPrice}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CalendarDate, DateValue } from '@internationalized/date';
|
||||||
|
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
date,
|
||||||
|
placeholder,
|
||||||
|
minValue,
|
||||||
|
maxValue,
|
||||||
|
isDateUnavailable,
|
||||||
|
onchange,
|
||||||
|
onPlaceholderChange
|
||||||
|
}: {
|
||||||
|
date: CalendarDate | undefined;
|
||||||
|
placeholder: CalendarDate;
|
||||||
|
minValue: CalendarDate;
|
||||||
|
maxValue: CalendarDate;
|
||||||
|
isDateUnavailable: (date: DateValue) => boolean;
|
||||||
|
onchange?: (date: CalendarDate | undefined) => void;
|
||||||
|
onPlaceholderChange?: (date: CalendarDate) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center p-6">
|
||||||
|
<Calendar
|
||||||
|
type="single"
|
||||||
|
bind:value={date}
|
||||||
|
bind:placeholder
|
||||||
|
{isDateUnavailable}
|
||||||
|
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:pointer-events-none [&_[data-outside-month]]:opacity-0"
|
||||||
|
weekdayFormat="short"
|
||||||
|
{minValue}
|
||||||
|
{maxValue}
|
||||||
|
locale="en-GB"
|
||||||
|
onValueChange={(v: DateValue | undefined) => {
|
||||||
|
if (onchange) {
|
||||||
|
onchange(v as CalendarDate | undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPlaceholderChange={(p: DateValue) => {
|
||||||
|
if (onPlaceholderChange) {
|
||||||
|
onPlaceholderChange(p as CalendarDate);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Service } from '$lib/types/booking';
|
||||||
|
|
||||||
|
let {
|
||||||
|
service,
|
||||||
|
selected = false,
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
service: Service;
|
||||||
|
selected?: boolean;
|
||||||
|
onclick?: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 hover:text-accent-foreground {selected
|
||||||
|
? 'bg-fuchsia-100'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => onclick?.()}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
|
<p class="text-sm text-gray-600">{service.description}</p>
|
||||||
|
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||||
|
<span>{service.duration_minutes} mins</span>
|
||||||
|
<span>£{service.price}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {selected
|
||||||
|
? 'border-primary bg-primary'
|
||||||
|
: 'border-gray-300'}"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{#if selected}
|
||||||
|
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ServiceCard from './ServiceCard.svelte';
|
||||||
|
import type { Service } from '$lib/types/booking';
|
||||||
|
|
||||||
|
let {
|
||||||
|
services = [],
|
||||||
|
selected = [],
|
||||||
|
loading = true,
|
||||||
|
ontoggle
|
||||||
|
}: {
|
||||||
|
services?: Service[];
|
||||||
|
selected?: Service[];
|
||||||
|
loading?: boolean;
|
||||||
|
ontoggle?: (service: Service) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function isServiceSelected(service: Service): boolean {
|
||||||
|
return selected.some((s) => s.id === service.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading services...</p>
|
||||||
|
{:else if services.length === 0}
|
||||||
|
<p>No services available at the moment.</p>
|
||||||
|
{:else}
|
||||||
|
{#each services as service (service.id)}
|
||||||
|
<ServiceCard
|
||||||
|
{service}
|
||||||
|
selected={isServiceSelected(service)}
|
||||||
|
onclick={() => ontoggle?.(service)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let currentStep: number;
|
||||||
|
export let steps: string[] = ['Service', 'Date & Time', 'Details', 'Payment'];
|
||||||
|
|
||||||
|
const totalSteps = steps.length;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-8 grid grid-cols-2 gap-4 md:flex md:items-center md:justify-center md:space-x-4">
|
||||||
|
{#each steps as step, index (step)}
|
||||||
|
{@const stepNumber = index + 1}
|
||||||
|
{@const isActive = stepNumber <= currentStep}
|
||||||
|
{@const isLastStep = index === totalSteps - 1}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-start md:justify-center">
|
||||||
|
<!-- Step circle -->
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium {isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-gray-200 text-gray-600'}"
|
||||||
|
>
|
||||||
|
{stepNumber}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step label -->
|
||||||
|
<span class="ml-2 text-sm font-medium {isActive ? 'text-primary' : 'text-gray-600'}">
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Connector line (desktop only, not after last step) -->
|
||||||
|
{#if !isLastStep}
|
||||||
|
<div
|
||||||
|
class="mx-4 hidden h-0.5 w-8 md:block {stepNumber < currentStep
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-gray-200'}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CalendarDate } from '@internationalized/date';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
let {
|
||||||
|
date,
|
||||||
|
groupedTimeSlots = [],
|
||||||
|
selectedTime = null,
|
||||||
|
formattedDate,
|
||||||
|
onselect,
|
||||||
|
lunchProtectionStatus = new Map()
|
||||||
|
}: {
|
||||||
|
date: CalendarDate | undefined;
|
||||||
|
groupedTimeSlots?: Array<{
|
||||||
|
type: 'available' | 'unavailable';
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isGrouped?: boolean;
|
||||||
|
}>;
|
||||||
|
selectedTime?: string | null;
|
||||||
|
formattedDate?: string;
|
||||||
|
onselect?: (time: string) => void;
|
||||||
|
lunchProtectionStatus?: Map<
|
||||||
|
string,
|
||||||
|
{ isBlocked: boolean; showWarning: boolean; warningMessage?: string }
|
||||||
|
>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function formatTime(time: string): string {
|
||||||
|
const parts = time.split(':').map(Number);
|
||||||
|
const hours = parts[0];
|
||||||
|
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||||
|
|
||||||
|
if (hours === 12 && minutes === 0) {
|
||||||
|
return 'Noon';
|
||||||
|
} else if (hours === 0 && minutes === 0) {
|
||||||
|
return 'Midnight';
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="no-scrollbar inset-y-0 right-0 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
|
||||||
|
>
|
||||||
|
{#if groupedTimeSlots.length > 0}
|
||||||
|
{#if formattedDate}
|
||||||
|
<div class="grid justify-center gap-2">{formattedDate}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
{#each groupedTimeSlots as slot (slot.startTime)}
|
||||||
|
{#if slot.type === 'available'}
|
||||||
|
{@const protection = lunchProtectionStatus.get(slot.startTime)}
|
||||||
|
{#if protection?.isBlocked}
|
||||||
|
<!-- Blocked by lunch protection -->
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
||||||
|
disabled
|
||||||
|
title={protection.warningMessage || 'Lunch protection'}
|
||||||
|
>
|
||||||
|
{formatTime(slot.startTime)}
|
||||||
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<!-- Available slot (possibly with warning for admin) -->
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
if (onselect) {
|
||||||
|
onselect(slot.startTime);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class={`w-full hover:bg-fuchsia-50 ${
|
||||||
|
slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''
|
||||||
|
} ${protection?.showWarning ? 'border-amber-400 bg-amber-50' : ''}`}
|
||||||
|
title={protection?.warningMessage}
|
||||||
|
>
|
||||||
|
{#if protection?.showWarning}
|
||||||
|
<span class="mr-1 text-amber-500">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{formatTime(slot.startTime)}
|
||||||
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Unavailable slot (already booked) -->
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{formatTime(slot.startTime)}
|
||||||
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if !date}
|
||||||
|
<p class="text-center text-sm text-gray-500">Select a date first</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-sm text-gray-500">No available slots</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Lunch Protection Utility
|
||||||
|
*
|
||||||
|
* Ensures that a minimum lunch break is preserved in the middle 50% of the working day.
|
||||||
|
* - User journeys: Requires 1h minimum lunch gap (blocks slots that would reduce below 1h)
|
||||||
|
* - Admin journeys: Requires 30min minimum, warns if < 1h remaining
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TimeSlot {
|
||||||
|
startTime: string; // "HH:MM" format
|
||||||
|
endTime: string; // "HH:MM" format
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LunchProtectionResult {
|
||||||
|
isBlocked: boolean;
|
||||||
|
showWarning: boolean;
|
||||||
|
warningMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert "HH:MM" time string to minutes since midnight
|
||||||
|
*/
|
||||||
|
export function timeToMinutes(time: string): number {
|
||||||
|
const parts = time.split(':');
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parts.length > 1 ? parseInt(parts[1], 10) : 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert minutes since midnight to "HH:MM" format
|
||||||
|
*/
|
||||||
|
export function minutesToTime(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the middle 50% window of a working day
|
||||||
|
* Example: 9:00-17:00 -> middle 50% is 11:00-15:00
|
||||||
|
*/
|
||||||
|
export function calculateMiddleWindow(
|
||||||
|
dayStartTime: string,
|
||||||
|
dayEndTime: string
|
||||||
|
): { windowStart: number; windowEnd: number } {
|
||||||
|
const startMinutes = timeToMinutes(dayStartTime);
|
||||||
|
const endMinutes = timeToMinutes(dayEndTime);
|
||||||
|
|
||||||
|
const totalDuration = endMinutes - startMinutes;
|
||||||
|
const quarterDuration = Math.floor(totalDuration / 4);
|
||||||
|
|
||||||
|
// Middle 50%: from 25% to 75% of the day
|
||||||
|
return {
|
||||||
|
windowStart: startMinutes + quarterDuration,
|
||||||
|
windowEnd: endMinutes - quarterDuration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the largest gap in the middle window after accounting for bookings
|
||||||
|
* Returns the duration in minutes of the largest gap
|
||||||
|
*/
|
||||||
|
export function findLargestLunchGap(
|
||||||
|
middleWindowStart: number,
|
||||||
|
middleWindowEnd: number,
|
||||||
|
existingBookings: TimeSlot[],
|
||||||
|
proposedBooking?: TimeSlot
|
||||||
|
): number {
|
||||||
|
const allBookings: TimeSlot[] = [...existingBookings];
|
||||||
|
if (proposedBooking) {
|
||||||
|
allBookings.push(proposedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only bookings that overlap with the middle window
|
||||||
|
const relevantBookings = allBookings
|
||||||
|
.filter((booking) => {
|
||||||
|
const bookingStart = timeToMinutes(booking.startTime);
|
||||||
|
const bookingEnd = timeToMinutes(booking.endTime);
|
||||||
|
return bookingStart < middleWindowEnd && bookingEnd > middleWindowStart;
|
||||||
|
})
|
||||||
|
.map((booking) => ({
|
||||||
|
startTime: Math.max(timeToMinutes(booking.startTime), middleWindowStart),
|
||||||
|
endTime: Math.min(timeToMinutes(booking.endTime), middleWindowEnd)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
if (relevantBookings.length === 0) {
|
||||||
|
return middleWindowEnd - middleWindowStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
let largestGap = 0;
|
||||||
|
|
||||||
|
const firstBookingStart = relevantBookings[0].startTime;
|
||||||
|
if (firstBookingStart > middleWindowStart) {
|
||||||
|
largestGap = Math.max(largestGap, firstBookingStart - middleWindowStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < relevantBookings.length - 1; i++) {
|
||||||
|
const gapStart = relevantBookings[i].endTime;
|
||||||
|
const gapEnd = relevantBookings[i + 1].startTime;
|
||||||
|
if (gapEnd > gapStart) {
|
||||||
|
largestGap = Math.max(largestGap, gapEnd - gapStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastBookingEnd = relevantBookings[relevantBookings.length - 1].endTime;
|
||||||
|
if (lastBookingEnd < middleWindowEnd) {
|
||||||
|
largestGap = Math.max(largestGap, middleWindowEnd - lastBookingEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return largestGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LUNCH_MINIMUM_USER = 60;
|
||||||
|
export const LUNCH_MINIMUM_ADMIN = 30;
|
||||||
|
export const LUNCH_WARNING_THRESHOLD = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a proposed booking slot violates lunch protection
|
||||||
|
*/
|
||||||
|
export function checkLunchProtection(
|
||||||
|
dayStartTime: string,
|
||||||
|
dayEndTime: string,
|
||||||
|
existingBookings: TimeSlot[],
|
||||||
|
proposedSlotStart: string,
|
||||||
|
proposedSlotEnd: string,
|
||||||
|
isAdmin: boolean
|
||||||
|
): LunchProtectionResult {
|
||||||
|
const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime);
|
||||||
|
|
||||||
|
const windowDuration = windowEnd - windowStart;
|
||||||
|
const minimumRequired = isAdmin ? LUNCH_MINIMUM_ADMIN : LUNCH_MINIMUM_USER;
|
||||||
|
|
||||||
|
if (windowDuration < minimumRequired) {
|
||||||
|
return { isBlocked: false, showWarning: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposedBooking: TimeSlot = {
|
||||||
|
startTime: proposedSlotStart,
|
||||||
|
endTime: proposedSlotEnd
|
||||||
|
};
|
||||||
|
|
||||||
|
const largestGap = findLargestLunchGap(windowStart, windowEnd, existingBookings, proposedBooking);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
if (largestGap < LUNCH_MINIMUM_ADMIN) {
|
||||||
|
return {
|
||||||
|
isBlocked: true,
|
||||||
|
showWarning: false,
|
||||||
|
warningMessage: `This booking would leave no lunch break (minimum 30 minutes required).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largestGap < LUNCH_WARNING_THRESHOLD) {
|
||||||
|
const gapMinutes = Math.round(largestGap);
|
||||||
|
return {
|
||||||
|
isBlocked: false,
|
||||||
|
showWarning: true,
|
||||||
|
warningMessage: `Warning: This booking would reduce lunch break to ${gapMinutes} minutes.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBlocked: false, showWarning: false };
|
||||||
|
} else {
|
||||||
|
if (largestGap < LUNCH_MINIMUM_USER) {
|
||||||
|
return {
|
||||||
|
isBlocked: true,
|
||||||
|
showWarning: false,
|
||||||
|
warningMessage: `This booking would leave insufficient lunch break (minimum 1 hour required).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBlocked: false, showWarning: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract booked slots from working hours and available hours
|
||||||
|
* The backend returns available slots (working hours minus bookings)
|
||||||
|
* We reverse-engineer the bookings by finding gaps in available slots
|
||||||
|
*/
|
||||||
|
export function extractBookedSlots(
|
||||||
|
dayStartTime: string,
|
||||||
|
dayEndTime: string,
|
||||||
|
availableSlots: TimeSlot[]
|
||||||
|
): TimeSlot[] {
|
||||||
|
const dayStart = timeToMinutes(dayStartTime);
|
||||||
|
const dayEnd = timeToMinutes(dayEndTime);
|
||||||
|
const bookedSlots: TimeSlot[] = [];
|
||||||
|
|
||||||
|
const sortedSlots = [...availableSlots].sort(
|
||||||
|
(a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentPos = dayStart;
|
||||||
|
|
||||||
|
for (const slot of sortedSlots) {
|
||||||
|
const slotStart = timeToMinutes(slot.startTime);
|
||||||
|
const slotEnd = timeToMinutes(slot.endTime);
|
||||||
|
|
||||||
|
// If there's a gap before this slot, it's a booking
|
||||||
|
if (slotStart > currentPos) {
|
||||||
|
bookedSlots.push({
|
||||||
|
startTime: minutesToTime(currentPos),
|
||||||
|
endTime: minutesToTime(slotStart)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPos = Math.max(currentPos, slotEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for booking at the end of the day
|
||||||
|
if (currentPos < dayEnd) {
|
||||||
|
bookedSlots.push({
|
||||||
|
startTime: minutesToTime(currentPos),
|
||||||
|
endTime: minutesToTime(dayEnd)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookedSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lunch protection status for all time slots on a given day
|
||||||
|
*/
|
||||||
|
export function getLunchProtectionForSlots(
|
||||||
|
dayStartTime: string,
|
||||||
|
dayEndTime: string,
|
||||||
|
existingBookings: TimeSlot[],
|
||||||
|
slotDuration: number,
|
||||||
|
slotInterval: number,
|
||||||
|
isAdmin: boolean
|
||||||
|
): Map<string, LunchProtectionResult> {
|
||||||
|
const results = new Map<string, LunchProtectionResult>();
|
||||||
|
|
||||||
|
const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime);
|
||||||
|
|
||||||
|
const windowDuration = windowEnd - windowStart;
|
||||||
|
const minimumRequired = isAdmin ? LUNCH_MINIMUM_ADMIN : LUNCH_MINIMUM_USER;
|
||||||
|
|
||||||
|
if (windowDuration < minimumRequired) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayStartMinutes = timeToMinutes(dayStartTime);
|
||||||
|
const dayEndMinutes = timeToMinutes(dayEndTime);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let slotStart = dayStartMinutes;
|
||||||
|
slotStart + slotDuration <= dayEndMinutes;
|
||||||
|
slotStart += slotInterval
|
||||||
|
) {
|
||||||
|
const slotStartStr = minutesToTime(slotStart);
|
||||||
|
const slotEndStr = minutesToTime(slotStart + slotDuration);
|
||||||
|
|
||||||
|
const result = checkLunchProtection(
|
||||||
|
dayStartTime,
|
||||||
|
dayEndTime,
|
||||||
|
existingBookings,
|
||||||
|
slotStartStr,
|
||||||
|
slotEndStr,
|
||||||
|
isAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isBlocked || result.showWarning) {
|
||||||
|
results.set(slotStartStr, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
duration_minutes: number;
|
||||||
|
patch_test_duration_hours: number;
|
||||||
|
minimum_age_required: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerInfo {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
specialRequests: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSlot {
|
||||||
|
time: string;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkingHoursDay {
|
||||||
|
date: string;
|
||||||
|
weekday: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableHoursDay {
|
||||||
|
date: string;
|
||||||
|
weekday: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
slots: Array<{ startTime: string; endTime: string }>;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
@@ -37,7 +37,12 @@
|
|||||||
<p class="mb-8 text-lg text-gray-600">
|
<p class="mb-8 text-lg text-gray-600">
|
||||||
Professional beauty treatments in a calm and friendly environment.
|
Professional beauty treatments in a calm and friendly environment.
|
||||||
</p>
|
</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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||||
|
|
||||||
// shadcn-svelte components
|
// shadcn-svelte components
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -110,7 +111,9 @@
|
|||||||
loadingUpcoming = true;
|
loadingUpcoming = true;
|
||||||
try {
|
try {
|
||||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -125,7 +128,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error fetching upcoming bookings:', err);
|
console.error('Error fetching upcoming bookings:', err);
|
||||||
toast.error('Network error loading upcoming bookings');
|
toast.error('Network error loading upcoming bookings');
|
||||||
@@ -140,7 +154,7 @@
|
|||||||
|
|
||||||
loadingPast = true;
|
loadingPast = true;
|
||||||
try {
|
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}`, {
|
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -156,7 +170,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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;
|
pastPage = data.page || page;
|
||||||
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
|
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
|
||||||
} catch (err) {
|
} 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 ===============
|
// =============== Account Deletion ===============
|
||||||
let showDeleteAlert = $state(false);
|
let showDeleteAlert = $state(false);
|
||||||
let deleteConfirmText = $state('');
|
let deleteConfirmText = $state('');
|
||||||
@@ -504,14 +556,13 @@
|
|||||||
{#each Array(3) as _, i (i)}
|
{#each Array(3) as _, i (i)}
|
||||||
<Skeleton class="h-16 w-full" />
|
<Skeleton class="h-16 w-full" />
|
||||||
{/each}
|
{/each}
|
||||||
{:else if upcomingBookings.length === 0}
|
|
||||||
<div class="py-4 text-center text-gray-500">No upcoming bookings</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#each upcomingBookings as b (b.id)}
|
{#each upcomingBookings as b (b.id)}
|
||||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
||||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<!-- Show Status Chip for Upcoming -->
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
||||||
'confirmed'
|
'confirmed'
|
||||||
@@ -520,38 +571,27 @@
|
|||||||
? 'bg-amber-100 text-amber-800'
|
? 'bg-amber-100 text-amber-800'
|
||||||
: b.status === 'in_progress'
|
: b.status === 'in_progress'
|
||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800'
|
||||||
: b.status === 'completed'
|
: 'bg-gray-100 text-gray-800'}"
|
||||||
? 'bg-green-100 text-green-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}
|
{b.status}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
- {(() => {
|
<!-- Services: Only show if data exists -->
|
||||||
const services = (b.services || []).map(
|
{#if b.services && b.services.length > 0}
|
||||||
(s) => s.service_name || 'Unknown Service'
|
<span>
|
||||||
);
|
- {(() => {
|
||||||
if (services.length === 0) return 'No services';
|
const services = b.services.map(
|
||||||
if (services.length === 1) return services[0];
|
(s) => s.service_name || 'Unknown Service'
|
||||||
if (services.length === 2) return services.join(' and ');
|
);
|
||||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
if (services.length === 1) return services[0];
|
||||||
})()}
|
if (services.length === 2) return services.join(' and ');
|
||||||
</span>
|
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button
|
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -578,45 +618,31 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
||||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span
|
<!-- Unpaid Chip: Matches the 'Confirmed' chip style but uses Red for urgency -->
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
{#if (b.amount_due || 0) > 0}
|
||||||
'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'}"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||||
? 'bg-emerald-600'
|
>
|
||||||
: b.status === 'pending'
|
Unpaid
|
||||||
? 'bg-amber-600'
|
</span>
|
||||||
: b.status === 'in_progress'
|
{/if}
|
||||||
? 'bg-blue-600'
|
|
||||||
: b.status === 'completed'
|
<!-- Services: Hidden if empty -->
|
||||||
? 'bg-green-600'
|
{#if b.services && b.services.length > 0}
|
||||||
: 'bg-gray-600'}"
|
<span>
|
||||||
></span>
|
- {(() => {
|
||||||
{b.status}
|
const services = b.services.map(
|
||||||
</span>
|
(s) => s.service_name || 'Unknown Service'
|
||||||
<span>
|
);
|
||||||
- {(() => {
|
if (services.length === 1) return services[0];
|
||||||
const services = (b.services || []).map(
|
if (services.length === 2) return services.join(' and ');
|
||||||
(s) => s.service_name || 'Unknown Service'
|
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||||
);
|
})()}
|
||||||
if (services.length === 0) return 'No services';
|
</span>
|
||||||
if (services.length === 1) return services[0];
|
{/if}
|
||||||
if (services.length === 2) return services.join(' and ');
|
|
||||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
|
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -991,6 +1017,11 @@
|
|||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- User Booking Modal -->
|
||||||
|
{#if showBookingModal && selectedBookingId}
|
||||||
|
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Mobile Tab Menu Styles */
|
/* Mobile Tab Menu Styles */
|
||||||
.mobile-tab-menu {
|
.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 CurrentAppointment from '$lib/components/today/CurrentAppointment.svelte';
|
||||||
import TodayCalendar from '$lib/components/today/TodayCalendar.svelte';
|
import TodayCalendar from '$lib/components/today/TodayCalendar.svelte';
|
||||||
import PendingApprovals from '$lib/components/today/PendingApprovals.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 CallInBooking from '$lib/components/admin/CallInBooking.svelte';
|
||||||
|
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
|
||||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||||
import UserModal from '$lib/components/admin/UserModal.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">
|
<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) -->
|
<!-- Left Column: Create a booking for a call in or messaging user (2/3 width on large screens) -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<CallInBooking />
|
<WalkInBooking />
|
||||||
</div>
|
</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) -->
|
<!-- 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">
|
<div class="space-y-6">
|
||||||
<!-- <WalkInBooking /> -->
|
<CallInBooking />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
|||||||
|
# Crussell - Beauty Salon Booking System
|
||||||
|
|
||||||
|
Stack: Go (Chi) backend | SvelteKit 5 static frontend | PostgreSQL | SabreDAV | Docker
|
||||||
|
|
||||||
|
## Dev Startup
|
||||||
|
```bash
|
||||||
|
./local-dev-2.sh # Creates tmux session 'crussell-dev' with 3 panes:
|
||||||
|
# Pane 0: psql interactive
|
||||||
|
# Pane 1: backend (go run -tags dev ./main.go)
|
||||||
|
# Pane 2: frontend (npm run dev -- --host)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
nginx:80/443 → static frontend build + /api/* → go:8080 → postgres:5432 + sabredav
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend is built static (no SSR). API calls go directly to Go backend in production. Local dev uses SvelteKit proxy for CORS.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Crussell/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.go # Router, all routes defined here
|
||||||
|
│ ├── auth/jwt.go # JWT init, signing
|
||||||
|
│ ├── auth/password.go # bcrypt hashing
|
||||||
|
│ ├── mw/auth.go # RequireAuth, RequireAdmin middleware
|
||||||
|
│ ├── db/db.go # Connection pooling
|
||||||
|
│ ├── db/db_dev.go # Dev-specific DB config
|
||||||
|
│ ├── handlers/
|
||||||
|
│ │ ├── auth/local.go # Login, register, refresh-token
|
||||||
|
│ │ ├── auth/social.go # NOT WIRED
|
||||||
|
│ │ ├── bookings/ # User + admin booking CRUD
|
||||||
|
│ │ ├── admin/ # Users, analytics (analytics NOT WIRED)
|
||||||
|
│ │ ├── scheduling/ # Default + exceptional hours
|
||||||
|
│ │ ├── services/ # Service management
|
||||||
|
│ │ ├── user/ # Profile, loyalty, account
|
||||||
|
│ │ ├── notifications/ # NOT WIRED
|
||||||
|
│ │ ├── portfolio/ # NOT WIRED
|
||||||
|
│ │ └── today/ # Current/next appointments
|
||||||
|
│ └── internal/dav/ # CardDAV/CalDAV client
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── admin/+page.svelte # Admin dashboard
|
||||||
|
│ │ ├── today/+page.svelte # Today view
|
||||||
|
│ │ ├── book/+page.svelte # Booking wizard
|
||||||
|
│ │ ├── login/+page.svelte # Auth
|
||||||
|
│ │ └── api/[...path]/+server.ts # Dev proxy only
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── admin/ # 13 components
|
||||||
|
│ │ ├── booking/ # 8 components
|
||||||
|
│ │ └── today/ # 3 components
|
||||||
|
│ ├── stores/auth.svelte.ts # Auth state
|
||||||
|
│ └── types/booking.ts # TS interfaces
|
||||||
|
├── init-scripts/init-script.sql # Full schema + functions
|
||||||
|
├── compose.yml # Docker stack
|
||||||
|
├── nginx/conf.d/ # nginx config
|
||||||
|
├── sabredav/ # DAV server
|
||||||
|
└── local-dev-2.sh # Dev environment + seeding
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helpful Greps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find all API routes
|
||||||
|
grep -n "r\.\(Get\|Post\|Put\|Delete\|Route\)" backend/main.go
|
||||||
|
|
||||||
|
# Find TODOs/FIXMEs in code
|
||||||
|
grep -rn "TODO\|FIXME" backend/ frontend/src/ --include="*.go" --include="*.svelte"
|
||||||
|
|
||||||
|
# Find unwired handlers (imported in main.go?)
|
||||||
|
grep -n "import.*handlers" backend/main.go
|
||||||
|
|
||||||
|
# Shows all three booking creation handlers: user self-booking, admin walk-in, admin call/message-in
|
||||||
|
grep -rn "func.*Create.*Booking\|func.*WalkIn\|func.*Walk.*In\|POST.*booking" backend/handlers/ --include="*.go"
|
||||||
|
|
||||||
|
# Shows all three frontend booking flows: customer BookingFlow, admin walk-in modal, admin booking modal
|
||||||
|
grep -rln "BookingFlow\|WalkIn\|walk-in\|call.*in\|message.*in" frontend/src/lib/components/ --include="*.svelte"
|
||||||
|
F
|
||||||
|
# Find where each booking flow starts - API routes and page loads
|
||||||
|
grep -rn "booking.*create\|/api/bookings\|booking/POST\|booking/new" backend/ frontend/ --include="*.go" --include="*.ts"
|
||||||
|
|
||||||
|
# Find all booking status handling
|
||||||
|
grep -rn "booking_status\|in_progress\|confirmed\|pending" backend/handlers/
|
||||||
|
|
||||||
|
# Find frontend API calls
|
||||||
|
grep -rn "fetch.*\/api\/" frontend/src/ --include="*.svelte" --include="*.ts"
|
||||||
|
|
||||||
|
# Find auth-protected routes
|
||||||
|
grep -n "RequireAuth\|RequireAdmin" backend/main.go
|
||||||
|
|
||||||
|
# Find Svelte 5 reactive state
|
||||||
|
grep -n "\$state\|\$derived\|\$effect" frontend/src/ -r --include="*.svelte"
|
||||||
|
|
||||||
|
# Find SQL function definitions
|
||||||
|
grep -n "CREATE.*FUNCTION" init-scripts/init-script.sql
|
||||||
|
|
||||||
|
# Find specific handler implementation
|
||||||
|
grep -l "func.*Handler" backend/handlers/**/*.go
|
||||||
|
|
||||||
|
# Find transaction patterns
|
||||||
|
grep -rn "tx, err := db.DB.Begin" backend/handlers/
|
||||||
|
|
||||||
|
# Find refresh token handler (exists but not wired)
|
||||||
|
grep -n "RefreshTokenHandler" backend/handlers/auth/local.go
|
||||||
|
|
||||||
|
# Find notification handler (exists but not wired)
|
||||||
|
grep -n "func.*Notification" backend/handlers/notifications/notifications.go
|
||||||
|
|
||||||
|
# Find guest booking TODO in frontend
|
||||||
|
grep -n "TODO.*guest\|guest.*TODO" frontend/src/lib/components/admin/WalkInCreateModal.svelte
|
||||||
|
|
||||||
|
# Find console.logs to remove
|
||||||
|
grep -rn "console\.log" frontend/src/ --include="*.svelte" | grep -v node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
JWT in localStorage → decoded for role/user_id → profile fetch from `/api/user/profile`. Refresh logic exists but endpoint not wired. Roles: `unverified_email | verified_email | admin | guest | affiliate`
|
||||||
|
|
||||||
|
**Admin promotion**: Direct SQL only, no API endpoint: `UPDATE users SET account_role = 'admin' WHERE email = '...'`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Names: 1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot only
|
||||||
|
- Phone: UK format, converted to E.164 (+44...)
|
||||||
|
- Email: standard format validation
|
||||||
|
- Age: Must be 16+ years old
|
||||||
|
- Login rate limit: 1 attempt per 5 seconds
|
||||||
|
|
||||||
|
## Booking Status Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → confirmed → in_progress → completed
|
||||||
|
↘ client_cancelled | we_cancelled | no_show | re-schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
TODO: `in_progress` should auto-infer by time OR manual "Begin" button (gray if >3hrs away).
|
||||||
|
|
||||||
|
## Scheduling
|
||||||
|
|
||||||
|
- `/api/scheduling/default-hours` - Weekly template
|
||||||
|
- `/api/scheduling/exceptional-groups` - Recurring exceptions (holidays)
|
||||||
|
- `/api/scheduling/working-hours` - Merged result (default + applied exceptions)
|
||||||
|
- `/api/scheduling/available-hours` - Slots minus bookings
|
||||||
|
|
||||||
|
All 3 booking flows (customer, call-in, walk-in) correctly use merged hours.
|
||||||
|
|
||||||
|
## Critical TODOs
|
||||||
|
|
||||||
|
**HIGH:**
|
||||||
|
- `BookingFlow.svelte:600` - `submitBooking()` logs only, needs `POST /api/bookings`
|
||||||
|
- `BookingCreateModal.svelte:224` - Remove `console.log(users)` debug
|
||||||
|
- `/api/users/guest` - Guest endpoint for walk-ins
|
||||||
|
- One-off custom services (single booking, no list add)
|
||||||
|
- One-off exceptional hours (single day, not recurring)
|
||||||
|
- Auto lunch protection (block if removes 1h lunch, 30min admin with warning)
|
||||||
|
- Walk-in slot blocking during intake
|
||||||
|
- Square payment integration
|
||||||
|
- GDPR export endpoint (`export_all_user_data()` SQL exists)
|
||||||
|
- Tax data export (admin, software-compatible format)
|
||||||
|
|
||||||
|
**MEDIUM:**
|
||||||
|
- Notifications UI (frontend panel for admin notifications)
|
||||||
|
- Notifications push (WebSocket/polling mechanism)
|
||||||
|
- User notifications (booking confirmations, reminders for customers)
|
||||||
|
- Refresh token endpoint (handler exists, not wired)
|
||||||
|
- Loyalty display component
|
||||||
|
- S3/R2 for portfolio images
|
||||||
|
- Prometheus metrics
|
||||||
|
- CI/CD (Gitea)
|
||||||
|
|
||||||
|
**NOT WIRED:**
|
||||||
|
- `handlers/auth/social.go`
|
||||||
|
- `handlers/admin/analytics.go`
|
||||||
|
- `handlers/portfolio/images.go`
|
||||||
|
|
||||||
|
## Dev Build Tag
|
||||||
|
Backend uses `go run -tags dev ./main.go` - check for dev-specific behavior.
|
||||||
|
|
||||||
|
## API JSON Examples
|
||||||
|
|
||||||
|
**Booking:** `{"start_time":"2025-01-15T10:00:00+00:00","service_ids":["abc123def456"],"notes":"optional"}`
|
||||||
|
|
||||||
|
**Service:** `{"name":"Classic Manicure","description":"...","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}`
|
||||||
|
|
||||||
|
**Confirm booking:** `POST /api/admin/bookings/{id}/confirm` with body `{"serviceOverrides":[]}`
|
||||||
|
|
||||||
|
**Exceptional group:** `{"name":"Holiday","description":"...","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},...],"weekStarts":["2025-12-22"]}`
|
||||||
|
|
||||||
|
## Database Enums
|
||||||
|
|
||||||
|
```sql
|
||||||
|
account_role, account_type, booking_status, payment_type, payment_method, payment_status, admin_notification_reason
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current `admin_notification_reason`**: `pending_booking | cancelled_booking | rescheduled_booking | 1_week_no_pay | 1_month_no_pay | affiliate_claim`
|
||||||
|
|
||||||
|
**Suggested additions**: `no_show`, `payment_failed`, `patch_test_due`, `loyalty_milestone`, `first_time_customer`, `vip_booking`, `inactive_customer`, `birthday_this_week`, `special_request`, `schedule_conflict`
|
||||||
|
|
||||||
|
## Key SQL Functions
|
||||||
|
|
||||||
|
`anonymize_user()`, `export_all_user_data()`, `delete_guest_user()`, `get_vat_return_data()`, `calculate_vat()`, `get_receipt_data()`
|
||||||
|
|
||||||
|
## Env Required
|
||||||
|
|
||||||
|
`JWT_SECRET_KEY`, `DATABASE_URL`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- IDs: 12-char generated strings (not UUIDs)
|
||||||
|
- Timezone: UK local throughout (`TZ=Europe/London`)
|
||||||
|
- Frontend: Svelte 5 runes (`$state`, `$derived`, `$effect`)
|
||||||
|
- Auth header: `Authorization: Bearer ${token}`
|
||||||
|
- All times in ISO format with timezone: `YYYY-MM-DDTHH:MM:SS±HH:MM`
|
||||||
|
|
||||||
|
## Seed Data (local-dev-2.sh)
|
||||||
|
- 18 users (1 admin, 17 regular)
|
||||||
|
- 6 services (manicure, gel, pedicure, express, removal, nail-art)
|
||||||
|
- 45 bookings (8 past, 3 today, 4 tomorrow, 30 future spread over 15 days)
|
||||||
|
- ~50% of upcoming bookings auto-confirmed
|
||||||
|
- 2 exceptional groups (November Break, Christmas Holiday)
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
**Transaction pattern** (used throughout):
|
||||||
|
```go
|
||||||
|
tx, err := db.DB.Begin(r.Context())
|
||||||
|
if err != nil { ... }
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
// ... queries using tx instead of db.DB ...
|
||||||
|
if err := tx.Commit(r.Context()); err != nil { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**CardDAV sync**:
|
||||||
|
- On registration: creates vCard in SabreDAV
|
||||||
|
- On profile update: updates existing vCard via `updateCardDAV()` helper
|
||||||
|
- Uses internal HTTP calls to DAV server
|
||||||
|
|
||||||
|
**Role change detection**: `RefreshTokenHandler` checks if role changed since token issued - forces re-login if so.
|
||||||
Executable
+416
@@ -0,0 +1,416 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
SESSION_NAME="crussell-dev"
|
||||||
|
SEED_SCRIPT="/tmp/seed_data.sh"
|
||||||
|
|
||||||
|
setopt NO_UNSET
|
||||||
|
setopt PIPE_FAIL
|
||||||
|
setopt ERR_EXIT
|
||||||
|
|
||||||
|
# --- UI Helpers ---
|
||||||
|
log_info() { echo "🔹 $1" }
|
||||||
|
log_success() { echo "✅ $1" }
|
||||||
|
log_error() { echo "❌ $1" }
|
||||||
|
log_step() { echo "▶️ $1" }
|
||||||
|
|
||||||
|
# --- 1. Environment Setup ---
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
log_success "Loaded environment variables"
|
||||||
|
else
|
||||||
|
log_error ".env file not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. Docker Checks ---
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
log_info "Docker daemon not running. Starting..."
|
||||||
|
sudo systemctl start docker
|
||||||
|
sleep 2
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
log_error "Failed to start Docker."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_success "Docker started"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Database Reset ---
|
||||||
|
log_step "Resetting PostgreSQL..."
|
||||||
|
docker compose down -v postgres > /dev/null 2>&1
|
||||||
|
docker compose up postgres -d > /dev/null 2>&1
|
||||||
|
log_success "PostgreSQL reset complete"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# --- 4. Tmux Session Setup ---
|
||||||
|
if tmux has-session -t $SESSION_NAME 2>/dev/null; then
|
||||||
|
log_info "Killing existing tmux session..."
|
||||||
|
tmux kill-session -t $SESSION_NAME
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "Starting tmux session '$SESSION_NAME'..."
|
||||||
|
tmux new-session -d -s $SESSION_NAME -n "Workspace"
|
||||||
|
|
||||||
|
# Pane 0: Database
|
||||||
|
# Start interactive shell only. Stats will be shown after seeding.
|
||||||
|
tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb" Enter
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
|
||||||
|
|
||||||
|
# Pane 1: Backend (Split Horizontally)
|
||||||
|
tmux split-window -v -t $SESSION_NAME
|
||||||
|
tmux send-keys -t $SESSION_NAME "cd backend && go run -tags dev ./main.go" Enter
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
|
||||||
|
|
||||||
|
# Pane 2: Frontend (Split Vertically from Backend)
|
||||||
|
tmux split-window -h -t $SESSION_NAME:0.1
|
||||||
|
tmux send-keys -t $SESSION_NAME "cd frontend && npm run dev -- --host" Enter
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend"
|
||||||
|
|
||||||
|
# Layout configuration
|
||||||
|
tmux select-layout -t $SESSION_NAME even-vertical
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.0
|
||||||
|
|
||||||
|
# --- 5. Seed Script Generation ---
|
||||||
|
log_step "Generating seed script..."
|
||||||
|
|
||||||
|
cat > $SEED_SCRIPT << 'SEED_EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
ADMIN_EMAIL="admin@example.com"
|
||||||
|
ADMIN_PASS="password"
|
||||||
|
USER_EMAIL="user@example.com"
|
||||||
|
USER_PASS="password"
|
||||||
|
BASE_URL="http://localhost:8080/api"
|
||||||
|
|
||||||
|
# --- Formatting ---
|
||||||
|
C_RESET=$'\033[0m'
|
||||||
|
C_GREEN=$'\033[32m'
|
||||||
|
C_RED=$'\033[31m'
|
||||||
|
C_BLUE=$'\033[34m'
|
||||||
|
C_YELLOW=$'\033[33m'
|
||||||
|
|
||||||
|
# --- Global for ID Capture ---
|
||||||
|
LAST_BOOKING_ID=""
|
||||||
|
|
||||||
|
# --- Helper: API Request ---
|
||||||
|
# --- Helper: API Request ---
|
||||||
|
api_post() {
|
||||||
|
local url="$1"
|
||||||
|
local data="$2"
|
||||||
|
local desc="$3"
|
||||||
|
local token="$4"
|
||||||
|
|
||||||
|
local curl_opts=(-s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json')
|
||||||
|
[[ -n "$token" ]] && curl_opts+=(-H "Authorization: Bearer $token")
|
||||||
|
[[ -n "$data" ]] && curl_opts+=(-d "$data")
|
||||||
|
|
||||||
|
local response=$(curl "${curl_opts[@]}" "$url")
|
||||||
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
|
local body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2 ]]; then
|
||||||
|
# FIX:
|
||||||
|
# 1. tr -d '\n': Ensure JSON is treated as a single line (handles pretty-printing).
|
||||||
|
# 2. sed 's/"user":{[^}]*}//': Remove the "user" object entirely.
|
||||||
|
# [^}]* matches everything up to the first closing brace, which is safe for UserSummary (flat object).
|
||||||
|
# 3. grep/cut: Extract the remaining root 'id' (which is now the Booking ID).
|
||||||
|
echo "$body" | tr -d '\n' | sed 's/"user":{[^}]*}//' | grep -o '"id":"[^"]*' | cut -d'"' -f4 | tr -d '\r\n'
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
printf "${C_RED}❌ Failed: %s (HTTP %s)${C_RESET}\n" "$desc" "$http_code"
|
||||||
|
printf " Request: %s\n" "$data"
|
||||||
|
printf " Response: %s\n" "$body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_backend() {
|
||||||
|
echo -ne "⏳ Waiting for backend..."
|
||||||
|
for ((i=1; i<=60; i++)); do
|
||||||
|
if curl -s --connect-timeout 2 http://localhost:8080/api/register > /dev/null 2>&1; then
|
||||||
|
echo -e "\r⏳ Waiting for backend... ${C_GREEN}Ready!${C_RESET}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -e "\r⏳ Waiting for backend... ${C_RED}Timed out${C_RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main Execution ---
|
||||||
|
wait_for_backend
|
||||||
|
|
||||||
|
# 1. Register Users
|
||||||
|
echo -e "\n${C_BLUE}👤 Registering Users...${C_RESET}"
|
||||||
|
success=0
|
||||||
|
total=18
|
||||||
|
|
||||||
|
# Admin user
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Admin\",\"lastName\":\"User\",\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"phone\":\"+447000000000\",\"dateOfBirth\":\"1985-01-01\",\"agreedToPolicy\":true}" "Register Admin" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
|
||||||
|
# Original test user
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Regular\",\"lastName\":\"User\",\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\",\"phone\":\"+447000000001\",\"dateOfBirth\":\"1990-05-15\",\"agreedToPolicy\":true}" "Register User" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
|
||||||
|
# Additional test users (16 more)
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Emma\",\"lastName\":\"Johnson\",\"email\":\"emma.johnson@example.com\",\"password\":\"password\",\"phone\":\"+447000000002\",\"dateOfBirth\":\"1988-03-22\",\"agreedToPolicy\":true}" "Register Emma Johnson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Oliver\",\"lastName\":\"Smith\",\"email\":\"oliver.smith@example.com\",\"password\":\"password\",\"phone\":\"+447000000003\",\"dateOfBirth\":\"1992-07-14\",\"agreedToPolicy\":true}" "Register Oliver Smith" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Sophie\",\"lastName\":\"Williams\",\"email\":\"sophie.williams@example.com\",\"password\":\"password\",\"phone\":\"+447000000004\",\"dateOfBirth\":\"1995-11-08\",\"agreedToPolicy\":true}" "Register Sophie Williams" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Harry\",\"lastName\":\"Brown\",\"email\":\"harry.brown@example.com\",\"password\":\"password\",\"phone\":\"+447000000005\",\"dateOfBirth\":\"1987-02-19\",\"agreedToPolicy\":true}" "Register Harry Brown" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Amelia\",\"lastName\":\"Jones\",\"email\":\"amelia.jones@example.com\",\"password\":\"password\",\"phone\":\"+447000000006\",\"dateOfBirth\":\"1993-09-30\",\"agreedToPolicy\":true}" "Register Amelia Jones" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Jack\",\"lastName\":\"Taylor\",\"email\":\"jack.taylor@example.com\",\"password\":\"password\",\"phone\":\"+447000000007\",\"dateOfBirth\":\"1991-05-12\",\"agreedToPolicy\":true}" "Register Jack Taylor" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Isla\",\"lastName\":\"Davies\",\"email\":\"isla.davies@example.com\",\"password\":\"password\",\"phone\":\"+447000000008\",\"dateOfBirth\":\"1989-12-25\",\"agreedToPolicy\":true}" "Register Isla Davies" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Thomas\",\"lastName\":\"Evans\",\"email\":\"thomas.evans@example.com\",\"password\":\"password\",\"phone\":\"+447000000009\",\"dateOfBirth\":\"1994-04-17\",\"agreedToPolicy\":true}" "Register Thomas Evans" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Lily\",\"lastName\":\"Wilson\",\"email\":\"lily.wilson@example.com\",\"password\":\"password\",\"phone\":\"+447000000010\",\"dateOfBirth\":\"1996-08-03\",\"agreedToPolicy\":true}" "Register Lily Wilson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"George\",\"lastName\":\"Roberts\",\"email\":\"george.roberts@example.com\",\"password\":\"password\",\"phone\":\"+447000000011\",\"dateOfBirth\":\"1986-01-29\",\"agreedToPolicy\":true}" "Register George Roberts" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Poppy\",\"lastName\":\"Thompson\",\"email\":\"poppy.thompson@example.com\",\"password\":\"password\",\"phone\":\"+447000000012\",\"dateOfBirth\":\"1997-06-21\",\"agreedToPolicy\":true}" "Register Poppy Thompson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Charlie\",\"lastName\":\"Wright\",\"email\":\"charlie.wright@example.com\",\"password\":\"password\",\"phone\":\"+447000000013\",\"dateOfBirth\":\"1990-10-11\",\"agreedToPolicy\":true}" "Register Charlie Wright" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Ava\",\"lastName\":\"Walker\",\"email\":\"ava.walker@example.com\",\"password\":\"password\",\"phone\":\"+447000000014\",\"dateOfBirth\":\"1993-03-07\",\"agreedToPolicy\":true}" "Register Ava Walker" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Noah\",\"lastName\":\"Robinson\",\"email\":\"noah.robinson@example.com\",\"password\":\"password\",\"phone\":\"+447000000015\",\"dateOfBirth\":\"1988-11-16\",\"agreedToPolicy\":true}" "Register Noah Robinson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Mia\",\"lastName\":\"White\",\"email\":\"mia.white@example.com\",\"password\":\"password\",\"phone\":\"+447000000016\",\"dateOfBirth\":\"1995-07-28\",\"agreedToPolicy\":true}" "Register Mia White" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Oscar\",\"lastName\":\"Hughes\",\"email\":\"oscar.hughes@example.com\",\"password\":\"password\",\"phone\":\"+447000000017\",\"dateOfBirth\":\"1991-02-04\",\"agreedToPolicy\":true}" "Register Oscar Hughes" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
|
||||||
|
# Promote Admin
|
||||||
|
docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'" > /dev/null 2>&1
|
||||||
|
echo "${C_GREEN}✅ Registered $success/$total Users${C_RESET}"
|
||||||
|
|
||||||
|
# 2. Login
|
||||||
|
echo -e "\n${C_BLUE}🔑 Authenticating...${C_RESET}"
|
||||||
|
LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" "$BASE_URL/login")
|
||||||
|
# FIX 2: Clean token extraction
|
||||||
|
ADMIN_TOKEN=$(echo "$LOGIN_RESP" \
|
||||||
|
| tr -d '\r\n\t ' \
|
||||||
|
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
if [ -z "$ADMIN_TOKEN" ]; then
|
||||||
|
echo "❌ Admin auth failed. Response: $LOGIN_RESP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
USER_LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\"}" "$BASE_URL/login")
|
||||||
|
USER_TOKEN=$(echo "$USER_LOGIN_RESP" \
|
||||||
|
| tr -d '\r\n\t ' \
|
||||||
|
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
|
||||||
|
if [ -z "$USER_TOKEN" ]; then
|
||||||
|
echo "❌ User auth failed. Response: $USER_LOGIN_RESP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "${C_GREEN}✅ Authentication successful${C_RESET}"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# 3. Create Services
|
||||||
|
echo -e "\n${C_BLUE}💅 Creating Services...${C_RESET}"
|
||||||
|
SERVICES=(
|
||||||
|
'{"name":"Classic Manicure","description":"Nail shaping, cuticle care, hand massage, and polish.","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Gel Manicure (BIAB)","description":"Hard-wearing gel polish with Builder In A Bottle base.","price":35.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Luxury Pedicure","description":"Foot soak, scrub, mask, extended massage, and polish.","price":45.00,"duration_minutes":75,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICE_IDS=()
|
||||||
|
success=0
|
||||||
|
total=${#SERVICES[@]}
|
||||||
|
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
NAME=$(echo "$svc" | grep -o '"name":"[^"]*' | cut -d'"' -f4)
|
||||||
|
ID=$(api_post "$BASE_URL/admin/services" "$svc" "Create $NAME" "$ADMIN_TOKEN")
|
||||||
|
if [[ -n "$ID" ]]; then
|
||||||
|
ID=$(echo "$ID" | tr -cd '[:alnum:]-')
|
||||||
|
SERVICE_IDS+=("$ID")
|
||||||
|
success=$((success+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "${C_GREEN}✅ Created $success/$total Services${C_RESET}"
|
||||||
|
|
||||||
|
# 4. Create Bookings
|
||||||
|
echo -e "\n${C_BLUE}📅 Creating Bookings...${C_RESET}"
|
||||||
|
|
||||||
|
format_london_time() {
|
||||||
|
TZ=Europe/London date -d "$1 $2" +"%Y-%m-%dT%H:%M:%S%:z"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_booking() {
|
||||||
|
local token=$1 time=$2 services=$3 notes=$4 name=$5
|
||||||
|
local json="{\"start_time\":\"$time\",\"service_ids\":$services"
|
||||||
|
[[ -n "$notes" ]] && json="$json,\"notes\":\"$notes\""
|
||||||
|
json="$json}"
|
||||||
|
|
||||||
|
# Call API and capture ID
|
||||||
|
local id=$(api_post "$BASE_URL/bookings" "$json" "Book: $name" "$token")
|
||||||
|
|
||||||
|
if [[ -n "$id" ]]; then
|
||||||
|
id=$(echo "$id" | tr -cd '[:alnum:]-')
|
||||||
|
LAST_BOOKING_ID="$id"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
LAST_BOOKING_ID=""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_svc() { echo "${SERVICE_IDS[$1]}"; }
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
count_past=0
|
||||||
|
count_today=0
|
||||||
|
count_tomorrow=0
|
||||||
|
count_future=0
|
||||||
|
|
||||||
|
# Array to hold IDs of upcoming bookings for confirmation step
|
||||||
|
UPCOMING_BOOKING_IDS=()
|
||||||
|
UPCOMING_BOOKING_NAMES=()
|
||||||
|
|
||||||
|
# --- PAST BOOKINGS (8 Total) ---
|
||||||
|
echo -e "\n${C_YELLOW}📅 Creating 8 Past Bookings (Last 8 days)...${C_RESET}"
|
||||||
|
for day_offset in {1..8}; do
|
||||||
|
PAST_DATE=$(TZ=Europe/London date -d "today -$day_offset days" +%Y-%m-%d)
|
||||||
|
|
||||||
|
# Alternate times
|
||||||
|
if [ $((day_offset % 2)) -eq 0 ]; then
|
||||||
|
TIME="14:00:00"
|
||||||
|
else
|
||||||
|
TIME="10:00:00"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Alternate services
|
||||||
|
SVC_IDX=$(( (day_offset % 2) ))
|
||||||
|
NAME="Past ($PAST_DATE) - $(echo "${SERVICES[$SVC_IDX]}" | grep -o '"name":"[^"]*' | cut -d'"' -f4)"
|
||||||
|
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$PAST_DATE" "$TIME")" "[\"$(get_svc $SVC_IDX)\"]" "" "$NAME"; then
|
||||||
|
count_past=$((count_past+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "${C_GREEN}✅ Created $count_past/8 Past Bookings${C_RESET}"
|
||||||
|
|
||||||
|
# --- TODAY (3) ---
|
||||||
|
TODAY=$(TZ=Europe/London date +%Y-%m-%d)
|
||||||
|
TOMORROW=$(TZ=Europe/London date -d "tomorrow" +%Y-%m-%d)
|
||||||
|
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "09:30:00")" "[\"$(get_svc 0)\"]" "" "Today - Classic Manicure"; then
|
||||||
|
count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Classic Manicure");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "11:30:00")" "[\"$(get_svc 1)\"]" "" "Today - Gel Manicure"; then
|
||||||
|
count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Gel Manicure");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "14:00:00")" "[\"$(get_svc 2)\"]" "" "Today - Luxury Pedicure"; then
|
||||||
|
count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Luxury Pedicure");
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- TOMORROW (4) ---
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "09:00:00")" "[\"$(get_svc 3)\"]" "" "Tomorrow - Express Mani & Pedi"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Express Mani & Pedi");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "10:30:00")" "[\"$(get_svc 0)\",\"$(get_svc 5)\"]" "" "Tomorrow - Classic + Nail Art"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Classic + Nail Art");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "13:00:00")" "[\"$(get_svc 1)\"]" "" "Tomorrow - Gel Manicure"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Gel Manicure");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "15:30:00")" "[\"$(get_svc 2)\"]" "" "Tomorrow - Luxury Pedicure"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Luxury Pedicure");
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- FUTURE (30) ---
|
||||||
|
# Spread over the next 15 days (Day +2 to Day +16)
|
||||||
|
for day_offset in {2..16}; do
|
||||||
|
FUTURE_DATE=$(TZ=Europe/London date -d "$TODAY +$day_offset days" +%Y-%m-%d)
|
||||||
|
|
||||||
|
# Morning Slot (10:00)
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "10:00:00")" "[\"$(get_svc 0)\"]" "" "Future ($FUTURE_DATE) - Classic Manicure"; then
|
||||||
|
count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) AM");
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Afternoon Slot (14:30)
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "14:30:00")" "[\"$(get_svc 1)\"]" "" "Future ($FUTURE_DATE) - Gel Manicure"; then
|
||||||
|
count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) PM");
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
TOTAL=$((count_today + count_tomorrow + count_future + count_past))
|
||||||
|
echo "${C_GREEN}✅ Created $count_today/3 Bookings (Today)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $count_tomorrow/4 Bookings (Tomorrow)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $count_future/30 Bookings (Future)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $count_past/8 Bookings (Past)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $TOTAL/45 Bookings (Total)${C_RESET}"
|
||||||
|
|
||||||
|
# 5. Confirm Random Half of Upcoming Bookings
|
||||||
|
echo -e "\n${C_BLUE}🔒 Confirming Random Upcoming Bookings...${C_RESET}"
|
||||||
|
confirmed_count=0
|
||||||
|
total_upcoming=${#UPCOMING_BOOKING_IDS[@]}
|
||||||
|
|
||||||
|
# Small pause to ensure backend is ready after bulk creation
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
for ((i=0; i<${#UPCOMING_BOOKING_IDS[@]}; i++)); do
|
||||||
|
# FIX 3: Sanitize ID again just before use to ensure no hidden characters broke the array
|
||||||
|
id="${UPCOMING_BOOKING_IDS[$i]}"
|
||||||
|
name="${UPCOMING_BOOKING_NAMES[$i]}"
|
||||||
|
|
||||||
|
# Ensure ID is not empty
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Random coin flip (0 or 1). If 1, confirm.
|
||||||
|
if [ $((RANDOM % 2)) -eq 1 ]; then
|
||||||
|
# Send proper JSON with empty serviceOverrides array
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-d '{"serviceOverrides":[]}' \
|
||||||
|
"$BASE_URL/admin/bookings/$id/confirm")
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo " ✅ Confirmed: $name"
|
||||||
|
confirmed_count=$((confirmed_count+1))
|
||||||
|
else
|
||||||
|
echo " ⚠️ Failed to confirm: $name (HTTP $HTTP_CODE)"
|
||||||
|
echo " Response: $BODY"
|
||||||
|
fi
|
||||||
|
# Small sleep to prevent overwhelming the server
|
||||||
|
sleep 0.1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "${C_GREEN}✅ Confirmed $confirmed_count upcoming bookings${C_RESET}"
|
||||||
|
|
||||||
|
# 6. Exceptional Groups (2 Total)
|
||||||
|
echo -e "\n${C_BLUE}🗓️ Creating Exceptional Groups...${C_RESET}"
|
||||||
|
NOV_BREAK='{"name":"November Break","description":"Short break period in November","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":2,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-11-10"]}'
|
||||||
|
XMAS_BREAK='{"name":"Christmas Holiday Period","description":"Reduced hours for Christmas and New Year","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":2,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-12-22","2025-12-29"]}'
|
||||||
|
|
||||||
|
success=0
|
||||||
|
total=2
|
||||||
|
if api_post "$BASE_URL/scheduling/exceptional-groups" "$NOV_BREAK" "November Break" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas Holiday" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
|
||||||
|
echo "${C_GREEN}✅ Created $success/$total Exceptional Groups${C_RESET}"
|
||||||
|
|
||||||
|
# --- UPDATE DB PANE STATS ---
|
||||||
|
if [ -n "$SESSION_NAME" ]; then
|
||||||
|
echo -e "\n⏳ Updating DB pane stats..."
|
||||||
|
tmux send-keys -t "$SESSION_NAME:0.0" 'SELECT relname AS table_name, n_live_tup AS row_count FROM pg_stat_user_tables ORDER BY table_name\;'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${C_GREEN}🎉 Seeding Complete!${C_RESET}"
|
||||||
|
read -n1 -s -p "Press any key to close this window..."
|
||||||
|
SEED_EOF
|
||||||
|
|
||||||
|
chmod +x $SEED_SCRIPT
|
||||||
|
|
||||||
|
# --- 6. Execute Seed Script in Tmux ---
|
||||||
|
log_step "Starting seeding process in new window..."
|
||||||
|
# Pass SESSION_NAME to the seed script
|
||||||
|
tmux new-window -t $SESSION_NAME -n "Seeding" "SESSION_NAME=$SESSION_NAME $SEED_SCRIPT"
|
||||||
|
|
||||||
|
# --- 7. Finalize ---
|
||||||
|
trap 'rm -f $SEED_SCRIPT' EXIT
|
||||||
|
log_success "Environment ready. Attaching to session..."
|
||||||
|
tmux attach-session -t $SESSION_NAME
|
||||||
Executable
+416
@@ -0,0 +1,416 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
SESSION_NAME="crussell-dev"
|
||||||
|
SEED_SCRIPT="/tmp/seed_data.sh"
|
||||||
|
|
||||||
|
setopt NO_UNSET
|
||||||
|
setopt PIPE_FAIL
|
||||||
|
setopt ERR_EXIT
|
||||||
|
|
||||||
|
# --- UI Helpers ---
|
||||||
|
log_info() { echo "🔹 $1" }
|
||||||
|
log_success() { echo "✅ $1" }
|
||||||
|
log_error() { echo "❌ $1" }
|
||||||
|
log_step() { echo "▶️ $1" }
|
||||||
|
|
||||||
|
# --- 1. Environment Setup ---
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
log_success "Loaded environment variables"
|
||||||
|
else
|
||||||
|
log_error ".env file not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. Docker Checks ---
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
log_info "Docker daemon not running. Starting..."
|
||||||
|
sudo systemctl start docker
|
||||||
|
sleep 2
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
log_error "Failed to start Docker."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_success "Docker started"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Database Reset ---
|
||||||
|
log_step "Resetting PostgreSQL..."
|
||||||
|
docker compose down -v postgres > /dev/null 2>&1
|
||||||
|
docker compose up postgres -d > /dev/null 2>&1
|
||||||
|
log_success "PostgreSQL reset complete"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# --- 4. Tmux Session Setup ---
|
||||||
|
if tmux has-session -t $SESSION_NAME 2>/dev/null; then
|
||||||
|
log_info "Killing existing tmux session..."
|
||||||
|
tmux kill-session -t $SESSION_NAME
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "Starting tmux session '$SESSION_NAME'..."
|
||||||
|
tmux new-session -d -s $SESSION_NAME -n "Workspace"
|
||||||
|
|
||||||
|
# Pane 0: Database
|
||||||
|
# Start interactive shell only. Stats will be shown after seeding.
|
||||||
|
tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb" Enter
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
|
||||||
|
|
||||||
|
# Pane 1: Backend (Split Horizontally)
|
||||||
|
tmux split-window -v -t $SESSION_NAME
|
||||||
|
tmux send-keys -t $SESSION_NAME "cd backend && go run -tags dev ./main.go" Enter
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
|
||||||
|
|
||||||
|
# Pane 2: Frontend (Split Vertically from Backend)
|
||||||
|
tmux split-window -h -t $SESSION_NAME:0.1
|
||||||
|
tmux send-keys -t $SESSION_NAME "cd frontend && npm run dev -- --host" Enter
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend"
|
||||||
|
|
||||||
|
# Layout configuration
|
||||||
|
tmux select-layout -t $SESSION_NAME even-vertical
|
||||||
|
tmux select-pane -t $SESSION_NAME:0.0
|
||||||
|
|
||||||
|
# --- 5. Seed Script Generation ---
|
||||||
|
log_step "Generating seed script..."
|
||||||
|
|
||||||
|
cat > $SEED_SCRIPT << 'SEED_EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
ADMIN_EMAIL="admin@example.com"
|
||||||
|
ADMIN_PASS="password"
|
||||||
|
USER_EMAIL="user@example.com"
|
||||||
|
USER_PASS="password"
|
||||||
|
BASE_URL="http://localhost:8080/api"
|
||||||
|
|
||||||
|
# --- Formatting ---
|
||||||
|
C_RESET=$'\033[0m'
|
||||||
|
C_GREEN=$'\033[32m'
|
||||||
|
C_RED=$'\033[31m'
|
||||||
|
C_BLUE=$'\033[34m'
|
||||||
|
C_YELLOW=$'\033[33m'
|
||||||
|
|
||||||
|
# --- Global for ID Capture ---
|
||||||
|
LAST_BOOKING_ID=""
|
||||||
|
|
||||||
|
# --- Helper: API Request ---
|
||||||
|
# --- Helper: API Request ---
|
||||||
|
api_post() {
|
||||||
|
local url="$1"
|
||||||
|
local data="$2"
|
||||||
|
local desc="$3"
|
||||||
|
local token="$4"
|
||||||
|
|
||||||
|
local curl_opts=(-s -w "\n%{http_code}" -X POST -H 'Content-Type: application/json')
|
||||||
|
[[ -n "$token" ]] && curl_opts+=(-H "Authorization: Bearer $token")
|
||||||
|
[[ -n "$data" ]] && curl_opts+=(-d "$data")
|
||||||
|
|
||||||
|
local response=$(curl "${curl_opts[@]}" "$url")
|
||||||
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
|
local body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2 ]]; then
|
||||||
|
# FIX:
|
||||||
|
# 1. tr -d '\n': Ensure JSON is treated as a single line (handles pretty-printing).
|
||||||
|
# 2. sed 's/"user":{[^}]*}//': Remove the "user" object entirely.
|
||||||
|
# [^}]* matches everything up to the first closing brace, which is safe for UserSummary (flat object).
|
||||||
|
# 3. grep/cut: Extract the remaining root 'id' (which is now the Booking ID).
|
||||||
|
echo "$body" | tr -d '\n' | sed 's/"user":{[^}]*}//' | grep -o '"id":"[^"]*' | cut -d'"' -f4 | tr -d '\r\n'
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
printf "${C_RED}❌ Failed: %s (HTTP %s)${C_RESET}\n" "$desc" "$http_code"
|
||||||
|
printf " Request: %s\n" "$data"
|
||||||
|
printf " Response: %s\n" "$body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_backend() {
|
||||||
|
echo -ne "⏳ Waiting for backend..."
|
||||||
|
for ((i=1; i<=60; i++)); do
|
||||||
|
if curl -s --connect-timeout 2 http://localhost:8080/api/register > /dev/null 2>&1; then
|
||||||
|
echo -e "\r⏳ Waiting for backend... ${C_GREEN}Ready!${C_RESET}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -e "\r⏳ Waiting for backend... ${C_RED}Timed out${C_RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main Execution ---
|
||||||
|
wait_for_backend
|
||||||
|
|
||||||
|
# 1. Register Users
|
||||||
|
echo -e "\n${C_BLUE}👤 Registering Users...${C_RESET}"
|
||||||
|
success=0
|
||||||
|
total=18
|
||||||
|
|
||||||
|
# Admin user
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Admin\",\"lastName\":\"User\",\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"phone\":\"+447000000000\",\"dateOfBirth\":\"1985-01-01\",\"agreedToPolicy\":true}" "Register Admin" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
|
||||||
|
# Original test user
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Regular\",\"lastName\":\"User\",\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\",\"phone\":\"+447000000001\",\"dateOfBirth\":\"1990-05-15\",\"agreedToPolicy\":true}" "Register User" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
|
||||||
|
# Additional test users (16 more)
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Emma\",\"lastName\":\"Johnson\",\"email\":\"emma.johnson@example.com\",\"password\":\"password\",\"phone\":\"+447000000002\",\"dateOfBirth\":\"1988-03-22\",\"agreedToPolicy\":true}" "Register Emma Johnson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Oliver\",\"lastName\":\"Smith\",\"email\":\"oliver.smith@example.com\",\"password\":\"password\",\"phone\":\"+447000000003\",\"dateOfBirth\":\"1992-07-14\",\"agreedToPolicy\":true}" "Register Oliver Smith" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Sophie\",\"lastName\":\"Williams\",\"email\":\"sophie.williams@example.com\",\"password\":\"password\",\"phone\":\"+447000000004\",\"dateOfBirth\":\"1995-11-08\",\"agreedToPolicy\":true}" "Register Sophie Williams" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Harry\",\"lastName\":\"Brown\",\"email\":\"harry.brown@example.com\",\"password\":\"password\",\"phone\":\"+447000000005\",\"dateOfBirth\":\"1987-02-19\",\"agreedToPolicy\":true}" "Register Harry Brown" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Amelia\",\"lastName\":\"Jones\",\"email\":\"amelia.jones@example.com\",\"password\":\"password\",\"phone\":\"+447000000006\",\"dateOfBirth\":\"1993-09-30\",\"agreedToPolicy\":true}" "Register Amelia Jones" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Jack\",\"lastName\":\"Taylor\",\"email\":\"jack.taylor@example.com\",\"password\":\"password\",\"phone\":\"+447000000007\",\"dateOfBirth\":\"1991-05-12\",\"agreedToPolicy\":true}" "Register Jack Taylor" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Isla\",\"lastName\":\"Davies\",\"email\":\"isla.davies@example.com\",\"password\":\"password\",\"phone\":\"+447000000008\",\"dateOfBirth\":\"1989-12-25\",\"agreedToPolicy\":true}" "Register Isla Davies" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Thomas\",\"lastName\":\"Evans\",\"email\":\"thomas.evans@example.com\",\"password\":\"password\",\"phone\":\"+447000000009\",\"dateOfBirth\":\"1994-04-17\",\"agreedToPolicy\":true}" "Register Thomas Evans" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Lily\",\"lastName\":\"Wilson\",\"email\":\"lily.wilson@example.com\",\"password\":\"password\",\"phone\":\"+447000000010\",\"dateOfBirth\":\"1996-08-03\",\"agreedToPolicy\":true}" "Register Lily Wilson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"George\",\"lastName\":\"Roberts\",\"email\":\"george.roberts@example.com\",\"password\":\"password\",\"phone\":\"+447000000011\",\"dateOfBirth\":\"1986-01-29\",\"agreedToPolicy\":true}" "Register George Roberts" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Poppy\",\"lastName\":\"Thompson\",\"email\":\"poppy.thompson@example.com\",\"password\":\"password\",\"phone\":\"+447000000012\",\"dateOfBirth\":\"1997-06-21\",\"agreedToPolicy\":true}" "Register Poppy Thompson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Charlie\",\"lastName\":\"Wright\",\"email\":\"charlie.wright@example.com\",\"password\":\"password\",\"phone\":\"+447000000013\",\"dateOfBirth\":\"1990-10-11\",\"agreedToPolicy\":true}" "Register Charlie Wright" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Ava\",\"lastName\":\"Walker\",\"email\":\"ava.walker@example.com\",\"password\":\"password\",\"phone\":\"+447000000014\",\"dateOfBirth\":\"1993-03-07\",\"agreedToPolicy\":true}" "Register Ava Walker" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Noah\",\"lastName\":\"Robinson\",\"email\":\"noah.robinson@example.com\",\"password\":\"password\",\"phone\":\"+447000000015\",\"dateOfBirth\":\"1988-11-16\",\"agreedToPolicy\":true}" "Register Noah Robinson" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Mia\",\"lastName\":\"White\",\"email\":\"mia.white@example.com\",\"password\":\"password\",\"phone\":\"+447000000016\",\"dateOfBirth\":\"1995-07-28\",\"agreedToPolicy\":true}" "Register Mia White" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/register" "{\"firstName\":\"Oscar\",\"lastName\":\"Hughes\",\"email\":\"oscar.hughes@example.com\",\"password\":\"password\",\"phone\":\"+447000000017\",\"dateOfBirth\":\"1991-02-04\",\"agreedToPolicy\":true}" "Register Oscar Hughes" "" > /dev/null; then success=$((success+1)); fi
|
||||||
|
|
||||||
|
# Promote Admin
|
||||||
|
docker exec postgres psql -U myuser -d mydb -c "UPDATE users SET account_role = 'admin' WHERE email = '$ADMIN_EMAIL'" > /dev/null 2>&1
|
||||||
|
echo "${C_GREEN}✅ Registered $success/$total Users${C_RESET}"
|
||||||
|
|
||||||
|
# 2. Login
|
||||||
|
echo -e "\n${C_BLUE}🔑 Authenticating...${C_RESET}"
|
||||||
|
LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" "$BASE_URL/login")
|
||||||
|
# FIX 2: Clean token extraction
|
||||||
|
ADMIN_TOKEN=$(echo "$LOGIN_RESP" \
|
||||||
|
| tr -d '\r\n\t ' \
|
||||||
|
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
if [ -z "$ADMIN_TOKEN" ]; then
|
||||||
|
echo "❌ Admin auth failed. Response: $LOGIN_RESP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
USER_LOGIN_RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASS\"}" "$BASE_URL/login")
|
||||||
|
USER_TOKEN=$(echo "$USER_LOGIN_RESP" \
|
||||||
|
| tr -d '\r\n\t ' \
|
||||||
|
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
|
||||||
|
if [ -z "$USER_TOKEN" ]; then
|
||||||
|
echo "❌ User auth failed. Response: $USER_LOGIN_RESP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "${C_GREEN}✅ Authentication successful${C_RESET}"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# 3. Create Services
|
||||||
|
echo -e "\n${C_BLUE}💅 Creating Services...${C_RESET}"
|
||||||
|
SERVICES=(
|
||||||
|
'{"name":"Classic Manicure","description":"Nail shaping, cuticle care, hand massage, and polish.","price":25.00,"duration_minutes":45,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Gel Manicure (BIAB)","description":"Hard-wearing gel polish with Builder In A Bottle base.","price":35.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Luxury Pedicure","description":"Foot soak, scrub, mask, extended massage, and polish.","price":45.00,"duration_minutes":75,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Express Mani & Pedi","description":"Quick file, shape, and polish for both hands and feet.","price":40.00,"duration_minutes":60,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Gel Polish Removal","description":"Safe removal of existing gel polish.","price":10.00,"duration_minutes":20,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
'{"name":"Nail Art Add-on","description":"Custom nail art, per two fingers.","price":5.00,"duration_minutes":15,"patch_test_duration_hours":0,"minimum_age_required":0}'
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICE_IDS=()
|
||||||
|
success=0
|
||||||
|
total=${#SERVICES[@]}
|
||||||
|
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
NAME=$(echo "$svc" | grep -o '"name":"[^"]*' | cut -d'"' -f4)
|
||||||
|
ID=$(api_post "$BASE_URL/admin/services" "$svc" "Create $NAME" "$ADMIN_TOKEN")
|
||||||
|
if [[ -n "$ID" ]]; then
|
||||||
|
ID=$(echo "$ID" | tr -cd '[:alnum:]-')
|
||||||
|
SERVICE_IDS+=("$ID")
|
||||||
|
success=$((success+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "${C_GREEN}✅ Created $success/$total Services${C_RESET}"
|
||||||
|
|
||||||
|
# 4. Create Bookings
|
||||||
|
echo -e "\n${C_BLUE}📅 Creating Bookings...${C_RESET}"
|
||||||
|
|
||||||
|
format_london_time() {
|
||||||
|
TZ=Europe/London date -d "$1 $2" +"%Y-%m-%dT%H:%M:%S%:z"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_booking() {
|
||||||
|
local token=$1 time=$2 services=$3 notes=$4 name=$5
|
||||||
|
local json="{\"start_time\":\"$time\",\"service_ids\":$services"
|
||||||
|
[[ -n "$notes" ]] && json="$json,\"notes\":\"$notes\""
|
||||||
|
json="$json}"
|
||||||
|
|
||||||
|
# Call API and capture ID
|
||||||
|
local id=$(api_post "$BASE_URL/bookings" "$json" "Book: $name" "$token")
|
||||||
|
|
||||||
|
if [[ -n "$id" ]]; then
|
||||||
|
id=$(echo "$id" | tr -cd '[:alnum:]-')
|
||||||
|
LAST_BOOKING_ID="$id"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
LAST_BOOKING_ID=""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_svc() { echo "${SERVICE_IDS[$1]}"; }
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
count_past=0
|
||||||
|
count_today=0
|
||||||
|
count_tomorrow=0
|
||||||
|
count_future=0
|
||||||
|
|
||||||
|
# Array to hold IDs of upcoming bookings for confirmation step
|
||||||
|
UPCOMING_BOOKING_IDS=()
|
||||||
|
UPCOMING_BOOKING_NAMES=()
|
||||||
|
|
||||||
|
# --- PAST BOOKINGS (8 Total) ---
|
||||||
|
echo -e "\n${C_YELLOW}📅 Creating 8 Past Bookings (Last 8 days)...${C_RESET}"
|
||||||
|
for day_offset in {1..8}; do
|
||||||
|
PAST_DATE=$(TZ=Europe/London date -d "today -$day_offset days" +%Y-%m-%d)
|
||||||
|
|
||||||
|
# Alternate times
|
||||||
|
if [ $((day_offset % 2)) -eq 0 ]; then
|
||||||
|
TIME="14:00:00"
|
||||||
|
else
|
||||||
|
TIME="10:00:00"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Alternate services
|
||||||
|
SVC_IDX=$(( (day_offset % 2) ))
|
||||||
|
NAME="Past ($PAST_DATE) - $(echo "${SERVICES[$SVC_IDX]}" | grep -o '"name":"[^"]*' | cut -d'"' -f4)"
|
||||||
|
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$PAST_DATE" "$TIME")" "[\"$(get_svc $SVC_IDX)\"]" "" "$NAME"; then
|
||||||
|
count_past=$((count_past+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "${C_GREEN}✅ Created $count_past/8 Past Bookings${C_RESET}"
|
||||||
|
|
||||||
|
# --- TODAY (3) ---
|
||||||
|
TODAY=$(TZ=Europe/London date +%Y-%m-%d)
|
||||||
|
TOMORROW=$(TZ=Europe/London date -d "tomorrow" +%Y-%m-%d)
|
||||||
|
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "09:30:00")" "[\"$(get_svc 0)\"]" "" "Today - Classic Manicure"; then
|
||||||
|
count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Classic Manicure");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "11:30:00")" "[\"$(get_svc 1)\"]" "" "Today - Gel Manicure"; then
|
||||||
|
count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Gel Manicure");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TODAY" "14:00:00")" "[\"$(get_svc 2)\"]" "" "Today - Luxury Pedicure"; then
|
||||||
|
count_today=$((count_today+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Today - Luxury Pedicure");
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- TOMORROW (4) ---
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "09:00:00")" "[\"$(get_svc 3)\"]" "" "Tomorrow - Express Mani & Pedi"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Express Mani & Pedi");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "10:30:00")" "[\"$(get_svc 0)\",\"$(get_svc 5)\"]" "" "Tomorrow - Classic + Nail Art"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Classic + Nail Art");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "13:00:00")" "[\"$(get_svc 1)\"]" "" "Tomorrow - Gel Manicure"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Gel Manicure");
|
||||||
|
fi
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW" "15:30:00")" "[\"$(get_svc 2)\"]" "" "Tomorrow - Luxury Pedicure"; then
|
||||||
|
count_tomorrow=$((count_tomorrow+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Tomorrow - Luxury Pedicure");
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- FUTURE (30) ---
|
||||||
|
# Spread over the next 15 days (Day +2 to Day +16)
|
||||||
|
for day_offset in {2..16}; do
|
||||||
|
FUTURE_DATE=$(TZ=Europe/London date -d "$TODAY +$day_offset days" +%Y-%m-%d)
|
||||||
|
|
||||||
|
# Morning Slot (10:00)
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "10:00:00")" "[\"$(get_svc 0)\"]" "" "Future ($FUTURE_DATE) - Classic Manicure"; then
|
||||||
|
count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) AM");
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Afternoon Slot (14:30)
|
||||||
|
if create_booking "$USER_TOKEN" "$(format_london_time "$FUTURE_DATE" "14:30:00")" "[\"$(get_svc 1)\"]" "" "Future ($FUTURE_DATE) - Gel Manicure"; then
|
||||||
|
count_future=$((count_future+1)); UPCOMING_BOOKING_IDS+=("$LAST_BOOKING_ID"); UPCOMING_BOOKING_NAMES+=("Future ($FUTURE_DATE) PM");
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
TOTAL=$((count_today + count_tomorrow + count_future + count_past))
|
||||||
|
echo "${C_GREEN}✅ Created $count_today/3 Bookings (Today)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $count_tomorrow/4 Bookings (Tomorrow)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $count_future/30 Bookings (Future)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $count_past/8 Bookings (Past)${C_RESET}"
|
||||||
|
echo "${C_GREEN}✅ Created $TOTAL/45 Bookings (Total)${C_RESET}"
|
||||||
|
|
||||||
|
# 5. Confirm Random Half of Upcoming Bookings
|
||||||
|
echo -e "\n${C_BLUE}🔒 Confirming Random Upcoming Bookings...${C_RESET}"
|
||||||
|
confirmed_count=0
|
||||||
|
total_upcoming=${#UPCOMING_BOOKING_IDS[@]}
|
||||||
|
|
||||||
|
# Small pause to ensure backend is ready after bulk creation
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
for ((i=0; i<${#UPCOMING_BOOKING_IDS[@]}; i++)); do
|
||||||
|
# FIX 3: Sanitize ID again just before use to ensure no hidden characters broke the array
|
||||||
|
id="${UPCOMING_BOOKING_IDS[$i]}"
|
||||||
|
name="${UPCOMING_BOOKING_NAMES[$i]}"
|
||||||
|
|
||||||
|
# Ensure ID is not empty
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Random coin flip (0 or 1). If 1, confirm.
|
||||||
|
if [ $((RANDOM % 2)) -eq 1 ]; then
|
||||||
|
# Send proper JSON with empty serviceOverrides array
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-d '{"serviceOverrides":[]}' \
|
||||||
|
"$BASE_URL/admin/bookings/$id/confirm")
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo " ✅ Confirmed: $name"
|
||||||
|
confirmed_count=$((confirmed_count+1))
|
||||||
|
else
|
||||||
|
echo " ⚠️ Failed to confirm: $name (HTTP $HTTP_CODE)"
|
||||||
|
echo " Response: $BODY"
|
||||||
|
fi
|
||||||
|
# Small sleep to prevent overwhelming the server
|
||||||
|
sleep 0.1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "${C_GREEN}✅ Confirmed $confirmed_count upcoming bookings${C_RESET}"
|
||||||
|
|
||||||
|
# 6. Exceptional Groups (2 Total)
|
||||||
|
echo -e "\n${C_BLUE}🗓️ Creating Exceptional Groups...${C_RESET}"
|
||||||
|
NOV_BREAK='{"name":"November Break","description":"Short break period in November","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":2,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-11-10"]}'
|
||||||
|
XMAS_BREAK='{"name":"Christmas Holiday Period","description":"Reduced hours for Christmas and New Year","hours":[{"weekday":0,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":1,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":2,"startTime":"10:00:00","endTime":"15:00:00","isOpen":true},{"weekday":3,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":4,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":5,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false},{"weekday":6,"startTime":"00:00:00","endTime":"00:00:00","isOpen":false}],"weekStarts":["2025-12-22","2025-12-29"]}'
|
||||||
|
|
||||||
|
success=0
|
||||||
|
total=2
|
||||||
|
if api_post "$BASE_URL/scheduling/exceptional-groups" "$NOV_BREAK" "November Break" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
|
||||||
|
if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas Holiday" "$ADMIN_TOKEN" > /dev/null; then success=$((success+1)); fi
|
||||||
|
echo "${C_GREEN}✅ Created $success/$total Exceptional Groups${C_RESET}"
|
||||||
|
|
||||||
|
# --- UPDATE DB PANE STATS ---
|
||||||
|
if [ -n "$SESSION_NAME" ]; then
|
||||||
|
echo -e "\n⏳ Updating DB pane stats..."
|
||||||
|
tmux send-keys -t "$SESSION_NAME:0.0" 'SELECT relname AS table_name, n_live_tup AS row_count FROM pg_stat_user_tables ORDER BY table_name\;'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${C_GREEN}🎉 Seeding Complete!${C_RESET}"
|
||||||
|
read -n1 -s -p "Press any key to close this window..."
|
||||||
|
SEED_EOF
|
||||||
|
|
||||||
|
chmod +x $SEED_SCRIPT
|
||||||
|
|
||||||
|
# --- 6. Execute Seed Script in Tmux ---
|
||||||
|
log_step "Starting seeding process in new window..."
|
||||||
|
# Pass SESSION_NAME to the seed script
|
||||||
|
tmux new-window -t $SESSION_NAME -n "Seeding" "SESSION_NAME=$SESSION_NAME $SEED_SCRIPT"
|
||||||
|
|
||||||
|
# --- 7. Finalize ---
|
||||||
|
trap 'rm -f $SEED_SCRIPT' EXIT
|
||||||
|
log_success "Environment ready. Attaching to session..."
|
||||||
|
tmux attach-session -t $SESSION_NAME
|
||||||
Vendored
+10
-11
@@ -4,21 +4,21 @@
|
|||||||
"type": "split",
|
"type": "split",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "f918f140cb277962",
|
"id": "4f4ac1241ce3420f",
|
||||||
"type": "tabs",
|
"type": "tabs",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "7fe99991d0e457c6",
|
"id": "0c84336298f72379",
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"state": {
|
"state": {
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "Express.js Cheat Sheet.md",
|
"file": "Crussell/Crussell Nails.md",
|
||||||
"mode": "preview",
|
"mode": "source",
|
||||||
"source": false
|
"source": false
|
||||||
},
|
},
|
||||||
"icon": "lucide-file",
|
"icon": "lucide-file",
|
||||||
"title": "Express.js Cheat Sheet"
|
"title": "Crussell Nails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -78,8 +78,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direction": "horizontal",
|
"direction": "horizontal",
|
||||||
"width": 300,
|
"width": 300
|
||||||
"collapsed": true
|
|
||||||
},
|
},
|
||||||
"right": {
|
"right": {
|
||||||
"id": "2750d7726f904ef3",
|
"id": "2750d7726f904ef3",
|
||||||
@@ -170,12 +169,12 @@
|
|||||||
"bases:Create new base": false
|
"bases:Create new base": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active": "7fe99991d0e457c6",
|
"active": "0c84336298f72379",
|
||||||
"lastOpenFiles": [
|
"lastOpenFiles": [
|
||||||
|
"Crussell/Backend/bookings.md",
|
||||||
|
"Crussell/Crussell Nails.md",
|
||||||
"Untitled.base",
|
"Untitled.base",
|
||||||
"Untitled.canvas",
|
"Untitled.canvas",
|
||||||
"Express.js Cheat Sheet.md",
|
"Express.js Cheat Sheet.md"
|
||||||
"Crussell/Crussell Nails.md",
|
|
||||||
"Crussell/Backend/bookings.md"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
# Booking API - cURL Examples (Revised)
|
|
||||||
|
|
||||||
## User Endpoints (Requires Authentication)
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 1\. Create Booking
|
|
||||||
|
|
||||||
Creates a new booking with the specified services and preferred time.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/bookings \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"service_ids": ["service-uuid-1", "service-uuid-2"],
|
|
||||||
"notes": "Please use the side entrance"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (201 Created):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "pending",
|
|
||||||
"notes": "Please use the side entrance",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-21T10:30:00Z",
|
|
||||||
"created_by": "user-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 2\. Get User Booking by ID
|
|
||||||
|
|
||||||
Retrieves a specific booking for the authenticated user.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET http://localhost:8080/api/bookings/booking-uuid \
|
|
||||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
*The response now consistently includes all joined data: `services` and `payments`.*
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "pending",
|
|
||||||
"notes": "Please use the side entrance",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-21T10:30:00Z",
|
|
||||||
"created_by": "user-uuid",
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"booking_id": "booking-uuid",
|
|
||||||
"service_id": "service-uuid-1",
|
|
||||||
"override_price": null,
|
|
||||||
"override_duration_minutes": null,
|
|
||||||
"service_name": "Haircut",
|
|
||||||
"base_price": 25.00
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"payments": [
|
|
||||||
{
|
|
||||||
"id": "payment-uuid",
|
|
||||||
"booking_id": "booking-uuid",
|
|
||||||
"payment_type": "deposit",
|
|
||||||
"payment_method": "card",
|
|
||||||
"status": "completed",
|
|
||||||
"amount": 10.00,
|
|
||||||
"is_vat_applicable": true,
|
|
||||||
"vat_rate": 20.0,
|
|
||||||
"vat_amount": 2.00,
|
|
||||||
"net_amount": 8.00
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 3\. Get All User Bookings
|
|
||||||
|
|
||||||
Retrieves a list of all bookings associated with the authenticated user.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET http://localhost:8080/api/bookings \
|
|
||||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "booking-uuid-1",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "pending",
|
|
||||||
"updated_at": "2025-10-21T10:30:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "booking-uuid-2",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-26T10:00:00Z",
|
|
||||||
"status": "confirmed",
|
|
||||||
"updated_at": "2025-10-22T09:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 4\. Edit Booking (Change Start Time and/or Notes)
|
|
||||||
|
|
||||||
Updates the `start_time` and/or `notes` for a booking.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PUT http://localhost:8080/api/bookings/booking-uuid \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"start_time": "2025-10-25T15:00:00Z",
|
|
||||||
"notes": "Please use the front entrance this time"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T15:00:00Z",
|
|
||||||
"status": "pending",
|
|
||||||
"notes": "Please use the front entrance this time",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-21T10:40:00Z",
|
|
||||||
"created_by": "user-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 5\. Delete/Cancel Booking
|
|
||||||
|
|
||||||
This endpoint performs a **hard delete** if no payments exist, or a **soft delete (cancel)** if payments exist, requiring a `reason`.
|
|
||||||
|
|
||||||
#### A. Hard Delete (No Payments)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Hard delete when no payments exist
|
|
||||||
curl -X DELETE http://localhost:8080/api/bookings/booking-uuid \
|
|
||||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Booking deleted successfully",
|
|
||||||
"id": "booking-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B. Cancel Booking (With Payments)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Soft delete/cancel when payments exist - requires reason
|
|
||||||
curl -X DELETE http://localhost:8080/api/bookings/booking-uuid \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"reason": "client_cancelled"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Valid reasons:** `client_cancelled`, `we_cancelled`, `re-schedule`, `no_show`
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Booking cancelled successfully",
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"status": "client_cancelled"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Admin Endpoints (Requires Authentication + Admin Role)
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 6\. Get All Admin Bookings
|
|
||||||
|
|
||||||
Retrieves a paginated list of all bookings in the system.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET 'http://localhost:8080/api/admin/bookings?limit=50&offset=0' \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "booking-uuid-a",
|
|
||||||
"user_id": "user-uuid-1",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "confirmed",
|
|
||||||
"updated_at": "2025-10-21T10:45:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "booking-uuid-b",
|
|
||||||
"user_id": "user-uuid-2",
|
|
||||||
"start_time": "2025-10-26T10:00:00Z",
|
|
||||||
"status": "pending",
|
|
||||||
"updated_at": "2025-10-22T09:00:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 7\. Search Admin Bookings
|
|
||||||
|
|
||||||
Retrieves a filtered list of bookings based on query parameters.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search for all 'pending' bookings for a specific user ID
|
|
||||||
curl -X GET 'http://localhost:8080/api/admin/bookings/search?status=pending&user_id=user-uuid-1&start_date=2025-10-01' \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
*Same list format as 'Get All Admin Bookings'*
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 8\. Get Admin Bookings by User ID
|
|
||||||
|
|
||||||
Retrieves all bookings for a single, specified user.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET http://localhost:8080/api/admin/bookings/user/user-uuid \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
*Same list format as 'Get All Admin Bookings'*
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 9\. Get Admin Booking by ID
|
|
||||||
|
|
||||||
Retrieves a specific booking including all service and payment details.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET http://localhost:8080/api/admin/bookings/booking-uuid \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
*Full booking object, including `services` and `payments` arrays.*
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "confirmed",
|
|
||||||
"notes": "Please use the side entrance",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-21T10:45:00Z",
|
|
||||||
"created_by": "admin-uuid",
|
|
||||||
"services": [ ... ],
|
|
||||||
"payments": [ ... ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 10\. Get Admin Booking Summary
|
|
||||||
|
|
||||||
Retrieves the booking, user details, services, payments, and financial totals in a single, comprehensive response.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET http://localhost:8080/api/admin/bookings/booking-uuid/summary \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"booking": {
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "confirmed",
|
|
||||||
"notes": "Please use the side entrance",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-21T10:45:00Z",
|
|
||||||
"created_by": "admin-uuid"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"first_name": "John",
|
|
||||||
"last_name": "Doe",
|
|
||||||
"email": "john.doe@example.com",
|
|
||||||
"account_role": "client",
|
|
||||||
"loyalty_stamps": 5
|
|
||||||
// ... other user fields
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"service_name": "Haircut",
|
|
||||||
"base_price": 25.00,
|
|
||||||
"override_price": 20.00
|
|
||||||
// ... other service fields
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"payments": [
|
|
||||||
{
|
|
||||||
"id": "payment-uuid",
|
|
||||||
"payment_type": "deposit",
|
|
||||||
"status": "completed",
|
|
||||||
"amount": 10.00,
|
|
||||||
"vat_rate": 20.0
|
|
||||||
// ... other payment fields
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_amount": 20.00,
|
|
||||||
"amount_paid": 10.00,
|
|
||||||
"amount_due": 10.00,
|
|
||||||
"duration_minutes": 25
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 11\. Progress Booking Status
|
|
||||||
|
|
||||||
Moves the booking to the next status in its lifecycle (e.g., from `confirmed` to `in_progress`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PUT http://localhost:8080/api/admin/bookings/booking-uuid/progress \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"status": "in_progress"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Valid statuses:** `pending`, `confirmed`, `in_progress`, `completed`, `client_cancelled`, `we_cancelled`, `re-schedule`, `no_show`
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "in_progress",
|
|
||||||
"notes": "Please use the side entrance",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-25T14:05:00Z",
|
|
||||||
"created_by": "admin-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 12\. Confirm Booking (With/Without Overrides)
|
|
||||||
|
|
||||||
Confirms a `pending` booking, optionally applying price/duration overrides and updating notes.
|
|
||||||
|
|
||||||
#### A. Confirm with Overrides
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/admin/bookings/booking-uuid/confirm \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"service_overrides": [
|
|
||||||
{
|
|
||||||
"service_id": "service-uuid-1",
|
|
||||||
"override_price": 20.00,
|
|
||||||
"override_duration_minutes": 25
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"notes": "Confirmed by phone. Applied special discount."
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B. Confirm with Notes Update Only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/admin/bookings/booking-uuid/confirm \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"notes": "Confirmed via email"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "booking-uuid",
|
|
||||||
"user_id": "user-uuid",
|
|
||||||
"start_time": "2025-10-25T14:00:00Z",
|
|
||||||
"status": "confirmed",
|
|
||||||
"notes": "Confirmed by phone. Applied special discount.",
|
|
||||||
"created_at": "2025-10-21T10:30:00Z",
|
|
||||||
"updated_at": "2025-10-21T10:50:00Z",
|
|
||||||
"created_by": "admin-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 13\. Add Payment to Booking (New Endpoint)
|
|
||||||
|
|
||||||
Adds a new payment record to the specified booking.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/admin/bookings/booking-uuid/payments \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"payment_type": "final",
|
|
||||||
"payment_method": "cash",
|
|
||||||
"status": "completed",
|
|
||||||
"amount": 15.00,
|
|
||||||
"is_vat_applicable": false,
|
|
||||||
"invoice_number": 1042
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Valid Payment Types:** `deposit`, `final`
|
|
||||||
**Valid Payment Methods:** `card`, `cash`, `bank_transfer`
|
|
||||||
**Valid Payment Statuses:** `pending`, `completed`, `failed`
|
|
||||||
|
|
||||||
**Response (201 Created):**
|
|
||||||
*Returns the newly created payment object.*
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "new-payment-uuid",
|
|
||||||
"booking_id": "booking-uuid",
|
|
||||||
"payment_type": "final",
|
|
||||||
"payment_method": "cash",
|
|
||||||
"status": "completed",
|
|
||||||
"amount": 15.00,
|
|
||||||
"is_vat_applicable": false,
|
|
||||||
"vat_rate": null,
|
|
||||||
"vat_amount": null,
|
|
||||||
"net_amount": 15.00,
|
|
||||||
"invoice_number": 1042,
|
|
||||||
"created_at": "2025-10-22T12:00:00Z",
|
|
||||||
"updated_at": "2025-10-22T12:00:00Z",
|
|
||||||
"created_by": "admin-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
### 14\. Remove Payment from Booking (New Endpoint)
|
|
||||||
|
|
||||||
Deletes a specific payment record associated with a booking.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:8080/api/admin/bookings/booking-uuid/payments/payment-uuid-to-delete \
|
|
||||||
-H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Payment removed successfully",
|
|
||||||
"id": "payment-uuid-to-delete"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Status Code | Example Response | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **400 Bad Request** | `{"error": "Start time is required"}` | Missing or invalid required fields. |
|
|
||||||
| **401 Unauthorized** | `{"error": "Authentication required"}` | Missing or expired JWT token. |
|
|
||||||
| **403 Forbidden** | `{"error": "Admin access required"}` | Attempting to access an admin endpoint without the necessary role. |
|
|
||||||
| **404 Not Found** | `{"error": "Booking not found or access denied"}` | Resource does not exist or user/admin does not have permission to view it. |
|
|
||||||
| **500 Internal Server Error** | `{"error": "Internal server error"}` | Unhandled server error. |
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
1. Replace `YOUR_JWT_TOKEN` with a standard user's JWT token.
|
|
||||||
2. Replace `YOUR_ADMIN_JWT_TOKEN` with an admin user's JWT token.
|
|
||||||
3. Replace UUIDs (`booking-uuid`, `service-uuid-1`, etc.) with actual IDs from your database.
|
|
||||||
4. All timestamps should be in **RFC3339 format (ISO 8601)**, e.g., `"2025-10-25T14:00:00Z"`.
|
|
||||||
5. Start times must be in the future when creating or editing bookings.
|
|
||||||
6. Bookings can only be **hard-deleted** (Endpoint 5A) if they have no associated payments. Otherwise, a `reason` must be supplied to cancel the booking (Endpoint 5B).
|
|
||||||
7. The **Confirm Booking** endpoint (12) only works on bookings with status `pending`.
|
|
||||||
+487
-234
@@ -1,299 +1,552 @@
|
|||||||
## ✅ / ⏳ Project Checklist
|
> **Last Updated:** January 2025
|
||||||
|
> **Status:** Work in Progress
|
||||||
### Backend (Go / Chi / Postgres)
|
|
||||||
- [x] JWT authentication (login, refresh, role verification)
|
|
||||||
- [x] User registration with input validation
|
|
||||||
- [x] Password hashing with bcrypt
|
|
||||||
- [x] Middleware for auth/roles
|
|
||||||
- [x] DB connection pooling
|
|
||||||
- [ ] Booking endpoints
|
|
||||||
- ☐ `/api/bookings` (CRUD for users)
|
|
||||||
- ☐ `/api/admin/bookings` (list, search, user‑by‑user, progress, confirm)
|
|
||||||
- [ ] Admin endpoints
|
|
||||||
- ☐ `/api/admin/services` (create, delete, list, toggle)
|
|
||||||
- ☐ `/api/admin/bookings` (list, search, user bookings, progress, confirm)
|
|
||||||
- ☐ `/api/admin/users` (list, view) – *handlers still to be implemented*
|
|
||||||
- [ ] Unit tests
|
|
||||||
- [ ] CI/CD
|
|
||||||
|
|
||||||
### Frontend (SvelteKit / Tailwind / shadcn)
|
|
||||||
- [x] Core pages (Home, Prices, Contact, Book)
|
|
||||||
- [x] Booking wizard prototype
|
|
||||||
- [x] Calendar/time slot selector
|
|
||||||
- [ ] API integration for booking flow
|
|
||||||
*The wizard currently only shows a prototype alert. Wire it to the backend `/api/bookings` POST endpoint in the next iteration.*
|
|
||||||
- [ ] Payments (Stripe/Square) – *pending integration*
|
|
||||||
- [ ] Auth screens
|
|
||||||
- [ ] Admin dashboard
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
- [x] `.env` config
|
|
||||||
- [x] Docker Compose full stack
|
|
||||||
- [ ] nginx reverse proxy – *still under review*
|
|
||||||
- [ ] Monitoring/logging – *no stack configured*
|
|
||||||
- [ ] CI/CD pipeline – *not yet defined*
|
|
||||||
|
|
||||||
### Integrations
|
|
||||||
- [x] CardDAV sync for contacts
|
|
||||||
- [ ] Email/SMS reminders – *not yet implemented*
|
|
||||||
- [ ] Payment provider – *placeholder only*
|
|
||||||
- [ ] Loyalty tracking frontend – *no component yet*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📐 Current Architecture
|
## Project Checklist
|
||||||
|
|
||||||
|
### Backend (Go / Chi / Postgres)
|
||||||
|
|
||||||
|
#### Authentication & Authorization
|
||||||
|
- [x] JWT authentication (login, refresh, role verification)
|
||||||
|
- [x] User registration with input validation
|
||||||
|
- Names: 1-50 chars, unicode letters/spaces/hyphen/apostrophe/dot
|
||||||
|
- Phone: UK format → E.164 (+44...)
|
||||||
|
- Email: standard format
|
||||||
|
- Age: Must be 16+ years
|
||||||
|
- [x] Password hashing with bcrypt
|
||||||
|
- [x] Middleware for auth/roles (`mw.RequireAuth`, `mw.RequireAdmin`)
|
||||||
|
- [x] DB connection pooling
|
||||||
|
- [ ] **Refresh token endpoint** - `RefreshTokenHandler` exists at `local.go:322`, NOT wired in router
|
||||||
|
- [x] Login rate limiting (1 attempt per 5 seconds)
|
||||||
|
|
||||||
|
#### Booking System
|
||||||
|
- [x] `/api/bookings` - Full CRUD for authenticated users
|
||||||
|
- [x] `/api/admin/bookings` - List, search, create for user, progress, confirm, cancel
|
||||||
|
- [x] `/api/admin/bookings/search` - Search functionality
|
||||||
|
- [x] `/api/admin/bookings/user/{user_id}` - User-specific bookings
|
||||||
|
- [x] `/api/admin/bookings/{id}/progress` - Progress booking status
|
||||||
|
- [x] `/api/admin/bookings/{id}/confirm` - Confirm booking
|
||||||
|
- [x] `/api/admin/bookings/{id}/cancel` - Cancel booking
|
||||||
|
- [ ] **In-progress auto-infer** - Status should auto-set based on time
|
||||||
|
- [ ] **Begin button on Today** - Manual start for early arrivals (gray out if >3hrs away)
|
||||||
|
|
||||||
|
#### Admin Endpoints
|
||||||
|
- [x] `/api/admin/services` - Create, delete, list, toggle
|
||||||
|
- [x] `/api/admin/users` - List, view with booking history
|
||||||
|
- [x] `/api/admin/today` - Current/next appointment, today's appointments, pending approvals
|
||||||
|
- [x] `/api/admin/notifications` - GET/acknowledge endpoint wired, but:
|
||||||
|
- [ ] Frontend UI to display notifications
|
||||||
|
- [ ] Push mechanism (currently only pull-based)
|
||||||
|
- [ ] User notifications (only admin notifications exist)
|
||||||
|
|
||||||
|
#### Scheduling System
|
||||||
|
- [x] `/api/scheduling/default-hours` - GET public, PUT admin
|
||||||
|
- [x] `/api/scheduling/exceptional-groups` - CRUD for holiday/special hours
|
||||||
|
- [x] `/api/scheduling/working-hours` - Merged default + exceptional hours
|
||||||
|
- [x] `/api/scheduling/available-hours` - Available slots accounting for bookings
|
||||||
|
|
||||||
|
#### User Endpoints
|
||||||
|
- [x] `/api/user/profile` - GET, PUT
|
||||||
|
- [x] `/api/user/account` - DELETE (GDPR compliant)
|
||||||
|
- [x] `/api/user/loyalty` - GET loyalty stamps
|
||||||
|
- [ ] **GDPR data export** - `export_all_user_data()` exists but not wired to endpoint
|
||||||
|
- [ ] **Tax data export** - Admin endpoint for tax-software-compatible format
|
||||||
|
|
||||||
|
#### Not Yet Wired
|
||||||
|
- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
|
||||||
|
- [ ] Analytics (`handlers/admin/analytics.go` exists, not imported)
|
||||||
|
- [ ] Portfolio/images (`handlers/portfolio/images.go` exists, not imported)
|
||||||
|
- [ ] Guest user endpoint (`/api/users/guest` - needed for walk-in bookings)
|
||||||
|
|
||||||
|
#### Unit Tests & CI/CD
|
||||||
|
- [ ] Unit tests
|
||||||
|
- [ ] CI/CD pipeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend (SvelteKit / Tailwind / shadcn)
|
||||||
|
|
||||||
|
#### Core Pages
|
||||||
|
- [x] Home (`/`)
|
||||||
|
- [x] Prices (`/prices`)
|
||||||
|
- [x] Contact (`/contact`)
|
||||||
|
- [x] Book (`/book`) - Full wizard with service selection, date/time, customer details
|
||||||
|
- [ ] Portfolio (`/portfolio`) - Stubbed, needs S3/R2 integration for images
|
||||||
|
- [x] Today (`/today`) - Admin only, real-time schedule view
|
||||||
|
- [x] Account (`/account`)
|
||||||
|
- [x] Login (`/login`)
|
||||||
|
- [x] Manage (`/manage`)
|
||||||
|
|
||||||
|
#### Admin Dashboard (`/admin`)
|
||||||
|
- [x] Auth guard with role check
|
||||||
|
- [x] ImageUpload component
|
||||||
|
- [x] UsersCard + UserModal
|
||||||
|
- [x] BookingsCard + BookingModal
|
||||||
|
- [x] HolidayHours (exceptional hours management)
|
||||||
|
- [x] WeeklySchedule (default hours management)
|
||||||
|
- [x] ServicesManagement
|
||||||
|
- [x] BookingCreateModal (call-in/admin booking creation)
|
||||||
|
- [x] WalkInBooking + WalkInCreateModal
|
||||||
|
- [x] CallInBooking
|
||||||
|
- [x] ApprovalModal
|
||||||
|
|
||||||
|
#### Booking Flow
|
||||||
|
- [x] Service selection with pricing/duration
|
||||||
|
- [x] Calendar with availability detection
|
||||||
|
- [x] Time slot generation with gap logic
|
||||||
|
- [x] Customer details form (guest or authenticated)
|
||||||
|
- [x] Auth store with token refresh logic
|
||||||
|
- [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings`
|
||||||
|
- [ ] Payment integration (Square placeholder)
|
||||||
|
|
||||||
|
#### API Integration
|
||||||
|
- [x] Services fetch from `/api/services`
|
||||||
|
- [x] Working hours fetch from `/api/scheduling/working-hours`
|
||||||
|
- [x] Available hours fetch from `/api/scheduling/available-hours`
|
||||||
|
- [x] Admin bookings use `/api/admin/bookings`
|
||||||
|
- [ ] Guest user creation (`/api/users/guest` not implemented)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- [x] `.env` config
|
||||||
|
- [x] Docker Compose (postgres, backend, sabredav, nginx)
|
||||||
|
- [x] Static frontend build served via nginx
|
||||||
|
- [ ] nginx reverse proxy - config under review
|
||||||
|
- [ ] Monitoring/logging - no stack configured
|
||||||
|
- [ ] CI/CD pipeline (Gitea) - not yet defined
|
||||||
|
- [ ] Prometheus metrics integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
|
- [x] CardDAV sync for contacts (SabreDAV)
|
||||||
|
- [x] CalDAV ready
|
||||||
|
- [ ] Email/SMS reminders - not yet implemented
|
||||||
|
- [ ] Square payment - placeholder only
|
||||||
|
- [ ] S3/R2 image hosting - not configured
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
### Overview Diagram
|
### Overview Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
User([Customer])
|
User([Customer])
|
||||||
Admin([Admin])
|
Admin([Admin])
|
||||||
CardReader[Square Card Reader<br/>Physical Terminal]
|
|
||||||
|
|
||||||
subgraph CF[Cloudflare Protection]
|
subgraph Docker[Docker Compose Stack]
|
||||||
CDN[CDN / DDoS Protection]
|
subgraph NGINX[Nginx :80/:443]
|
||||||
|
Static[Static Frontend Build]
|
||||||
|
Proxy[API Proxy → Backend:8080]
|
||||||
|
DAVProxy[DAV Proxy → SabreDAV]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend[Go + Chi :8080]
|
||||||
|
Router[chi Router]
|
||||||
|
Auth[JWT Middleware]
|
||||||
|
Handlers[API Handlers]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Database[PostgreSQL :5432]
|
||||||
|
DB[(Users / Bookings<br/>Payments / Services<br/>Scheduling)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DAV[SabreDAV :9000]
|
||||||
|
CardDAV[(vCard Contacts)]
|
||||||
|
CalDAV[(Calendar Events)]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Lightsail[AWS Lightsail Instance]
|
subgraph External[External Services - TODO]
|
||||||
subgraph NGINX[Nginx Reverse Proxy]
|
Gmail[Gmail SMTP]
|
||||||
Proxy[Port 443/80]
|
SquareAPI[Square API]
|
||||||
end
|
S3[S3/R2 Storage]
|
||||||
|
|
||||||
subgraph Frontend[SvelteKit Frontend]
|
|
||||||
UI[Booking UI / Prices / Contact]
|
|
||||||
AdminUI[Admin Dashboard]
|
|
||||||
APIProxy[API Proxy Routes]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Backend[Go + Chi Backend]
|
|
||||||
Router[chi Router]
|
|
||||||
Auth[JWT Middleware]
|
|
||||||
Handlers[API Handlers]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Database[PostgreSQL]
|
|
||||||
DB[(Users / Bookings<br/>Transactions)]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph DAV[SabreDAV Server]
|
|
||||||
CardDAV[(vCard Contacts)]
|
|
||||||
CalDAV[(Calendar Events)]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph External[External Services]
|
User -->|HTTPS| NGINX
|
||||||
Gmail[Gmail SMTP<br/>smtp.gmail.com:587]
|
Admin -->|HTTPS| NGINX
|
||||||
SquareAPI[Square API<br/>Online Payments]
|
Static --> User
|
||||||
end
|
Proxy --> Router
|
||||||
|
|
||||||
%% Connections
|
|
||||||
User -->|HTTPS| CF
|
|
||||||
Admin -->|HTTPS| CF
|
|
||||||
CF --> Proxy
|
|
||||||
|
|
||||||
Proxy --> UI
|
|
||||||
Proxy --> AdminUI
|
|
||||||
Proxy --> APIProxy
|
|
||||||
|
|
||||||
UI --> APIProxy
|
|
||||||
AdminUI --> APIProxy
|
|
||||||
APIProxy --> Router
|
|
||||||
|
|
||||||
Router --> Auth
|
Router --> Auth
|
||||||
Auth --> Handlers
|
Auth --> Handlers
|
||||||
|
|
||||||
Handlers --> DB
|
Handlers --> DB
|
||||||
Handlers --> CardDAV
|
Handlers --> DAV
|
||||||
Handlers --> CalDAV
|
Handlers -.->|TODO| Gmail
|
||||||
Handlers -->|Send Emails| Gmail
|
Handlers -.->|TODO| SquareAPI
|
||||||
Handlers -->|Process Payments| SquareAPI
|
Handlers -.->|TODO| S3
|
||||||
|
|
||||||
Admin -.->|Sync Contacts/Calendar| DAV
|
|
||||||
|
|
||||||
CardReader -->|Transaction Data| SquareAPI
|
|
||||||
Handlers -.->|Poll Transactions| SquareAPI
|
|
||||||
|
|
||||||
%% Force External Services to appear below
|
|
||||||
Lightsail --> External
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Backend Implementation
|
## API Reference
|
||||||
|
|
||||||
### JWT Auth
|
### Public Endpoints
|
||||||
JWT uses **HS256** algorithm with 30‑day expiry. Implemented via `go-chi/jwtauth`.
|
|
||||||
|
|
||||||
### Middleware
|
| Method | Path | Description |
|
||||||
Validates JWT and attaches user context. Supports role restrictions.
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/services` | List active services |
|
||||||
|
| POST | `/api/register` | Create new user account |
|
||||||
|
| POST | `/api/login` | Authenticate and receive JWT |
|
||||||
|
| GET | `/api/scheduling/default-hours` | Get weekly default hours |
|
||||||
|
| GET | `/api/scheduling/exceptional-groups` | List holiday/special hour groups |
|
||||||
|
| GET | `/api/scheduling/working-hours` | Get merged working hours for date range |
|
||||||
|
| GET | `/api/scheduling/available-hours` | Get available booking slots |
|
||||||
|
|
||||||
### Registration Flow
|
### Authenticated User Endpoints
|
||||||
The registration process:
|
|
||||||
- Validates names, email, phone, DOB
|
|
||||||
- Rejects users under 16 years old
|
|
||||||
- Hashes password with bcrypt
|
|
||||||
- Saves user profile and creates vCard in CardDAV
|
|
||||||
|
|
||||||
```go
|
| Method | Path | Description |
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|--------|------|-------------|
|
||||||
tx, _ := db.DB.Begin(r.Context())
|
| GET | `/api/user/profile` | Get current user profile |
|
||||||
defer tx.Rollback(r.Context())
|
| PUT | `/api/user/profile` | Update profile |
|
||||||
|
| DELETE | `/api/user/account` | Delete account (GDPR) |
|
||||||
_, err := tx.Exec(r.Context(),
|
| GET | `/api/user/loyalty` | Get loyalty stamp count |
|
||||||
`INSERT INTO users (first_name, last_name, email, phone, dob, password_hash)
|
| GET | `/api/bookings` | List user's bookings |
|
||||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
| POST | `/api/bookings` | Create booking |
|
||||||
req.FirstName, req.LastName, req.Email, req.Phone, dob, string(hash))
|
| GET | `/api/bookings/{id}` | Get specific booking |
|
||||||
```
|
| PUT | `/api/bookings/{id}` | Update booking |
|
||||||
|
| DELETE | `/api/bookings/{id}` | Cancel booking |
|
||||||
|
|
||||||
### CardDAV Integration
|
|
||||||
Each registered user generates a `.vcf` file in SabreDAV, ensuring external calendar and contact apps stay in sync. The code now uses the internal `dav.BaseService.CreateContact` helper.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func CreateCardDAVContact(service *dav.BaseService, addressBookID int, userID, firstName, lastName, email, phone, dob string) error {
|
|
||||||
input := dav.ContactInput{
|
|
||||||
UserID: userID,
|
|
||||||
FirstName: firstName,
|
|
||||||
LastName: lastName,
|
|
||||||
Email: email,
|
|
||||||
Phone: phone,
|
|
||||||
DOB: dob,
|
|
||||||
}
|
|
||||||
return service.CreateContact(addressBookID, userID, input)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The address‑book ID is currently hard‑coded in the database (`dav_cards` table). A migration will expose the ID via a dedicated endpoint in the next sprint.
|
|
||||||
|
|
||||||
### Admin Endpoints
|
### Admin Endpoints
|
||||||
*Handlers for `/api/admin/services`, `/api/admin/bookings`, and `/api/admin/users` are defined in the router, but the concrete implementation files are still empty. These will be fleshed out in the next sprint.*
|
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/admin/services` | List all services |
|
||||||
|
| POST | `/api/admin/services` | Create service |
|
||||||
|
| DELETE | `/api/admin/services/{id}` | Delete service |
|
||||||
|
| PUT | `/api/admin/services/{id}/toggle` | Toggle active status |
|
||||||
|
| GET | `/api/admin/bookings` | List all bookings |
|
||||||
|
| POST | `/api/admin/bookings` | Create booking for user |
|
||||||
|
| GET | `/api/admin/bookings/search` | Search bookings |
|
||||||
|
| GET | `/api/admin/bookings/user/{user_id}` | User's bookings |
|
||||||
|
| PUT | `/api/admin/bookings/{id}/progress` | Progress status |
|
||||||
|
| POST | `/api/admin/bookings/{id}/confirm` | Confirm booking |
|
||||||
|
| POST | `/api/admin/bookings/{id}/cancel` | Cancel booking |
|
||||||
|
| GET | `/api/admin/users` | List users |
|
||||||
|
| GET | `/api/admin/users/{id}` | Get user details |
|
||||||
|
| GET | `/api/admin/today/current-next` | Current and next appointment |
|
||||||
|
| GET | `/api/admin/today/appointments` | Today's appointments |
|
||||||
|
| GET | `/api/admin/today/pending-approvals` | Pending approval queue |
|
||||||
|
| GET | `/api/admin/notifications` | List notifications |
|
||||||
|
| POST | `/api/admin/notifications/{id}/acknowledge` | Acknowledge |
|
||||||
|
| PUT | `/api/scheduling/default-hours` | Update weekly hours |
|
||||||
|
| POST | `/api/scheduling/exceptional-groups` | Create exception group |
|
||||||
|
| DELETE | `/api/scheduling/exceptional-groups` | Delete exception group |
|
||||||
|
| PUT | `/api/scheduling/exceptional-applications` | Apply exceptions to dates |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎨 Frontend Implementation
|
## Database Schema
|
||||||
|
|
||||||
### API Proxy
|
### Enums
|
||||||
Prevents CORS issues by proxying all API calls through SvelteKit.
|
|
||||||
|
|
||||||
```ts
|
```sql
|
||||||
// routes/api/[...path]/+server.ts
|
account_role: unverified_email | verified_email | admin | guest | affiliate
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
|
account_type: email | google | microsoft | facebook | guest
|
||||||
|
booking_status: pending | confirmed | in_progress | completed | client_cancelled | we_cancelled | re-schedule | no_show
|
||||||
async function proxyRequest(request: Request, path: string) {
|
payment_type: deposit | full | tip | balance | partial
|
||||||
const url = `${BACKEND_URL}/api/${path}`;
|
payment_method: online_square | in_person_card | cash | giftcard | discount
|
||||||
const backendRes = await fetch(url, {
|
payment_status: pending | completed | failed | refunded
|
||||||
method: request.method,
|
admin_notification_reason: pending_booking | cancelled_booking | rescheduled_booking | 1_week_no_pay | 1_month_no_pay | affiliate_claim
|
||||||
headers: request.headers
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward everything transparently
|
|
||||||
return new Response(backendRes.body, {
|
|
||||||
status: backendRes.status,
|
|
||||||
headers: new Headers(backendRes.headers)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Booking Flow
|
**Suggested additional `admin_notification_reason` values:**
|
||||||
**Step 1**: Select services
|
|
||||||
**Step 2**: Choose date/time
|
|
||||||
**Step 3**: Enter details
|
|
||||||
**Step 4**: Payment (TODO)
|
|
||||||
|
|
||||||
**Calendar Component:**
|
| Reason | Purpose |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `payment_failed` | Payment processing failed |
|
||||||
|
| `patch_test_due` | Customer needs patch test before appointment |
|
||||||
|
| `first_time_customer` | New customer's first booking |
|
||||||
|
| `inactive_customer` | Regular hasn't booked in X months - 5% discount (non stacking) |
|
||||||
|
| `birthday_this_week` | Customer birthday - 5% discount (stacking) |
|
||||||
|
| `schedule_conflict` | Potential double-booking detected - admin alert, 'second customer' alert |
|
||||||
|
|
||||||
```svelte
|
### Core Tables
|
||||||
<Calendar
|
|
||||||
type="single"
|
|
||||||
bind:value={selectedDate}
|
|
||||||
isDateUnavailable={(date) => bookedDates.some(d => d.compare(date) === 0)}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Time Slot Generator:**
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `users` | User accounts with profile data |
|
||||||
|
| `user_social_logins` | Social auth provider links |
|
||||||
|
| `services` | Service offerings |
|
||||||
|
| `user_service_patch_tests` | Patch test tracking |
|
||||||
|
| `bookings` | Appointment records |
|
||||||
|
| `booking_services` | Services per booking |
|
||||||
|
| `user_referrals` | Referral tracking |
|
||||||
|
| `working_hours` | Default weekly schedule |
|
||||||
|
| `exceptional_working_hours_groups` | Holiday/special hour groups |
|
||||||
|
| `exceptional_working_hours` | Hours for exception groups |
|
||||||
|
| `exceptional_group_applications` | Apply exceptions to date ranges |
|
||||||
|
| `payments` | Payment transactions |
|
||||||
|
| `business_settings` | Business configuration |
|
||||||
|
| `admin_notifications` | Admin notification queue |
|
||||||
|
|
||||||
```ts
|
### Key Functions
|
||||||
function generateTimeSlots(duration: number) {
|
|
||||||
const slots = []
|
| Function | Purpose |
|
||||||
for (let hour = 9; hour < 17; hour++) {
|
|----------|---------|
|
||||||
for (let minute = 0; minute < 60; minute += 15) {
|
| `generate_short_id()` | Generate 12-char IDs |
|
||||||
slots.push(`${hour.toString().padStart(2,'0')}:${minute.toString().padStart(2,'0')}`);
|
| `anonymize_user()` | GDPR data removal |
|
||||||
}
|
| `delete_guest_user()` | Clean up guest accounts |
|
||||||
}
|
| `export_all_user_data()` | GDPR subject access |
|
||||||
return slots
|
| `get_monthly_business_summary()` | Analytics |
|
||||||
}
|
| `get_vat_return_data()` | VAT reporting |
|
||||||
```
|
| `calculate_vat()` | VAT calculation |
|
||||||
|
| `get_receipt_data()` | Receipt generation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Next Steps
|
## Remaining Work
|
||||||
- **Booking Integration** – wire the wizard to the backend `/api/bookings` POST endpoint.
|
|
||||||
- **Payments** – integrate Stripe or Square SDK; add a payment screen.
|
### High Priority
|
||||||
- **Admin Dashboard** – implement admin routes and UI.
|
|
||||||
- **Monitoring/Logging** – add a lightweight Prometheus/Grafana stack or use CloudWatch.
|
| Task | Description | Files Affected |
|
||||||
- **CI/CD Pipeline** – create GitHub Actions workflow for linting, testing, and container publishing.
|
| ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------- |
|
||||||
- **Admin Handlers** – flesh out `/api/admin/services`, `/api/admin/bookings`, and `/api/admin/users` endpoints.
|
| **Customer booking submit** | `submitBooking()` at line 600 only logs, needs `POST /api/bookings` | `frontend/src/lib/components/booking/BookingFlow.svelte` |
|
||||||
- **Email/SMS Reminders** – add scheduled job to send reminders.
|
| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600`, `BookingCreateModal.svelte:224` | Frontend components |
|
||||||
- **Loyalty Tracking** – add a frontend component to display loyalty stamps.
|
| **Guest user endpoint** | Create `/api/users/guest` for walk-in bookings | `backend/handlers/user/` (new file) |
|
||||||
|
| **In-progress auto-infer** | Auto-set `in_progress` status based on time | Backend booking logic |
|
||||||
|
| **Begin button (Today)** | Manual start for early arrivals, gray out if >3hrs away | `CurrentAppointment.svelte` + backend |
|
||||||
|
| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals |
|
||||||
|
| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours |
|
||||||
|
| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic |
|
||||||
|
| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` |
|
||||||
|
| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step |
|
||||||
|
| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page |
|
||||||
|
| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page |
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
| Task | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| **Notifications UI** | Frontend panel to display admin notifications |
|
||||||
|
| **Notifications push** | Real-time notification mechanism (WebSocket/polling) |
|
||||||
|
| **User notifications** | Notification system for regular users (booking confirmations, reminders) |
|
||||||
|
| **Remove debug logs** | `console.log` in BookingFlow.svelte:600 and BookingCreateModal.svelte:224 |
|
||||||
|
| **S3/R2 image hosting** | Portfolio image storage with admin upload |
|
||||||
|
| **Refresh token endpoint** | Wire existing `RefreshTokenHandler` to router |
|
||||||
|
| **Loyalty display component** | Show stamps in account/bookings |
|
||||||
|
| **Email/SMS reminders** | Scheduled notification jobs |
|
||||||
|
| **Prometheus metrics** | Monitoring integration |
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
| Task | Description |
|
||||||
|
| ----------------------- | ----------------------------------- |
|
||||||
|
| **Social auth** | Wire `handlers/auth/social.go` |
|
||||||
|
| **Analytics** | Wire `handlers/admin/analytics.go` |
|
||||||
|
| **Portfolio API** | Wire `handlers/portfolio/images.go` |
|
||||||
|
| **nginx config review** | Finalize production config |
|
||||||
|
| **CI/CD pipeline** | Gitea Actions workflow |
|
||||||
|
| And many more | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Environment & Runtime
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Purpose | Default / Example |
|
| Variable | Purpose | Required |
|
||||||
|----------|---------|-------------------|
|
|----------|---------|----------|
|
||||||
| `JWT_SECRET_KEY` | Secret used to sign JWTs | **REQUIRED** – set in `.env` or Docker secrets |
|
| `JWT_SECRET_KEY` | Secret for JWT signing | **Yes** |
|
||||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://user:pass@localhost:5432/crussell?sslmode=disable` |
|
| `DATABASE_URL` | PostgreSQL connection string | **Yes** |
|
||||||
| `VITE_BACKEND_URL` | Front‑end proxy target | `http://localhost:8080` (dev) |
|
| `POSTGRES_USER` | Database username | Docker |
|
||||||
| `VITE_SQUARE_ENV` | Square environment | `sandbox` or `production` |
|
| `POSTGRES_PASSWORD` | Database password | Docker |
|
||||||
| `VITE_STRIPE_KEY` | Stripe publishable key | `pk_test_...` |
|
| `POSTGRES_DB` | Database name | Docker |
|
||||||
|
|
||||||
> **Tip**: The `.env.example` file contains all required keys – copy it to `.env` and edit.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./init-scripts/init-script.sql:/docker-entrypoint-initdb.d/init-script.sql:ro
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
environment:
|
depends_on: [postgres]
|
||||||
- JWT_SECRET_KEY=supersecret
|
|
||||||
- DATABASE_URL=postgres://crussell:crussell@db:5432/crussell
|
sabredav:
|
||||||
depends_on: [db, dav]
|
image: php:8.2-fpm
|
||||||
dav:
|
|
||||||
image: sabredav/sabredav
|
|
||||||
environment:
|
|
||||||
- DAV_URL=http://dav:8000
|
|
||||||
volumes:
|
volumes:
|
||||||
- dav-data:/data
|
- ./sabredav:/var/www/dav
|
||||||
db:
|
depends_on: [postgres]
|
||||||
image: postgres:15
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=crussell
|
|
||||||
- POSTGRES_PASSWORD=crussell
|
|
||||||
- POSTGRES_DB=crussell
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:stable
|
||||||
depends_on: [backend, dav]
|
ports: ["80:80", "443:443"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||||
frontend:
|
- ./frontend/build:/usr/share/nginx/html
|
||||||
build: ./frontend
|
depends_on: [backend, sabredav]
|
||||||
environment:
|
|
||||||
- VITE_BACKEND_URL=http://backend:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Run `docker compose up -d` to bring the stack up.
|
---
|
||||||
|
|
||||||
|
## Frontend Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/components/
|
||||||
|
├── admin/
|
||||||
|
│ ├── ApprovalModal.svelte
|
||||||
|
│ ├── BookingCreateModal.svelte
|
||||||
|
│ ├── BookingModal.svelte
|
||||||
|
│ ├── BookingsCard.svelte
|
||||||
|
│ ├── CallInBooking.svelte
|
||||||
|
│ ├── HolidayHours.svelte
|
||||||
|
│ ├── ImageUpload.svelte
|
||||||
|
│ ├── ServicesManagement.svelte
|
||||||
|
│ ├── UserModal.svelte
|
||||||
|
│ ├── UsersCard.svelte
|
||||||
|
│ ├── WalkInBooking.svelte
|
||||||
|
│ └── WalkInCreateModal.svelte
|
||||||
|
├── booking/
|
||||||
|
│ ├── BookingActions.svelte
|
||||||
|
│ ├── BookingFlow.svelte
|
||||||
|
│ ├── BookingSummary.svelte
|
||||||
|
│ ├── DatePicker.svelte
|
||||||
|
│ ├── ServiceCard.svelte
|
||||||
|
│ ├── ServiceSelector.svelte
|
||||||
|
│ ├── StepIndicator.svelte
|
||||||
|
│ └── TimeSlotPicker.svelte
|
||||||
|
└── today/
|
||||||
|
├── CurrentAppointment.svelte
|
||||||
|
├── PendingApprovals.svelte
|
||||||
|
└── TodayCalendar.svelte
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📜 GDPR & Data Retention
|
## Notes
|
||||||
|
|
||||||
The database schema includes a `anonymize_user()` function (see `init-script.sql`) that removes PII after a user’s account is closed. The code also contains a `export_all_user_data()` helper to support subject‑access requests.
|
- Holiday hours ARE integrated into all 3 booking flows (customer, call-in, walk-in) via `/api/scheduling/working-hours` and `/api/scheduling/available-hours`
|
||||||
|
- The backend merges default hours with applied exceptional hours automatically
|
||||||
|
- Static frontend is built and served by nginx; API calls go directly to Go backend in production
|
||||||
|
- Local dev uses SvelteKit's API proxy for CORS avoidance
|
||||||
|
- **GDPR functions exist in SQL** (`anonymize_user`, `export_all_user_data`, `delete_guest_user`) but only `DELETE /api/user/account` is wired - need user data export and admin tax export endpoints
|
||||||
|
- **Debug console.logs** in `BookingFlow.svelte:600` and `BookingCreateModal.svelte:224` should be removed before production
|
||||||
|
- **Notifications** are pull-based only (no push/WebSocket). Admin endpoint exists but no frontend UI. No user-facing notification system yet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./local-dev-2.sh # Creates tmux session 'crussell-dev'
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates 3 panes:
|
||||||
|
- **Pane 0**: psql interactive shell
|
||||||
|
- **Pane 1**: Backend (`go run -tags dev ./main.go`)
|
||||||
|
- **Pane 2**: Frontend (`npm run dev -- --host`)
|
||||||
|
|
||||||
|
### Dev Build Tag
|
||||||
|
|
||||||
|
Backend uses `-tags dev` - check for dev-specific behavior in code with build constraints.
|
||||||
|
|
||||||
|
### Admin User Setup
|
||||||
|
|
||||||
|
No API endpoint exists for role promotion. Admin users are created via direct SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE users SET account_role = 'admin' WHERE email = 'admin@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seed Data
|
||||||
|
|
||||||
|
Running `local-dev-2.sh` creates:
|
||||||
|
|
||||||
|
| Resource | Count | Details |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Users | 18 | 1 admin, 17 regular users |
|
||||||
|
| Services | 6 | Classic Manicure, Gel Manicure (BIAB), Luxury Pedicure, Express Mani & Pedi, Gel Removal, Nail Art Add-on |
|
||||||
|
| Bookings | 45 | 8 past, 3 today, 4 tomorrow, 30 future (spread over 15 days) |
|
||||||
|
| Confirmed | ~50% | Random selection of upcoming bookings auto-confirmed |
|
||||||
|
| Exceptional | 2 | November Break (closed), Christmas Holiday (reduced hours) |
|
||||||
|
|
||||||
|
### API JSON Examples
|
||||||
|
|
||||||
|
**Create Booking:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"start_time": "2025-01-15T10:00:00+00:00",
|
||||||
|
"service_ids": ["abc123def456"],
|
||||||
|
"notes": "Optional notes"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create Service:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Classic Manicure",
|
||||||
|
"description": "Nail shaping, cuticle care, hand massage, and polish.",
|
||||||
|
"price": 25.00,
|
||||||
|
"duration_minutes": 45,
|
||||||
|
"patch_test_duration_hours": 0,
|
||||||
|
"minimum_age_required": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confirm Booking:**
|
||||||
|
```json
|
||||||
|
POST /api/admin/bookings/{id}/confirm
|
||||||
|
Body: {"serviceOverrides": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create Exceptional Group:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Christmas Holiday Period",
|
||||||
|
"description": "Reduced hours for Christmas and New Year",
|
||||||
|
"hours": [
|
||||||
|
{"weekday": 0, "startTime": "00:00:00", "endTime": "00:00:00", "isOpen": false},
|
||||||
|
{"weekday": 1, "startTime": "10:00:00", "endTime": "15:00:00", "isOpen": true}
|
||||||
|
],
|
||||||
|
"weekStarts": ["2025-12-22", "2025-12-29"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time Format
|
||||||
|
|
||||||
|
All timestamps use ISO 8601 with timezone: `YYYY-MM-DDTHH:MM:SS±HH:MM` (e.g., `2025-01-15T10:00:00+00:00`)
|
||||||
|
|
||||||
|
Timezone is always `Europe/London` (handles BST automatically).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Transaction Pattern
|
||||||
|
Used throughout for atomic operations:
|
||||||
|
|
||||||
|
```go
|
||||||
|
tx, err := db.DB.Begin(r.Context())
|
||||||
|
if err != nil { /* handle error */ }
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
// Use tx instead of db.DB for queries
|
||||||
|
err = tx.QueryRow(r.Context(), `INSERT INTO...`)
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil { /* handle error */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
### CardDAV Synchronization
|
||||||
|
- **On registration**: Creates vCard in SabreDAV via `dav.Service.CreateContact()`
|
||||||
|
- **On profile update**: Updates existing vCard via `updateCardDAV()` helper
|
||||||
|
- Uses internal HTTP calls to DAV server
|
||||||
|
|
||||||
|
### Role Change Detection
|
||||||
|
`RefreshTokenHandler` verifies user's current role hasn't changed since token was issued. If role changed, forces re-login with `401 Unauthorized`.
|
||||||
|
|
||||||
|
### Build Tags
|
||||||
|
- `db_dev.go` - Used with `-tags dev` for local development (localhost connection)
|
||||||
|
- `db.go` - Production build (uses env var for host)
|
||||||
|
- `internal/dav/service_dev.go` / `service_prod.go` - Same pattern for DAV service
|
||||||
Reference in New Issue
Block a user