diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go
index 7eec7ee..7820a57 100644
--- a/backend/handlers/auth/local.go
+++ b/backend/handlers/auth/local.go
@@ -161,7 +161,6 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(r.Context())
if err != nil {
- fmt.Println(err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
@@ -173,8 +172,8 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
var userID string
err = tx.QueryRow(r.Context(), `
INSERT INTO users
- (n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
- account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
+ (n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
+ account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
created_at, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8)
@@ -182,7 +181,6 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
if err != nil {
- fmt.Println(err)
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "an account with this email already exists", http.StatusConflict)
} else {
@@ -246,7 +244,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
var userID, passwordHash, role string
ctx := context.Background()
err := db.DB.QueryRow(ctx, `
- SELECT id, password_hash, account_role
+ SELECT id, password_hash, account_role
FROM users
WHERE email = $1 AND account_type = 'email'
`, req.Email).Scan(&userID, &passwordHash, &role)
diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go
index 2ecf5b6..72d1522 100644
--- a/backend/handlers/bookings/bookings.go
+++ b/backend/handlers/bookings/bookings.go
@@ -264,7 +264,9 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
var args []interface{}
+ var countArgs []interface{}
args = append(args, userID)
+ countArgs = append(countArgs, userID)
paramCount := 2
// Add filters
@@ -272,6 +274,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
baseQuery += fmt.Sprintf(" AND status = $%d", paramCount)
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
args = append(args, *req.Status)
+ countArgs = append(countArgs, *req.Status)
paramCount++
}
@@ -286,6 +289,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Ensure it's at start of day in London time
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
args = append(args, startTime)
+ countArgs = append(countArgs, startTime)
paramCount++
}
@@ -300,6 +304,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Add end of day
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
args = append(args, endTime)
+ countArgs = append(countArgs, endTime)
paramCount++
}
@@ -312,7 +317,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Get total count
var total int
- err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total)
+ err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
if err != nil {
log.Printf("Failed to get booking count for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -367,7 +372,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Build base query - only what the component needs
baseQuery := `
WITH booking_totals AS (
- SELECT
+ SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration,
SUM(COALESCE(bs.override_price, s.price)) as total_amount
@@ -376,16 +381,16 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
GROUP BY bs.booking_id
),
payment_totals AS (
- SELECT
+ SELECT
booking_id,
SUM(amount) as total_paid
- FROM payments
+ FROM payments
WHERE status = 'completed'
GROUP BY booking_id
)
- SELECT
- b.id,
- b.start_time,
+ SELECT
+ b.id,
+ b.start_time,
b.status,
u.fn,
COALESCE(bt.total_duration, 0) as duration_minutes,
@@ -707,10 +712,10 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User = &UserSummary{}
err := db.DB.QueryRow(r.Context(), `
- SELECT
+ SELECT
b.id, b.user_id, b.start_time, b.status, b.notes,
b.created_at, b.updated_at, b.created_by,
- u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
+ u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
u.referral_code, u.notes
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
@@ -736,8 +741,8 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
// ----------------------------
var referralCodeUses int
err = db.DB.QueryRow(r.Context(), `
- SELECT COUNT(*)
- FROM user_referrals
+ SELECT COUNT(*)
+ FROM user_referrals
WHERE referrer_id = $1
`, booking.User.ID).Scan(&referralCodeUses)
if err != nil {
@@ -751,7 +756,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
// 2. Fetch services and calculate totals
// ----------------------------
serviceRows, err := db.DB.Query(r.Context(), `
- SELECT
+ SELECT
s.name,
coalesce(bs.override_price, s.price) as price,
coalesce(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
@@ -894,7 +899,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Build the main search query using CTEs to match your actual schema
searchQuery := `
WITH booking_totals AS (
- SELECT
+ SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration,
SUM(COALESCE(bs.override_price, s.price)) as total_amount
@@ -903,16 +908,16 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
GROUP BY bs.booking_id
),
payment_totals AS (
- SELECT
+ SELECT
booking_id,
SUM(amount) as total_paid
- FROM payments
+ FROM payments
WHERE status = 'completed'
GROUP BY booking_id
)
- SELECT
- b.id,
- b.start_time,
+ SELECT
+ b.id,
+ b.start_time,
b.status,
u.fn as full_name,
COALESCE(bt.total_duration, 0) as duration_minutes,
@@ -921,7 +926,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_totals bt ON b.id = bt.booking_id
LEFT JOIN payment_totals pt ON b.id = pt.booking_id
- WHERE
+ WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR
@@ -931,8 +936,8 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
u.email ILIKE $1 ESCAPE '\' OR
u.phone ILIKE $1 ESCAPE '\' OR
EXISTS (
- SELECT 1 FROM booking_services bs
- JOIN services s ON bs.service_id = s.id
+ SELECT 1 FROM booking_services bs
+ JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\'
)
ORDER BY b.start_time ASC
@@ -945,7 +950,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
- WHERE
+ WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR
@@ -1089,10 +1094,11 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
- if req.StartTime.Before(time.Now()) {
- http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
- return
- }
+ // TODO: reenable start time validation before going live, disabled for testing
+ // if req.StartTime.Before(time.Now()) {
+ // http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
+ // return
+ // }
// Get created by from context (if available)
var createdBy *string
@@ -1153,6 +1159,18 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
+ // Create admin notification for pending booking
+ notificationQuery := `
+ INSERT INTO admin_notifications (reason, booking_id, user_id)
+ VALUES ($1, $2, $3)
+`
+ _, err = tx.Exec(r.Context(), notificationQuery, "pending_booking", booking.ID, userID)
+ if err != nil {
+ log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -1202,7 +1220,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update booking start time (only for user's own bookings)
query := `
- UPDATE bookings
+ UPDATE bookings
SET start_time = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
@@ -1273,7 +1291,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update booking status
query := `
- UPDATE bookings
+ UPDATE bookings
SET status = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
@@ -1354,7 +1372,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update booking status and notes
bookingQuery := `
- UPDATE bookings
+ UPDATE bookings
SET status = 'confirmed', notes = COALESCE($1, notes), updated_at = NOW()
WHERE id = $2 AND status = 'pending'
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
@@ -1391,7 +1409,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if len(req.ServiceOverrides) > 0 {
// First, verify all service IDs belong to this booking
serviceCheckQuery := `
- SELECT COUNT(*) FROM booking_services
+ SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND service_id = ANY($2)
`
serviceIDs := make([]string, len(req.ServiceOverrides))
@@ -1413,7 +1431,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update each service override
serviceUpdateQuery := `
- UPDATE booking_services
+ UPDATE booking_services
SET override_price = $1,
override_duration_minutes = $2
WHERE booking_id = $3 AND service_id = $4
@@ -1493,13 +1511,22 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // Start transaction for cancellation and notification
+ tx, err := db.DB.Begin(r.Context())
+ if err != nil {
+ log.Printf("Failed to start transaction: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback(r.Context())
+
// Update booking status instead of deleting
query := `
- UPDATE bookings
+ UPDATE bookings
SET status = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
`
- result, err := db.DB.Exec(r.Context(), query, req.Reason, bookingID, userID)
+ result, err := tx.Exec(r.Context(), query, req.Reason, bookingID, userID)
if err != nil {
log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1511,6 +1538,23 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // Create admin notification for cancelled booking
+ notificationQuery := `
+ INSERT INTO admin_notifications (reason, booking_id, user_id)
+ VALUES ($1, $2, $3)
+ `
+ _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
+ if err != nil {
+ log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -1521,9 +1565,30 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
- // Hard delete if no payments exist
+ // Hard delete if no payments exist - use transaction for notification
+ tx, err := db.DB.Begin(r.Context())
+ if err != nil {
+ log.Printf("Failed to start transaction: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ // Create admin notification BEFORE deleting the booking
+ notificationQuery := `
+ INSERT INTO admin_notifications (reason, booking_id, user_id)
+ VALUES ($1, $2, $3)
+ `
+ _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
+ if err != nil {
+ log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Now delete the booking
query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2"
- result, err := db.DB.Exec(r.Context(), query, bookingID, userID)
+ result, err := tx.Exec(r.Context(), query, bookingID, userID)
if err != nil {
log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Failed to delete booking", http.StatusInternalServerError)
@@ -1535,6 +1600,11 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ if err := tx.Commit(r.Context()); err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -1592,7 +1662,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int
serviceRows, err := db.DB.Query(r.Context(), `
- SELECT
+ SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go
index 710bab7..c066128 100644
--- a/backend/handlers/scheduling/default-hours.go
+++ b/backend/handlers/scheduling/default-hours.go
@@ -318,13 +318,13 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// Load bookings
bookingRows, _ := db.DB.Query(r.Context(), `
- SELECT
- b.start_time,
+ SELECT
+ b.start_time,
COALESCE(SUM(
- CASE
- WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
- THEN bs.override_duration_minutes
- ELSE s.duration_minutes
+ CASE
+ WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
+ THEN bs.override_duration_minutes
+ ELSE s.duration_minutes
END
), 0) AS total_duration
FROM bookings b
diff --git a/backend/main.go b/backend/main.go
index 8aaf22f..81efa2d 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -17,6 +17,7 @@ import (
authHandlers "crussell/handlers/auth"
"crussell/handlers/bookings"
+ "crussell/handlers/notifications"
"crussell/handlers/scheduling"
"crussell/handlers/services"
"crussell/handlers/user"
@@ -132,6 +133,15 @@ func main() {
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
})
+
+ // --- Admin Notifications ---
+ r.Route("/admin/notifications", func(r chi.Router) {
+ // List all unacknowledged notifications
+ r.Get("/", notifications.GetNotifications)
+
+ // Acknowledge a single notification
+ r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
+ })
})
})
diff --git a/frontend/src/lib/components/layout/ContactCard.svelte b/frontend/src/lib/components/layout/ContactCard.svelte
index bf6f827..60e357e 100644
--- a/frontend/src/lib/components/layout/ContactCard.svelte
+++ b/frontend/src/lib/components/layout/ContactCard.svelte
@@ -11,7 +11,7 @@
-
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index e5d7b9f..c633b72 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -76,17 +76,19 @@
-
-
-
Why Choose Us?
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus
- tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices
- diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci
- nec nonummy molestie,
-
-
-
+{#if !authStore.isAuthenticated}
+
+
+
Why Choose Us?
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus
+ tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices
+ diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci
+ nec nonummy molestie,
+
+
+
+{/if}
Ready to Treat Yourself?
diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte
index 767f0b0..b47d217 100644
--- a/frontend/src/routes/account/+page.svelte
+++ b/frontend/src/routes/account/+page.svelte
@@ -62,9 +62,7 @@
};
let userData = $state(null);
- let bookings = $state([]);
let loadingUser = $state(true);
- let loadingBookings = $state(true);
let stamps = $state(0);
// =============== Fetch User Data ===============
@@ -97,12 +95,22 @@
}
// =============== Fetch Bookings ===============
- async function fetchBookings() {
+ let upcomingBookings = $state([]);
+ let pastBookings = $state([]);
+ let pastPage = $state(1);
+ let pastTotalPages = $state(1);
+
+ let loadingUpcoming = $state(false);
+ let loadingPast = $state(false);
+
+ // =============== Fetch Upcoming Bookings (next 3) ===============
+ async function fetchUpcomingBookings() {
if (pageState !== 'authorized') return;
- loadingBookings = true;
+ loadingUpcoming = true;
try {
- const response = await fetch('/api/user/bookings', {
+ 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`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -110,24 +118,66 @@
}
});
- if (response.ok) {
- const data = await response.json();
- bookings = data.bookings || [];
- } else {
- // toast.error('Failed to load booking history');
+ if (!response.ok) {
+ const text = await response.text();
+ toast.error('Failed to load upcoming bookings: ' + text);
+ return;
}
+
+ const data = await response.json();
+ upcomingBookings = data.bookings || [];
} catch (err) {
- console.error('Error fetching bookings:', err);
- toast.error('Network error loading bookings');
+ console.error('Error fetching upcoming bookings:', err);
+ toast.error('Network error loading upcoming bookings');
} finally {
- loadingBookings = false;
+ loadingUpcoming = false;
}
}
+ // =============== Fetch Past Bookings (paginated 10 per page) ===============
+ async function fetchPastBookings(page = 1) {
+ if (pageState !== 'authorized') return;
+
+ loadingPast = true;
+ try {
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
+ const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${authStore.currentToken}`
+ }
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ toast.error('Failed to load past bookings: ' + text);
+ return;
+ }
+
+ const data = await response.json();
+ pastBookings = data.bookings || [];
+ pastPage = data.page || page;
+ pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
+ } catch (err) {
+ console.error('Error fetching past bookings:', err);
+ toast.error('Network error loading past bookings');
+ } finally {
+ loadingPast = false;
+ }
+ }
+
+ // =============== Pagination Helpers ===============
+ function goToPastPage(page: number) {
+ if (page < 1 || page > pastTotalPages) return;
+ fetchPastBookings(page);
+ }
+
$effect(() => {
if (pageState === 'authorized') {
fetchUserData();
- fetchBookings();
+ fetchUpcomingBookings();
+ fetchPastBookings();
}
});
@@ -231,13 +281,29 @@
function formatDateTime(dateString: string): string {
const date = new SvelteDate(dateString);
- return date.toLocaleString('en-GB', {
- day: 'numeric',
- month: 'short',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- });
+
+ // Get date parts
+ const day = date.getDate();
+ const month = date.toLocaleString('en-GB', { month: 'short' });
+ const year = date.getFullYear();
+
+ // Get time parts
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+
+ // Special cases for midnight and noon
+ let timeStr;
+ if (hours === 12 && minutes === 0) {
+ timeStr = 'Noon';
+ } else if (hours === 0 && minutes === 0) {
+ timeStr = 'Midnight';
+ } else {
+ const period = hours >= 12 ? 'PM' : 'AM';
+ const displayHours = hours % 12 || 12;
+ timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
+ }
+
+ return `${day} ${month} ${year}, ${timeStr}`;
}
@@ -424,97 +490,152 @@
{:else if activeTab === 'history'}
-
-
+
+
- Booking History
- Your past and upcoming appointments
+ Upcoming Appointments
+ Next 3 upcoming bookings
-
- {#if loadingBookings}
+
+ {#if loadingUpcoming}
{#each Array(3) as _, i (i)}
-
+
{/each}
- {:else if bookings.length === 0}
- No bookings found
+ {:else if upcomingBookings.length === 0}
+ No upcoming bookings
{:else}
- {#each bookings as booking (booking.id)}
-
-
-
-
{formatDateTime(booking.start_time)}
-
- {booking.duration_minutes} minutes
-
-
-
- {booking.status}
-
-
-
-
- {#if booking.services && booking.services.length > 0}
-
-
Services:
- {#each booking.services as service (service.service_name)}
-
- {service.service_name}
- £{service.price.toFixed(2)}
-
- {/each}
-
- {/if}
-
-
-
-
-
-
- Total:
- £{booking.total_amount.toFixed(2)}
-
-
-
Paid:
-
£{booking.amount_paid.toFixed(2)}
+
+
{formatDateTime(b.start_time)}
+
+
+
+ {b.status}
+
+
+ - {(() => {
+ const services = (b.services || []).map(
+ (s) => s.service_name || 'Unknown Service'
+ );
+ if (services.length === 0) return 'No services';
+ if (services.length === 1) return services[0];
+ if (services.length === 2) return services.join(' and ');
+ return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
+ })()}
+
- {#if booking.amount_due > 0}
-
- Due:
- £{booking.amount_due.toFixed(2)}
-
- {/if}
-
-
- {#if booking.payments && booking.payments.length > 0}
-
-
Payments:
- {#each booking.payments as payment (payment.id)}
-
- {payment.payment_method.replace('_', ' ')} ({payment.payment_type})
- £{payment.amount.toFixed(2)}
-
- {/each}
-
- {/if}
+
goto(`/bookings/${b.id}`)}>View
{/each}
{/if}
+
+
+
+
+ Past Appointments
+ Previous bookings
+
+
+ {#if loadingPast}
+ {#each Array(5) as _, i (i)}
+
+ {/each}
+ {:else if pastBookings.length === 0}
+ No past bookings
+ {:else}
+ {#each pastBookings as b (b.id)}
+
+
+
{formatDateTime(b.start_time)}
+
+
+
+ {b.status}
+
+
+ - {(() => {
+ const services = (b.services || []).map(
+ (s) => s.service_name || 'Unknown Service'
+ );
+ if (services.length === 0) return 'No services';
+ if (services.length === 1) return services[0];
+ if (services.length === 2) return services.join(' and ');
+ return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
+ })()}
+
+
+
+
goto(`/bookings/${b.id}`)}>View
+
+ {/each}
+ {/if}
+
+
+ {#if pastTotalPages > 1}
+
+ goToPastPage(pastPage - 1)}>Prev
+ {pastPage} / {pastTotalPages}
+ goToPastPage(pastPage + 1)}>Next
+
+ {/if}
+
+
{:else if activeTab === 'referral'}
diff --git a/frontend/src/routes/book/+page.svelte b/frontend/src/routes/book/+page.svelte
index f1b4b54..23486c0 100644
--- a/frontend/src/routes/book/+page.svelte
+++ b/frontend/src/routes/book/+page.svelte
@@ -795,7 +795,7 @@
{#if selectedDate}
{getDayWithOrdinal(selectedDate)}
{/if}
-
+
{#each groupedTimeSlots as slot (slot.startTime)}
{#if slot.type === 'available'}
diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql
index 115a633..8e16fad 100644
--- a/init-scripts/init-script.sql
+++ b/init-scripts/init-script.sql
@@ -339,6 +339,17 @@ INSERT INTO business_settings (
'https://www.website.co.uk'
);
+CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim');
+
+CREATE TABLE admin_notifications (
+ id SERIAL PRIMARY KEY,
+ reason admin_notification_reason NOT NULL,
+ booking_id CHAR(12) REFERENCES bookings(id),
+ user_id CHAR(12) REFERENCES users(id),
+ acknowledged_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status);
CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
CREATE INDEX idx_users_created_at ON users(created_at);
diff --git a/local-dev.sh b/local-dev.sh
index 50e21ef..6f7aa50 100755
--- a/local-dev.sh
+++ b/local-dev.sh
@@ -164,16 +164,16 @@ for SERVICE_JSON in "${SERVICES[@]}"; do
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating: $SERVICE_NAME"
echo "Request JSON: $SERVICE_JSON"
-
+
CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d "$SERVICE_JSON" \
"$BASE_URL/admin/services")
-
+
HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d')
-
+
if [ "$HTTP_CODE" = "201" ]; then
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
echo "✅ Created: $SERVICE_NAME"
@@ -207,7 +207,7 @@ done
# --- 7️⃣ Create Demo Bookings ---
echo ""
-echo "7️⃣ Creating 6 Demo Bookings..."
+echo "7️⃣ Creating 41 Demo Bookings (35 past + 6 future)..."
# Function to create a booking
create_booking() {
@@ -216,28 +216,28 @@ create_booking() {
local SERVICE_IDS_JSON=$3
local NOTES=$4
local BOOKING_NAME=$5
-
+
local BOOKING_JSON="{\"start_time\":\"$START_TIME\",\"service_ids\":$SERVICE_IDS_JSON"
if [ -n "$NOTES" ]; then
BOOKING_JSON="$BOOKING_JSON,\"notes\":\"$NOTES\""
fi
BOOKING_JSON="$BOOKING_JSON}"
-
+
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Creating: $BOOKING_NAME"
echo "Time: $START_TIME"
echo "Services: $SERVICE_IDS_JSON"
-
+
CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d "$BOOKING_JSON" \
"$BASE_URL/bookings")
-
+
HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d')
-
+
if [ "$HTTP_CODE" = "201" ]; then
echo "✅ Created: $BOOKING_NAME"
BOOKING_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4)
@@ -248,20 +248,13 @@ create_booking() {
echo "Response body: $RESPONSE_BODY"
fi
fi
-
+
sleep 0.2
}
# Use TZ=Europe/London to generate London-local times with correct offset
# Note: date command respects TZ for parsing and formatting
-# Get tomorrow at 08:00 London time as base (ensures future)
-TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d)
-
-# Calculate future dates in London time
-NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d)
-WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d)
-
# Helper function to format a London time as RFC3339 with 'T' (required by Go backend)
format_london_time() {
local DATE_PART="$1"
@@ -269,6 +262,60 @@ format_london_time() {
TZ=Europe/London date -d "$DATE_PART $TIME_PART" +"%Y-%m-%dT%H:%M:%S%:z"
}
+# --- PAST BOOKINGS (35 bookings from October-November 2025) ---
+echo ""
+echo "Creating 35 past bookings..."
+
+# October 2025 bookings (15 bookings)
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-01" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 1 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-03" "14:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 3 - Gel Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-05" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Treating myself" "Oct 5 - Luxury Pedicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-08" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Oct 8 - Express Mani & Pedi"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-10" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Simple nail art" "Oct 10 - Classic + Nail Art"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-12" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 12 - Gel Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-15" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Oct 15 - Gel Removal"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-17" "10:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 17 - Luxury Pedicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-19" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 19 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-22" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "Quick refresh" "Oct 22 - Express Mani & Pedi"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-24" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Oct 24 - Gel + Nail Art"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-26" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 26 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-28" "13:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 28 - Luxury Pedicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-29" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "" "Oct 29 - Removal + New Gel"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-10-31" "12:00:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Halloween nails!" "Oct 31 - Classic + Nail Art"
+
+# November 2025 bookings (20 bookings)
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-02" "10:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 2 - Gel Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-04" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 4 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-05" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 5 - Express Mani & Pedi"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-07" "15:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 7 - Luxury Pedicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-09" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 9 - Classic + Nail Art"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-11" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 11 - Gel Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-12" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Nov 12 - Gel Removal"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-14" "10:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 14 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-16" "14:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 16 - Express Mani & Pedi"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-18" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 18 - Luxury Pedicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-19" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 19 - Gel + Nail Art"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-21" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 21 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-22" "13:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 22 - Gel Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-23" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[0]}\"]" "" "Nov 23 - Removal + Classic"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-25" "10:00:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 25 - Express Mani & Pedi"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-26" "14:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 26 - Luxury Pedicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-27" "11:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 27 - Classic + Nail Art"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-28" "15:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 28 - Gel Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "09:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 29 - Classic Manicure"
+create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "13:00:00")" "[\"${SERVICE_IDS[3]}\"]" "Last one before December!" "Nov 29 - Express Mani & Pedi"
+
+# --- FUTURE BOOKINGS (6 bookings) ---
+echo ""
+echo "Creating 6 future bookings..."
+
+# Get tomorrow at 08:00 London time as base (ensures future)
+TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d)
+
+# Calculate future dates in London time
+NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d)
+WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d)
+
# Create demo bookings using London time with proper offset
create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00"
create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "14:30:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30"
@@ -385,4 +432,4 @@ tmux select-pane -t $SESSION_NAME:0.0
trap 'rm -f /tmp/seed_data.sh' EXIT
# Attach to session
-tmux attach-session -t $SESSION_NAME
\ No newline at end of file
+tmux attach-session -t $SESSION_NAME