Split admin dashboard, implement user and booking search

This commit is contained in:
2026-01-17 18:55:58 +00:00
parent 1e8995f577
commit 3a77e582e3
17 changed files with 4315 additions and 3266 deletions
+99 -85
View File
@@ -170,10 +170,11 @@ type GetAllBookingsRequest struct {
// BookingListResponse represents a paginated list of bookings // BookingListResponse represents a paginated list of bookings
type BookingListResponse struct { type BookingListResponse struct {
Bookings []Booking `json:"bookings"` Bookings []Booking `json:"bookings"`
Page int `json:"page"` Total int `json:"total"`
PerPage int `json:"per_page"` Page int `json:"page"`
Total int `json:"total"` PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"`
} }
// SearchBookingsRequest represents search parameters // SearchBookingsRequest represents search parameters
@@ -573,76 +574,53 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Parse query parameters // Parse pagination parameters
req := parseGetAllBookingsRequest(r) query := r.URL.Query()
page := 1
perPage := 5
// Build query with specific user filter if pageStr := query.Get("page"); pageStr != "" {
baseQuery := ` if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by, page = p
u.fn, u.profile_pic_url, u.notes as user_notes
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.user_id = $1
`
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
var args []interface{}
args = append(args, userID)
paramCount := 2
// Add filters
if req.Status != nil {
baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount)
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
args = append(args, *req.Status)
paramCount++
}
if req.StartDate != nil {
baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount)
startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
if err != nil {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
} }
// 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)
paramCount++
} }
if req.EndDate != nil { if perPageStr := query.Get("per_page"); perPageStr != "" {
baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) perPage = pp
endTime, err := time.Parse("2006-01-02", *req.EndDate)
if err != nil {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
} }
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
args = append(args, endTime)
paramCount++
} }
// Add ordering and pagination offset := (page - 1) * perPage
baseQuery += " ORDER BY b.start_time ASC"
if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
args = append(args, req.PerPage, (req.Page-1)*req.PerPage)
}
// Get total count // Get total count
var total int var total int
err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total) err := db.DB.QueryRow(r.Context(), `
SELECT COUNT(*)
FROM bookings
WHERE user_id = $1
`, userID).Scan(&total)
if err != nil { if err != nil {
log.Printf("Failed to get booking count for user %s: %v", userID, err) log.Printf("Failed to count bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
// Get bookings // Fetch bookings with pagination, ordered by start_time DESC (future to past)
rows, err := db.DB.Query(r.Context(), baseQuery, args...) rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.notes,
b.created_at,
b.updated_at,
b.created_by
FROM bookings b
WHERE b.user_id = $1
ORDER BY b.start_time DESC
LIMIT $2 OFFSET $3
`, userID, perPage, offset)
if err != nil { if err != nil {
log.Printf("Failed to fetch bookings for user %s: %v", userID, err) log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -653,45 +631,81 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
var bookings []Booking var bookings []Booking
for rows.Next() { for rows.Next() {
var b Booking var b Booking
b.User = &UserSummary{}
var createdBy sql.NullString
var userFullName, userPicURL, userNotes sql.NullString
err := rows.Scan( err := rows.Scan(
&b.ID, &b.User.ID, &b.StartTime, &b.Status, &b.Notes, &b.ID,
&b.CreatedAt, &b.UpdatedAt, &createdBy, &b.StartTime,
&userFullName, &userPicURL, &userNotes, &b.Status,
&b.Notes,
&b.CreatedAt,
&b.UpdatedAt,
&b.CreatedBy,
) )
if userFullName.Valid {
b.User.FullName = userFullName.String
}
if userPicURL.Valid {
b.User.ProfilePicURL = &userPicURL.String
}
if userNotes.Valid {
b.User.Notes = &userNotes.String
}
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)
return return
} }
if createdBy.Valid {
b.CreatedBy = &createdBy.String // Fetch services for this booking
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
s.name,
COALESCE(bs.override_price, s.price) as price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, b.ID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
continue
} }
var totalAmount float64
for serviceRows.Next() {
var service BookingService
var price float64
var durationMinutes int
if err := serviceRows.Scan(&service.ServiceName, &price, &durationMinutes); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
service.Price = &price
service.DurationMinutes = &durationMinutes
totalAmount += price
b.Services = append(b.Services, service)
}
serviceRows.Close()
b.TotalAmount = totalAmount
bookings = append(bookings, b) bookings = append(bookings, b)
} }
if bookings == nil {
bookings = []Booking{}
}
// Calculate total pages
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
response := BookingListResponse{ response := BookingListResponse{
Bookings: bookings, Bookings: bookings,
Page: req.Page, Total: total,
PerPage: req.PerPage, Page: page,
Total: total, PerPage: perPage,
TotalPages: totalPages,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode response: %v", err) log.Printf("Failed to encode bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -773,7 +787,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
defer serviceRows.Close() defer serviceRows.Close()
var totalAmount float64 var totalAmount float64
var durationMinutes int var durationMinutesTotal int
for serviceRows.Next() { for serviceRows.Next() {
var name string var name string
@@ -788,7 +802,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
// Calculate totals // Calculate totals
totalAmount += price totalAmount += price
durationMinutes += durationMinutes durationMinutesTotal += durationMinutes
booking.Services = append(booking.Services, BookingService{ booking.Services = append(booking.Services, BookingService{
ServiceName: &name, ServiceName: &name,
Price: &price, Price: &price,
@@ -797,7 +811,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
booking.TotalAmount = totalAmount booking.TotalAmount = totalAmount
booking.DurationMinutes = durationMinutes booking.DurationMinutes = durationMinutesTotal
// ---------------------------- // ----------------------------
// 3. Fetch payments and calculate amount paid // 3. Fetch payments and calculate amount paid
+48
View File
@@ -0,0 +1,48 @@
package portfolio
// import (
// "crussell/db"
// "net/http"
// )
// func GetImages(w http.ResponseWriter, r *http.Request) {
// rows, err := db.DB.Query(r.Context(), `
// select id, r2_url
// from images
// where
// ($1::text[] is null or tag_names @> $1)
// and ($2::text is null or exists (
// select 1 from unnest(tag_names) as tag
// where tag ilike '%' || $2 || '%'
// ))
// order by created_at desc
// limit $3 offset $4
// `,
// semanticTags,
// searchTerm,
// limit,
// offset,
// )
// }
// func getAutoCompleteAdmin(w http.ResponseWriter, r *http.Request) {
// rows, err := db.DB.Query(r.Context(), `
// select name
// from tags
// where name ilike '%' || $1 || '%'
// order by similarity(name, $1) desc
// limit 10
// `, query)
// }
// func getAutoCompleteUser(w http.ResponseWriter, r *http.Request) {
// rows, err := db.DB.Query(r.Context(), `
// select name
// from tags
// where
// name not like '%:%'
// and name ilike '%' || $1 || '%'
// order by similarity(name, $1) desc
// limit 10
// `, query)
// }
+266
View File
@@ -2,13 +2,17 @@ package user
import ( import (
"bytes" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"github.com/go-chi/chi/v5"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
@@ -39,6 +43,55 @@ type UpdateProfileRequest struct {
Phone string `json:"phone"` Phone string `json:"phone"`
} }
type AdminUserDetail struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
FullName string `json:"fullName"`
Phone *string `json:"phone,omitempty"`
DateOfBirth *string `json:"dateOfBirth,omitempty"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
AccountRole string `json:"accountRole"`
AccountType string `json:"accountType"`
LoyaltyStamps int `json:"loyaltyStamps"`
ReferralCode string `json:"referralCode"`
ReferralCodeUses int `json:"referralCodeUses"`
LastLoginAt *string `json:"lastLoginAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Notes *string `json:"notes,omitempty"`
// GDPR consent fields
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
PolicyConsentUpdatedAt *string `json:"policyConsentUpdatedAt,omitempty"`
DataRetentionConsent bool `json:"dataRetentionConsent"`
DataConsentUpdatedAt *string `json:"dataConsentUpdatedAt,omitempty"`
// Social logins
SocialLogins []SocialLogin `json:"socialLogins,omitempty"`
}
type SocialLogin struct {
Provider string `json:"provider"`
CreatedAt string `json:"createdAt"`
}
type UserListItem struct {
ID string `json:"id"`
FullName string `json:"fullName"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
}
type UserListResponse struct {
Users []UserListItem `json:"users"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"`
}
// GET /api/user/profile // GET /api/user/profile
func GetProfileHandler(w http.ResponseWriter, r *http.Request) { func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
@@ -209,3 +262,216 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// GET /api/admin/users/{id}
func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
// Fetch user details
var user AdminUserDetail
err := db.DB.QueryRow(r.Context(), `
SELECT
id, email, n_first_name, n_last_name, fn, phone,
date_of_birth::text, profile_pic_url,
account_role, account_type, loyalty_stamps, referral_code,
last_login_at::text, created_at::text, updated_at::text, notes,
privacy_policy_and_terms_consent,
policy_consent_updated_at::text,
data_retention_consent,
data_consent_updated_at::text
FROM users
WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &user.FirstName, &user.LastName, &user.FullName,
&user.Phone, &user.DateOfBirth, &user.ProfilePicURL,
&user.AccountRole, &user.AccountType, &user.LoyaltyStamps, &user.ReferralCode,
&user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes,
&user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt,
&user.DataRetentionConsent, &user.DataConsentUpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Fetch referral code uses count
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*)
FROM user_referrals
WHERE referrer_id = $1 AND claimed_booking_id IS NOT NULL
`, userID).Scan(&user.ReferralCodeUses)
if err != nil {
log.Printf("Failed to fetch referral code uses for user %s: %v", userID, err)
user.ReferralCodeUses = 0
}
// Fetch social logins
socialRows, err := db.DB.Query(r.Context(), `
SELECT provider, created_at::text
FROM user_social_logins
WHERE user_id = $1
ORDER BY created_at ASC
`, userID)
if err != nil {
log.Printf("Failed to fetch social logins for user %s: %v", userID, err)
} else {
defer socialRows.Close()
var socialLogins []SocialLogin
for socialRows.Next() {
var sl SocialLogin
if err := socialRows.Scan(&sl.Provider, &sl.CreatedAt); err != nil {
log.Printf("Failed to scan social login for user %s: %v", userID, err)
continue
}
socialLogins = append(socialLogins, sl)
}
if len(socialLogins) > 0 {
user.SocialLogins = socialLogins
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(user); err != nil {
log.Printf("Failed to encode user response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// GET /api/admin/users
func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
query := r.URL.Query()
searchTerm := query.Get("q")
// Pagination parameters
page := 1
perPage := 10
if pageStr := query.Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if perPageStr := query.Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
}
offset := (page - 1) * perPage
// Build query based on whether search is provided
var countQuery string
var listQuery string
var countArgs []interface{}
var listArgs []interface{}
if searchTerm != "" {
// Search in name, email, or phone
searchPattern := "%" + searchTerm + "%"
countQuery = `
SELECT COUNT(*)
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
`
countArgs = []interface{}{searchPattern}
listQuery = `
SELECT id, fn, email, phone
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
listArgs = []interface{}{searchPattern, perPage, offset}
} else {
// No search - get all users
countQuery = `SELECT COUNT(*) FROM users`
countArgs = []interface{}{}
listQuery = `
SELECT id, fn, email, phone
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
listArgs = []interface{}{perPage, offset}
}
// Get total count
var total int
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
if err != nil {
log.Printf("Failed to count users: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get users list
rows, err := db.DB.Query(r.Context(), listQuery, listArgs...)
if err != nil {
log.Printf("Failed to fetch users: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var users []UserListItem
for rows.Next() {
var user UserListItem
err := rows.Scan(&user.ID, &user.FullName, &user.Email, &user.Phone)
if err != nil {
log.Printf("Failed to scan user row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
users = append(users, user)
}
// Handle empty results
if users == nil {
users = []UserListItem{}
}
// Calculate total pages
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
response := UserListResponse{
Users: users,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode users response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
+6
View File
@@ -132,6 +132,12 @@ func main() {
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)
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
})
r.Route("/admin/users", func(r chi.Router) {
r.Get("/", user.ListAdminUsersHandler)
r.Get("/{id}", user.GetAdminUserHandler)
}) })
// --- Admin Notifications --- // --- Admin Notifications ---
@@ -0,0 +1,437 @@
<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';
interface Props {
open: boolean;
bookingId: string;
}
let { open = $bindable(), bookingId }: Props = $props();
type Booking = {
id: string;
start_time: string;
status:
| 'pending'
| 'confirmed'
| 'in_progress'
| 'completed'
| 'client_cancelled'
| 'we_cancelled'
| 're-schedule'
| 'no_show';
notes?: string;
created_at: string;
updated_at: string;
created_by?: string;
user?: {
id: string;
full_name: string;
email?: string;
phone?: string;
profile_pic_url?: string;
date_of_birth?: string;
account_role: string;
loyalty_stamps?: number;
referral_code?: string;
referral_code_uses?: number;
created_at: string;
notes?: string;
};
services: Array<{
booking_id: string;
service_id: string;
override_price?: number;
override_duration_minutes?: number;
service_name?: string;
service_description?: string;
price?: number;
duration_minutes?: number;
}>;
payments: Array<{
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
amount: number;
is_vat_applicable: boolean;
vat_rate?: number;
vat_amount?: number;
net_amount?: number;
created_at: string;
updated_at: string;
created_by?: string;
}>;
total_amount: number;
amount_paid: number;
amount_due: number;
duration_minutes: number;
};
let selectedBooking = $state<Booking | null>(null);
async function fetchBookingDetails() {
if (!bookingId) return;
try {
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
selectedBooking = {
id: data.id,
start_time: data.start_time,
status: data.status,
notes: data.notes,
user: data.user
? {
id: data.user.id,
full_name: data.user.full_name,
email: data.user.email,
phone: data.user.phone,
profile_pic_url: data.user.profile_pic_url,
date_of_birth: data.user.date_of_birth,
account_role: data.user.account_role,
loyalty_stamps: data.user.loyalty_stamps,
referral_code: data.user.referral_code,
referral_code_uses: data.user.referral_code_uses,
created_at: data.user.created_at,
notes: data.user.notes
}
: undefined,
services: (data.services || []).map((s) => ({
booking_id: s.booking_id,
service_id: s.service_id,
service_name: s.service_name,
service_description: s.service_description,
price: s.price,
duration_minutes: s.duration_minutes
})),
payments: (data.payments || []).map((p) => ({
id: p.id,
booking_id: p.booking_id,
payment_type: p.payment_type,
payment_method: p.payment_method,
vendor_code: p.vendor_code,
invoice_number: p.invoice_number,
status: p.status,
amount: p.amount,
is_vat_applicable: p.is_vat_applicable,
vat_rate: p.vat_rate,
vat_amount: p.vat_amount,
net_amount: p.net_amount,
created_at: p.created_at,
updated_at: p.updated_at,
created_by: p.created_by
})),
total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by
};
} else {
const text = await response.text();
toast.error('Failed to load booking details: ' + text);
}
} catch (err) {
console.error('Error fetching booking details:', err);
toast.error('Network error loading booking details');
}
}
$effect(() => {
if (open && bookingId) {
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}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: selectedBooking.status === 'confirmed'
? 'bg-emerald-100 text-emerald-800'
: selectedBooking.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: selectedBooking.status === 'completed'
? 'bg-green-100 text-green-800'
: selectedBooking.status === 'client_cancelled'
? 'bg-red-100 text-red-800'
: selectedBooking.status === 'we_cancelled'
? 'bg-rose-100 text-rose-800'
: selectedBooking.status === 're-schedule'
? 'bg-purple-100 text-purple-800'
: selectedBooking.status === 'no_show'
? 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-800'}"
>
{selectedBooking.status.charAt(0).toUpperCase() + selectedBooking.status.slice(1)}
</span>
{/if}
</div>
</Modal.Header>
{#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">{selectedBooking.duration_minutes} minutes</div>
</div>
<div>
<div class="text-xs text-gray-500">Created</div>
<div class="text-sm">
{new SvelteDate(selectedBooking.created_at).toLocaleString()}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Last Updated</div>
<div class="text-sm">
{new SvelteDate(selectedBooking.updated_at).toLocaleString()}
</div>
</div>
{#if selectedBooking.created_by}
<div class="md:col-span-2">
<div class="text-xs text-gray-500">Created By</div>
<div class="text-sm">{selectedBooking.created_by}</div>
</div>
{/if}
</div>
{#if selectedBooking.notes}
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="mb-1 text-xs font-semibold text-amber-800">Booking Notes</div>
<div class="text-sm text-amber-900">{selectedBooking.notes}</div>
</div>
{/if}
</div>
<!-- Customer Information -->
<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">
Customer Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{selectedBooking.user?.full_name || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{selectedBooking.user?.email || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{selectedBooking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Customer ID</div>
<div class="font-medium">{selectedBooking.user?.id || '—'}</div>
</div>
{#if selectedBooking.user?.loyalty_stamps !== undefined && selectedBooking.user?.loyalty_stamps !== null}
<div>
<div class="text-xs text-gray-500">Loyalty Stamps</div>
<div class="font-medium">{selectedBooking.user.loyalty_stamps}</div>
</div>
{/if}
{#if selectedBooking.user?.referral_code}
<div>
<div class="text-xs text-gray-500">Referral Code</div>
<div class="font-medium">{selectedBooking.user.referral_code}</div>
</div>
{/if}
{#if selectedBooking.user?.referral_code_uses !== undefined && selectedBooking.user?.referral_code_uses !== null}
<div>
<div class="text-xs text-gray-500">Referral Uses</div>
<div class="font-medium">{selectedBooking.user.referral_code_uses}</div>
</div>
{/if}
</div>
{#if selectedBooking.user?.notes}
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
<div class="mb-1 text-xs font-semibold text-blue-800">Customer Notes</div>
<div class="text-sm text-blue-900">{selectedBooking.user.notes}</div>
</div>
{/if}
</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'
: payment.status === 'failed'
? 'bg-red-100 text-red-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.vendor_code || payment.invoice_number}
<div class="mt-1 text-xs text-gray-500">
{#if payment.vendor_code}Vendor: {payment.vendor_code}{/if}
{#if payment.vendor_code && payment.invoice_number}
{/if}
{#if payment.invoice_number}Invoice: #{payment.invoice_number}{/if}
</div>
{/if}
{#if payment.is_vat_applicable}
<div class="mt-2 text-xs text-gray-600">
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
<div>
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed(
2
) || '0.00'}
</div>
</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>
@@ -0,0 +1,351 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Skeleton } from '$lib/components/ui/skeleton';
// Props
let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props();
// Types
type Booking = {
id: string;
start_time: string;
status:
| 'pending'
| 'confirmed'
| 'in_progress'
| 'completed'
| 'client_cancelled'
| 'we_cancelled'
| 're-schedule'
| 'no_show';
notes?: string;
created_at: string;
updated_at: string;
created_by?: string;
user?: {
id: string;
full_name: string;
};
services: Array<{
booking_id: string;
service_id: string;
override_price?: number;
override_duration_minutes?: number;
service_name?: string;
service_description?: string;
price?: number;
duration_minutes?: number;
}>;
payments: Array<{
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
amount: number;
is_vat_applicable: boolean;
vat_rate?: number;
vat_amount?: number;
net_amount?: number;
created_at: string;
updated_at: string;
created_by?: string;
}>;
total_amount: number;
amount_paid: number;
amount_due: number;
duration_minutes: number;
};
// State
let bookings = $state<Booking[]>([]);
let bookingQuery = $state('');
let loadingSearch = $state(false);
// Fetch bookings from API
async function fetchBookings() {
loadingSearch = true;
try {
const response = await fetch('/api/admin/bookings', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data.bookings && data.bookings.length === 0) {
bookings = [];
return;
}
bookings = data.bookings.map((b: any) => ({
id: b.id,
start_time: b.start_time,
status: b.status,
notes: b.notes,
created_at: b.created_at,
updated_at: b.updated_at,
created_by: b.created_by,
user: b.user
? {
id: b.user.id,
full_name: b.user.full_name
}
: undefined,
services: b.services || [],
total_amount: b.total_amount || 0,
amount_paid: b.amount_paid || 0,
amount_due: b.amount_due || 0,
duration_minutes: b.duration_minutes || 0
}));
} else {
const text = await response.text();
toast.error('Failed to load bookings: ' + text);
}
} catch (err) {
console.error('Error fetching bookings:', err);
toast.error('Network error loading bookings');
} finally {
loadingSearch = false;
}
}
// Search bookings via API
async function searchBookings() {
loadingSearch = true;
if (!bookingQuery.trim()) {
await fetchBookings();
loadingSearch = false;
return;
}
try {
const response = await fetch(
`/api/admin/bookings/search?q=${encodeURIComponent(bookingQuery)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
const data = await response.json();
bookings = data.bookings.map((b: any) => ({
id: b.id,
start_time: b.start_time,
status: b.status,
notes: b.notes,
created_at: b.created_at,
updated_at: b.updated_at,
created_by: b.created_by,
user: b.user
? {
id: b.user.id,
full_name: b.user.full_name
}
: undefined,
services: b.services || [],
total_amount: b.total_amount || 0,
amount_paid: b.amount_paid || 0,
amount_due: b.amount_due || 0,
duration_minutes: b.duration_minutes || 0
}));
} else {
const text = await response.text();
toast.error('Failed to search bookings: ' + text);
}
} catch (err) {
console.error('Error searching bookings:', err);
toast.error('Network error searching bookings');
} finally {
loadingSearch = false;
}
}
// Format booking date/time
function formatBookingDateTime(startTime: string): string {
const date = new SvelteDate(startTime);
const now = new SvelteDate();
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const bookingDate = new SvelteDate(date.getFullYear(), date.getMonth(), date.getDate());
const daysDiff = Math.floor((bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'June',
'July',
'Aug',
'Sept',
'Oct',
'Nov',
'Dec'
];
const day = days[date.getDay()];
const dateNum = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
const currentYear = now.getFullYear();
const hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
const hour12 = hours % 12 || 12;
const time = `${hour12}:${minutes}${ampm}`;
if (daysDiff === 0) return `Today, ${time}`;
if (daysDiff === 1) return `Tomorrow, ${time}`;
if (daysDiff > 1 && daysDiff <= 6) return `${day}, ${time}`;
if (daysDiff < 0 && daysDiff >= -6) return `Last ${day}, ${time}`;
const suffix =
dateNum === 1 || dateNum === 21 || dateNum === 31
? 'st'
: dateNum === 2 || dateNum === 22
? 'nd'
: dateNum === 3 || dateNum === 23
? 'rd'
: 'th';
const yearStr = year !== currentYear ? ` ${year}` : '';
return `${day}, ${dateNum}${suffix} ${month}${yearStr} at ${time}`;
}
// Format services list
function formatServices(services: Booking['services']): string {
const serviceNames = services.map((s) => s.service_name || 'Unknown Service');
if (serviceNames.length === 0) return 'No services';
if (serviceNames.length === 1) return serviceNames[0];
if (serviceNames.length === 2) return serviceNames.join(' and ');
return `${serviceNames[0]} and ${serviceNames.length - 1} other${serviceNames.length - 1 > 1 ? 's' : ''}`;
}
// Get status badge classes
function getStatusClasses(status: Booking['status']): string {
const baseClasses = 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium';
const statusMap = {
confirmed: 'bg-emerald-100 text-emerald-800',
pending: 'bg-amber-100 text-amber-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
client_cancelled: 'bg-red-100 text-red-800',
we_cancelled: 'bg-rose-100 text-rose-800',
're-schedule': 'bg-purple-100 text-purple-800',
no_show: 'bg-gray-100 text-gray-800'
};
return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
}
function getStatusDotClasses(status: Booking['status']): string {
const statusMap = {
confirmed: 'bg-emerald-600',
pending: 'bg-amber-600',
in_progress: 'bg-blue-600',
completed: 'bg-green-600',
client_cancelled: 'bg-red-600',
we_cancelled: 'bg-rose-600',
're-schedule': 'bg-purple-600',
no_show: 'bg-gray-600'
};
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
}
// Fetch bookings on mount
$effect(() => {
fetchBookings();
});
</script>
<Card.Root class="h-full">
<Card.Header>
<div class="flex items-start justify-between">
<div>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Bookings
</Card.Title>
<Card.Description>Search and manage booking history.</Card.Description>
</div>
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
<span class="text-xs font-semibold">{bookings.length}</span>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div>
<div class="flex gap-2">
<Input
placeholder="Search by customer name, email, phone, or service"
bind:value={bookingQuery}
onkeyup={(e) => {
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
}}
/>
<Button onclick={searchBookings} disabled={loadingSearch}>
{loadingSearch ? 'Searching...' : 'Search'}
</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#if loadingSearch}
<div class="flex items-center justify-center p-4">
<Skeleton class="h-4 w-32" />
</div>
{:else if bookings.length === 0}
<div class="text-center text-sm text-gray-500">No bookings found.</div>
{:else}
{#each bookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">
{formatBookingDateTime(b.start_time)}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,678 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
// =============== Types ===============
type WorkingHourRow = {
weekday: number;
start_time: string;
end_time: string;
is_open: boolean;
};
type ExceptionGroup = {
id?: number;
name: string;
description: string;
weekStarts: string[];
hours: WorkingHourRow[];
};
// =============== State ===============
let exceptionGroups = $state<ExceptionGroup[]>([]);
let exceptionGroupsLoading = $state(true);
let savingHours = $state(false);
// Exception modal state
let showExceptionModal = $state(false);
let exceptionDraft = $state<ExceptionGroup>({
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
});
let weekRangeFrom = $state('');
let weekRangeTo = $state('');
// Delete confirmation state
let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined);
// View exception state
let showViewExceptionModal = $state(false);
let viewingException = $state<ExceptionGroup | null>(null);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// =============== Helper Functions ===============
function weekdayLabel(i: number) {
return dayNames[i];
}
function isoDateOf(d: Date) {
return d.toISOString().slice(0, 10);
}
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new SvelteDate(fromISO + 'T00:00:00');
const to = new SvelteDate(toISO + 'T00:00:00');
const first = new SvelteDate(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
// Set to the Monday of the current week
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new SvelteDate(d)));
}
}
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
function formatTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
// Special case for 12:00
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}`;
}
// =============== API Functions ===============
async function fetchExceptionGroups() {
exceptionGroupsLoading = true;
try {
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
if (data === null || data.length === 0) {
return;
}
exceptionGroups = data.map((group) => ({
id: group.id,
name: group.name,
description: group.description,
weekStarts: group.weekStarts || [],
hours:
group.hours?.map((h) => ({
id: h.id,
weekday: h.weekday,
start_time: formatTime(h.startTime),
end_time: formatTime(h.endTime),
is_open: h.isOpen
})) || []
}));
} else {
console.error('Failed to fetch exception groups:', response.status);
toast.error('Failed to load exception groups');
}
} catch (err) {
console.error('Error fetching exception groups:', err);
toast.error('Network error loading exception groups');
} finally {
exceptionGroupsLoading = false;
}
}
async function saveExceptionGroup() {
// Validate
if (!exceptionDraft.name.trim()) {
toast.error('Please enter a group name');
return;
}
if (exceptionDraft.weekStarts.length === 0) {
toast.error('Please add at least one week');
return;
}
savingHours = true;
const loadingToast = toast.loading('Creating exception group...');
try {
// Map to API format
const payload = {
name: exceptionDraft.name,
description: exceptionDraft.description,
weekStarts: exceptionDraft.weekStarts,
hours: exceptionDraft.hours.map((h) => ({
weekday: h.weekday,
startTime: h.start_time,
endTime: h.end_time,
isOpen: h.is_open
}))
};
const response = await fetch('/api/scheduling/exceptional-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Exception group created successfully!', { id: loadingToast });
showExceptionModal = false;
resetExceptionForm();
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to create: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error creating exception group:', err);
toast.error('Network error creating exception group', { id: loadingToast });
} finally {
savingHours = false;
}
}
async function confirmDeleteExceptionGroup() {
if (exceptionToDelete === undefined) return;
const loadingToast = toast.loading('Deleting exception group...');
try {
const response = await fetch(`/api/scheduling/exceptional-groups?id=${exceptionToDelete}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok || response.status === 204) {
toast.success('Exception group deleted successfully!', { id: loadingToast });
showDeleteExceptionAlert = false;
exceptionToDelete = undefined;
// Refresh the exception groups list
await fetchExceptionGroups();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting exception group:', err);
toast.error('Network error deleting exception group', { id: loadingToast });
}
}
// =============== UI Actions ===============
function resetExceptionForm() {
exceptionDraft = {
name: '',
description: '',
weekStarts: [],
hours: [
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
]
};
weekRangeFrom = '';
weekRangeTo = '';
}
function createNewException() {
resetExceptionForm();
showExceptionModal = true;
}
function addWeekRange() {
if (!weekRangeFrom || !weekRangeTo) {
toast.error('Please select both start and end dates');
return;
}
addWeeksToException(weekRangeFrom, weekRangeTo, exceptionDraft.weekStarts);
weekRangeFrom = '';
weekRangeTo = '';
}
function removeWeek(index: number) {
exceptionDraft.weekStarts = exceptionDraft.weekStarts.filter((_, i) => i !== index);
}
function openViewExceptionModal(exception: ExceptionGroup) {
viewingException = exception;
showViewExceptionModal = true;
}
// =============== Effects ===============
$effect(() => {
fetchExceptionGroups();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Holiday Hours</Card.Title>
<Card.Description>
Manage temporary schedules for holidays, closures, and special events.
</Card.Description>
</div>
<Button variant="default" onclick={createNewException}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Schedule
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if exceptionGroupsLoading}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(2) as _, i (i)}
<Skeleton class="h-32 w-full" />
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#if exceptionGroups.length === 0}
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
{/if}
{#each exceptionGroups as g (g.weekStarts)}
<div class="group relative h-full rounded-lg border p-4 transition-all">
<div class="flex h-full flex-col gap-3">
<div class="flex-1">
<div class="mb-2 flex items-start justify-between">
<div class="flex items-center gap-2">
<div class="rounded-lg bg-gray-50 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<h3 class="font-semibold text-gray-900">{g.name}</h3>
</div>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
{g.weekStarts?.length || 0} weeks
</span>
</div>
<p class="mb-3 text-sm text-gray-600">{g.description}</p>
<div class="rounded-lg bg-gray-50 p-2">
<div class="mb-1 text-xs font-medium text-gray-500">Applies to weeks:</div>
<div class="text-xs text-gray-700">
{g.weekStarts
?.slice(0, 3)
.map((w) =>
new SvelteDate(w).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
})
)
.join(', ')}
{#if (g.weekStarts?.length ?? 0) > 3}
<span class="text-gray-500"> (+{(g.weekStarts?.length ?? 0) - 3} more)</span>
{/if}
</div>
</div>
</div>
<div class="flex gap-2 border-t pt-2">
<Button
variant="outline"
size="sm"
onclick={() => openViewExceptionModal(g)}
class="flex-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
View Details
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {
exceptionToDelete = g.id;
showDeleteExceptionAlert = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Exception Group Modal -->
<Modal.Root bind:open={showExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Create Exception Schedule</Modal.Title>
<Modal.Description>
Define custom working hours for holidays, closures, or special events.
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Basic Info -->
<div class="space-y-4">
<div class="space-y-2">
<label for="exception-name" class="text-sm font-medium">Schedule Name *</label>
<Input
id="exception-name"
type="text"
placeholder="e.g., Christmas Week, Summer Holiday"
bind:value={exceptionDraft.name}
/>
</div>
<div class="space-y-2">
<label for="exception-description" class="text-sm font-medium">Description</label>
<Input
id="exception-description"
type="text"
placeholder="Brief description of the service, will be shown to customers"
bind:value={exceptionDraft.description}
/>
</div>
</div>
<Separator />
<!-- Week Selection -->
<div class="space-y-4">
<div>
<h3 class="mb-2 text-sm font-medium">Apply to Weeks *</h3>
<p class="mb-3 text-xs text-gray-500">
Select a date range to add all Mondays within that range
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-2">
<label for="week-from" class="text-xs text-gray-600">From Date</label>
<Input id="week-from" type="date" bind:value={weekRangeFrom} />
</div>
<div class="space-y-2">
<label for="week-to" class="text-xs text-gray-600">To Date</label>
<Input id="week-to" type="date" bind:value={weekRangeTo} />
</div>
</div>
<Button variant="outline" size="sm" onclick={addWeekRange} class="mt-3">
Add Week Range
</Button>
</div>
{#if exceptionDraft.weekStarts.length > 0}
<div class="space-y-2">
<div class="text-xs text-gray-600">
Selected weeks ({exceptionDraft.weekStarts.length}):
</div>
<div class="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{#each exceptionDraft.weekStarts as week, index (week)}
<div class="flex items-center justify-between text-sm">
<span>Week starting: {week}</span>
<button
class="text-xs text-red-500 hover:text-red-700"
onclick={() => removeWeek(index)}
>
Remove
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours for these Weeks *</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto text-sm">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Open</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each exceptionDraft.hours as row (row.weekday)}
<tr class="border-t">
<td class="py-2">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<input
type="checkbox"
bind:checked={row.is_open}
class="h-4 w-4 rounded border-gray-300 bg-gray-100"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.start_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="w-24 text-sm"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showExceptionModal = false;
resetExceptionForm();
}}
disabled={savingHours}
>
Cancel
</Button>
<Button onclick={saveExceptionGroup} disabled={savingHours}>
{savingHours ? 'Creating…' : 'Create Schedule'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Delete Exception Confirmation -->
<AlertDialog.Root bind:open={showDeleteExceptionAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Delete exception group?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete this exception group and all its
associated schedule rows.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
exceptionToDelete = undefined;
}}
>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDeleteExceptionGroup}>Delete</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- View Exception Modal -->
{#if viewingException}
<Modal.Root bind:open={showViewExceptionModal}>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">{viewingException.name}</Modal.Title>
<Modal.Description>
{viewingException.description || 'Holiday schedule details'}
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Applied Weeks -->
<div class="space-y-2">
<h3 class="text-sm font-medium">Applied to Weeks</h3>
<div class="max-h-48 space-y-1 overflow-y-auto rounded border bg-gray-50 p-3">
{#if viewingException.weekStarts && viewingException.weekStarts.length > 0}
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
{#each viewingException.weekStarts as week (week)}
<div class="text-sm">
Week of {new SvelteDate(week).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-gray-500">No weeks specified</p>
{/if}
</div>
</div>
<Separator />
<!-- Working Hours -->
<div class="space-y-4">
<h3 class="text-sm font-medium">Working Hours</h3>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Status</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each viewingException.hours as row (row.weekday)}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<span
class="text-sm font-medium {row.is_open
? 'text-emerald-600'
: 'text-red-600'}"
>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="py-2 text-sm">
{row.is_open ? row.start_time : '—'}
</td>
<td class="py-2 text-sm">
{row.is_open ? row.end_time : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (showViewExceptionModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{/if}
@@ -0,0 +1,392 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
// =============== Image Upload ===============
let uploading = $state(false);
let uploadFiles = $state<File[]>([]);
let uploadProgress = $state(0);
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
function handleFilesDropped(files: File[]) {
uploadFiles = files;
}
/** Helper: turn any File into a JPEG-encoded Blob. */
function toJpegBlob(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file);
});
}
/** Resize to max 1500px on the *short* side, only scale down, never up. */
function resizeShortSide(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
const maxShortSide = 1500;
// Only resize if image is larger than target
const shortSide = Math.min(width, height);
if (shortSide > maxShortSide) {
if (width < height) {
const scale = maxShortSide / width;
width = maxShortSide;
height = Math.round(height * scale);
} else {
const scale = maxShortSide / height;
height = maxShortSide;
width = Math.round(width * scale);
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
/** Create a 250×250 thumbnail (square, center-cropped). */
function createThumbnail(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const thumbSize = 250;
const { width, height } = img;
// Scale up *or* down so that the image covers 250×250
const scale = Math.max(thumbSize / width, thumbSize / height);
const scaledW = Math.round(width * scale);
const scaledH = Math.round(height * scale);
const canvas = document.createElement('canvas');
canvas.width = thumbSize;
canvas.height = thumbSize;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('2D context not available'));
// Draw the scaled image, then crop the center 250×250
ctx.drawImage(
img,
(scaledW - thumbSize) / -2, // offset to center
(scaledH - thumbSize) / -2,
scaledW,
scaledH,
0,
0,
thumbSize,
thumbSize
);
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
},
'image/jpeg',
0.92
);
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(blob);
});
}
const knownTags = [
'portfolio',
'gel',
'acrylic',
'french',
'ombre',
'summer',
'wedding',
'holiday',
'pink',
'red',
'style:french',
'style:minimal',
'colour:red',
'colour:pink',
'season:summer'
];
let tags = $state<string[]>([]);
let input = $state('');
const suggestions = $derived.by(() => {
const q = input.trim().toLowerCase();
if (!q) return [];
return knownTags
.map((t) => t.toLowerCase())
.filter((t) => t.startsWith(q) && !tags.includes(t))
.slice(0, 6);
});
function handleTagInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
if (value.includes(',')) {
addTag(value);
input = '';
}
}
function isSemantic(tag: string) {
return tag.includes(':');
}
function addTag(raw: string) {
raw.split(',').forEach((p) => {
const t = p.trim().toLowerCase();
if (t && !tags.includes(t)) tags = [...tags, t];
});
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag(input);
input = '';
return;
}
if (e.key === 'Backspace' && !input && tags.length) {
tags = tags.slice(0, -1);
}
}
function selectSuggestion(tag: string) {
addTag(tag);
input = '';
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
/** Core upload function now processes the images before sending. */
async function uploadOneOrMany() {
if (!uploadFiles.length) return;
uploading = true;
uploadResults = [];
uploadProgress = 0;
const startTs = Date.now(); // timestamp of button click
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
const fd = new FormData();
try {
/* -------- 1. Turn whatever the user gave us into JPEG ------- */
const jpegBlob = await toJpegBlob(file);
/* -------- 2. Create the two processed versions ------------- */
const resizedBlob = await resizeShortSide(jpegBlob);
const thumbBlob = await createThumbnail(jpegBlob);
/* -------- 3. Generate filenames -------------------------------- */
const ts = startTs - i; // 1 ms decrement per file
const baseName = `${ts}.jpg`;
const thumbName = `${ts}_thumb.jpg`;
/* -------- 4. Attach to FormData -------------------------------- */
fd.append('file', resizedBlob, baseName); // this will be the "original"
fd.append('file', thumbBlob, thumbName); // the thumbnail
/* -------- 5. Mock the API call --------------------------------- */
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
if (file.name.toLowerCase().includes('fail')) {
uploadResults.push({
name: file.name,
error: 'Mocked API error'
});
} else {
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
uploadResults.push({
name: file.name,
url: `/images/${baseName}` // pretend this is the returned URL
});
}
} catch (err: unknown) {
uploadResults.push({
name: file.name,
error: err instanceof Error ? err.message : 'Unknown error'
});
}
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
}
uploading = false;
uploadFiles = [];
}
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Image Upload</Card.Title>
<Card.Description>Upload images for the portfolio or other uses.</Card.Description>
</div>
<div class="hidden rounded-lg p-2 md:block">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
<div class="p-6 text-center">
<p class="text-sm text-gray-500">Drop files here, or click to open the file picker</p>
</div>
</FileDropZone>
<div class="mt-4">
<div class="text-sm text-gray-600">Selected files ({uploadFiles.length})</div>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each uploadFiles as f (f.name)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>{f.name}{Math.round(f.size / 1024)}KB</div>
<button
class="text-red-500"
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
>
Remove
</button>
</div>
{/each}
</div>
{#if uploadResults.length > 0}
<div class="mt-4 text-sm text-gray-600">Upload Results</div>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each uploadResults as result (result.url || result.name)}
<div
class="rounded p-2 text-xs {result.error
? 'bg-red-100 text-red-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`}
</div>
{/each}
</div>
{/if}
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700">Tags</label>
<div class="relative">
<div
class="flex min-h-[38px] w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
>
{#each tags as tag (tag)}
<span
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
>
{tag}
<button
type="button"
class="ml-1 leading-none
{isSemantic(tag)
? 'text-indigo-700 hover:text-indigo-900'
: 'text-emerald-700 hover:text-emerald-900'}"
onmousedown={(e) => {
e.preventDefault();
removeTag(tag);
}}
aria-label={`Remove ${tag}`}
>
×
</button>
</span>
{/each}
<input
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
bind:value={input}
onkeydown={handleKey}
oninput={handleTagInput}
placeholder={tags.length ? '' : 'Add tags…'}
/>
</div>
{#if input.length && suggestions.length}
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow">
{#each suggestions as s (s)}
<div
class="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
onmousedown={(e) => {
e.preventDefault();
selectSuggestion(s);
}}
>
{s}
</div>
{/each}
</div>
{/if}
</div>
<p class="text-xs text-gray-500">
Add searchable tags here such as <code>`scooby doo`</code> or filterable categories like
<code>`style:french`</code>
or
<code>`colour:green`</code>
</p>
</div>
<div class="flex items-center justify-end gap-2">
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload Selected Files'}
</Button>
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,679 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
// =============== Types ===============
type Service = {
id: string;
name: string;
description: string;
price: number;
duration_minutes: number;
is_active: boolean;
patch_test_duration_hours: number;
minimum_age_required: number;
created_at: string;
updated_at?: string;
created_by?: string;
updated_by?: string;
};
// =============== State ===============
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
let servicesUpdating = $state<Record<string, boolean>>({});
// Service Creation State
let showServiceModal = $state(false);
let creatingService = $state(false);
let newService = $state({
name: '',
description: '',
price: '',
duration_minutes: 60,
patch_test_duration_hours: 0,
minimum_age_required: 0
});
let serviceErrors = $state({
name: '',
price: '',
duration_minutes: '',
patch_test_duration_hours: '',
minimum_age_required: ''
});
// =============== Validation ===============
let isFormValid = $derived(
newService.name.trim() !== '' &&
/^\d+(\.\d{1,2})?$/.test(newService.price) &&
parseFloat(newService.price) > 0 &&
Number.isInteger(newService.duration_minutes) &&
newService.duration_minutes > 0 &&
Number.isInteger(newService.patch_test_duration_hours) &&
newService.patch_test_duration_hours >= 0 &&
Number.isInteger(newService.minimum_age_required) &&
newService.minimum_age_required >= 0 &&
newService.minimum_age_required <= 100
);
function validatePrice(price: string): string {
const validFormat = /^\d*\.?\d*$/.test(price);
if (!validFormat) {
return 'Price must be a valid number (e.g., 4.50)';
}
const numPrice = parseFloat(price);
if (isNaN(numPrice)) {
return 'Price must be a valid number';
}
if (numPrice <= 0) {
return 'Price must be greater than 0';
}
const decimalRegex = /^\d+(\.\d{1,2})?$/;
if (!decimalRegex.test(price)) {
return 'Price can have up to 2 decimal places';
}
return '';
}
function validateDuration(value: number, field: string): string {
if (isNaN(value)) {
return 'Must be a valid number';
}
if (!Number.isInteger(value)) {
return 'Must be a whole number';
}
if (field === 'duration_minutes' && value <= 0) {
return 'Duration must be greater than 0';
}
if (field === 'patch_test_duration_hours' && value < 0) {
return 'Cannot be negative';
}
if (field === 'minimum_age_required' && (value < 0 || value > 100)) {
return 'Must be between 0 and 100';
}
return '';
}
function validateName(name: string): string {
if (!name.trim()) {
return 'Service name is required';
}
return '';
}
function updateAllErrors() {
serviceErrors = {
name: validateName(newService.name),
price: validatePrice(newService.price),
duration_minutes: validateDuration(newService.duration_minutes, 'duration_minutes'),
patch_test_duration_hours: validateDuration(
newService.patch_test_duration_hours,
'patch_test_duration_hours'
),
minimum_age_required: validateDuration(
newService.minimum_age_required,
'minimum_age_required'
)
};
}
function validateNameField() {
serviceErrors.name = validateName(newService.name);
}
function validatePriceField() {
serviceErrors.price = validatePrice(newService.price);
}
function validateDurationField() {
serviceErrors.duration_minutes = validateDuration(
newService.duration_minutes,
'duration_minutes'
);
}
function validatePatchTestField() {
serviceErrors.patch_test_duration_hours = validateDuration(
newService.patch_test_duration_hours,
'patch_test_duration_hours'
);
}
function validateMinimumAgeField() {
serviceErrors.minimum_age_required = validateDuration(
newService.minimum_age_required,
'minimum_age_required'
);
}
// =============== API Functions ===============
async function fetchServices() {
servicesLoading = true;
try {
const response = await fetch('/api/admin/services', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
services = data.filter((s: Service) => s.id);
if (data.length !== services.length) {
console.warn('Some services missing IDs were filtered out');
}
} 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;
}
}
async function toggleService(serviceId: string) {
servicesUpdating[serviceId] = true;
try {
const response = await fetch(`/api/admin/services/${serviceId}/toggle`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
toast.success('Service status updated');
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to update service: ${errorText}`);
}
} catch (err) {
console.error('Error toggling service:', err);
toast.error('Network error updating service');
} finally {
servicesUpdating[serviceId] = false;
}
}
async function deleteService(serviceId: string) {
if (!confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
return;
}
servicesUpdating[serviceId] = true;
try {
const response = await fetch(`/api/admin/services/${serviceId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
toast.success('Service deleted successfully');
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to delete service: ${errorText}`);
}
} catch (err) {
console.error('Error deleting service:', err);
toast.error('Network error deleting service');
} finally {
servicesUpdating[serviceId] = false;
}
}
async function createService() {
updateAllErrors();
const hasErrors = Object.values(serviceErrors).some((error) => error !== '');
if (hasErrors) {
toast.error('Please fix the validation errors before submitting');
return;
}
if (!isFormValid) {
toast.error('Form validation failed');
return;
}
creatingService = true;
const loadingToast = toast.loading('Creating service...');
try {
const payload = {
name: newService.name.trim(),
description: newService.description.trim() || undefined,
price: parseFloat(newService.price),
duration_minutes: newService.duration_minutes,
patch_test_duration_hours: newService.patch_test_duration_hours,
minimum_age_required: newService.minimum_age_required
};
const response = await fetch('/api/admin/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
await response.json();
toast.success('Service created successfully!', { id: loadingToast });
resetServiceForm();
showServiceModal = false;
await fetchServices();
} else if (response.status === 409) {
toast.error('A service with this name already exists', { id: loadingToast });
} else if (response.status === 400) {
const errorText = await response.text();
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
} else {
const errorText = await response.text();
toast.error(`Failed to create service: ${errorText}`, { id: loadingToast });
}
} catch (err) {
console.error('Error creating service:', err);
toast.error('Network error creating service', { id: loadingToast });
} finally {
creatingService = false;
}
}
function resetServiceForm() {
newService = {
name: '',
description: '',
price: '',
duration_minutes: 60,
patch_test_duration_hours: 0,
minimum_age_required: 0
};
serviceErrors = {
name: '',
price: '',
duration_minutes: '',
patch_test_duration_hours: '',
minimum_age_required: ''
};
}
function openServiceModal() {
resetServiceForm();
showServiceModal = true;
}
// =============== Lifecycle ===============
$effect(() => {
fetchServices();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Services Management</Card.Title>
<Card.Description>
Manage your services - add, edit, toggle availability, or delete services.
</Card.Description>
</div>
<Button onclick={openServiceModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Service
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Desktop Table -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="w-[20%] py-3 font-medium">Name</th>
<th class="w-[30%] py-3 font-medium">Description</th>
<th class="w-[10%] py-3 text-right font-medium">Price</th>
<th class="w-[12%] py-3 text-right font-medium">Duration</th>
<th class="w-[12%] py-3 text-center font-medium">Status</th>
<th class="w-[16%] py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if servicesLoading}
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-16" /></td>
<td class="py-3 text-center">
<div class="flex justify-center gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
{:else}
{#each services as service (service.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium">{service.name}</td>
<td class="py-3 text-gray-600">
{#if service.description}
<div class="line-clamp-2" title={service.description}>
{service.description}
</div>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
<td class="py-3 text-right">{service.duration_minutes} min</td>
<td class="py-3 text-center">
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
? 'bg-emerald-100 text-emerald-800'
: 'bg-red-100 text-red-800'}"
>
{service.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => toggleService(service.id)}
disabled={servicesUpdating[service.id]}
>
{servicesUpdating[service.id]
? '...'
: service.is_active
? 'Deactivate'
: 'Activate'}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
disabled={servicesUpdating[service.id]}
>
Delete
</Button>
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="space-y-4 md:hidden">
{#if servicesLoading}
{#each Array(3) as _, i (i)}
<div class="rounded-lg border p-4">
<div class="space-y-3">
<Skeleton class="h-5 w-32" />
<Skeleton class="h-4 w-48" />
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-20" />
</div>
<div class="flex gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</div>
</div>
{/each}
{:else}
{#each services as service (service.id)}
<div class="rounded-lg border p-4 hover:bg-gray-50">
<div class="space-y-3">
<div class="flex items-start justify-between">
<h3 class="font-medium">{service.name}</h3>
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
? 'bg-emerald-100 text-emerald-800'
: 'bg-red-100 text-red-800'}"
>
{service.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{#if service.description}
<p class="text-sm text-gray-600">{service.description}</p>
{/if}
<div class="flex justify-between text-sm">
<div>
<span class="font-medium">Price:</span> £{service.price.toFixed(2)}
</div>
<div>
<span class="font-medium">Duration:</span>
{service.duration_minutes} min
</div>
</div>
<div class="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onclick={() => toggleService(service.id)}
disabled={servicesUpdating[service.id]}
class="flex-1"
>
{servicesUpdating[service.id]
? '...'
: service.is_active
? 'Deactivate'
: 'Activate'}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
disabled={servicesUpdating[service.id]}
class="flex-1"
>
Delete
</Button>
</div>
</div>
</div>
{/each}
{/if}
</div>
{#if !servicesLoading && services.length === 0}
<div class="py-8 text-center text-gray-500">
No services found. Click "Add Service" to create your first service.
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Add Service Modal -->
<Modal.Root bind:open={showServiceModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add New Service</Modal.Title>
<Modal.Description>Create a new service that customers can book.</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<!-- Service Name -->
<div class="space-y-2">
<label for="service-name" class="text-sm font-medium">Service Name *</label>
<Input
id="service-name"
type="text"
placeholder="e.g., Haircut, Color, Blowdry"
bind:value={newService.name}
onblur={validateNameField}
class="w-full border-red-500={serviceErrors.name}"
/>
{#if serviceErrors.name}
<p class="text-sm text-red-600">{serviceErrors.name}</p>
{/if}
</div>
<!-- Description -->
<div class="space-y-2">
<label for="service-description" class="text-sm font-medium">Description</label>
<Input
id="service-description"
type="text"
placeholder="Brief description of the service, will be shown to customers"
bind:value={newService.description}
class="w-full"
/>
</div>
<!-- Price and Duration - Side by side on desktop -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Price -->
<div class="space-y-2">
<label for="service-price" class="text-sm font-medium">Price (£) *</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-sm text-gray-500">£</span>
<Input
id="service-price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
bind:value={newService.price}
onblur={validatePriceField}
class="w-full pl-8 border-red-500={serviceErrors.price}"
/>
</div>
{#if serviceErrors.price}
<p class="text-sm text-red-600">{serviceErrors.price}</p>
{/if}
</div>
<!-- Duration -->
<div class="space-y-2">
<label for="service-duration" class="text-sm font-medium">Duration (minutes) *</label>
<Input
id="service-duration"
type="number"
min="1"
step="1"
placeholder="60"
bind:value={newService.duration_minutes}
onblur={validateDurationField}
class="w-full border-red-500={serviceErrors.duration_minutes}"
/>
{#if serviceErrors.duration_minutes}
<p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p>
{/if}
</div>
</div>
<!-- Patch Test and Minimum Age - Side by side on desktop -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Patch Test Duration -->
<div class="space-y-2">
<label for="patch-test-duration" class="text-sm font-medium"
>Patch Test Duration (hours)</label
>
<Input
id="patch-test-duration"
type="number"
min="0"
step="1"
placeholder="0"
bind:value={newService.patch_test_duration_hours}
onblur={validatePatchTestField}
class="w-full border-red-500={serviceErrors.patch_test_duration_hours}"
/>
{#if serviceErrors.patch_test_duration_hours}
<p class="text-sm text-red-600">{serviceErrors.patch_test_duration_hours}</p>
{/if}
<p class="text-xs text-gray-500">Hours required before service (0 for none)</p>
</div>
<!-- Minimum Age -->
<div class="space-y-2">
<label for="minimum-age" class="text-sm font-medium">Minimum Age</label>
<Input
id="minimum-age"
type="number"
min="0"
max="100"
step="1"
placeholder="0"
bind:value={newService.minimum_age_required}
onblur={validateMinimumAgeField}
class="w-full border-red-500={serviceErrors.minimum_age_required}"
/>
{#if serviceErrors.minimum_age_required}
<p class="text-sm text-red-600">{serviceErrors.minimum_age_required}</p>
{/if}
<p class="text-xs text-gray-500">0 for no age restriction</p>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showServiceModal = false;
resetServiceForm();
}}
disabled={creatingService}
>
Cancel
</Button>
<Button onclick={createService} disabled={creatingService || !isFormValid}>
{creatingService ? 'Creating...' : 'Create Service'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
@@ -0,0 +1,456 @@
<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';
interface Props {
open: boolean;
userId: string;
openBookingModal: (bookingId: string) => void;
}
let { open = $bindable(), userId, openBookingModal }: Props = $props();
type SocialLogin = {
provider: string;
createdAt: string;
};
type AdminUserDetail = {
id: string;
email?: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string;
dateOfBirth?: string;
profilePicUrl?: string;
accountRole: string;
accountType: string;
loyaltyStamps: number;
referralCode: string;
referralCodeUses: number;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
notes?: string;
privacyPolicyConsent: boolean;
policyConsentUpdatedAt?: string;
dataRetentionConsent: boolean;
dataConsentUpdatedAt?: string;
socialLogins?: SocialLogin[];
};
type Booking = {
id: string;
start_time: string;
status:
| 'pending'
| 'confirmed'
| 'in_progress'
| 'completed'
| 'client_cancelled'
| 'we_cancelled'
| 're-schedule'
| 'no_show';
services: Array<{
service_name?: string;
}>;
total_amount: number;
};
let selectedUser = $state<AdminUserDetail | null>(null);
let bookingUserHistory = $state<Booking[]>([]);
let totalBookings = $state(0);
let currentBookingPage = $state(1);
let totalBookingPages = $state(1);
let loadingBookings = $state(false);
async function fetchUserDetails() {
if (!userId) return;
try {
const response = await fetch(`/api/admin/users/${userId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
selectedUser = await response.json();
} else {
const text = await response.text();
toast.error('Failed to load user details: ' + text);
}
} catch (err) {
console.error('Error fetching user details:', err);
toast.error('Network error loading user details');
}
}
async function fetchUserBookings(page: number = 1) {
if (!userId) return;
loadingBookings = true;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: '5'
});
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
bookingUserHistory = data.bookings || [];
totalBookings = data.total || 0;
currentBookingPage = data.page || 1;
totalBookingPages = data.totalPages || 1;
} else {
const text = await response.text();
toast.error('Failed to load user bookings: ' + text);
}
} catch (err) {
console.error('Error fetching user bookings:', err);
toast.error('Network error loading user bookings');
} finally {
loadingBookings = false;
}
}
function nextBookingPage() {
if (currentBookingPage < totalBookingPages) {
fetchUserBookings(currentBookingPage + 1);
}
}
function previousBookingPage() {
if (currentBookingPage > 1) {
fetchUserBookings(currentBookingPage - 1);
}
}
$effect(() => {
if (open && userId) {
fetchUserDetails();
fetchUserBookings();
}
});
function handleOpenBooking(bookingId: string) {
open = false;
openBookingModal(bookingId);
}
</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">User Details</Modal.Title>
{#if selectedUser}
<div class="mt-1 text-sm text-gray-500">ID: {selectedUser.id}</div>
{/if}
</div>
{#if selectedUser}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedUser.accountRole === 'admin'
? 'bg-purple-100 text-purple-800'
: selectedUser.accountRole === 'verified_email'
? 'bg-emerald-100 text-emerald-800'
: selectedUser.accountRole === 'unverified_email'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'}"
>
{selectedUser.accountRole}
</span>
{/if}
</div>
</Modal.Header>
{#if selectedUser}
<div class="space-y-6 px-4 pb-4">
<!-- Personal Information -->
<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">
Personal Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Full Name</div>
<div class="font-medium">{selectedUser.fullName}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{selectedUser.email || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{selectedUser.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Date of Birth</div>
<div class="font-medium">
{selectedUser.dateOfBirth
? new SvelteDate(selectedUser.dateOfBirth).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: '—'}
</div>
</div>
</div>
{#if selectedUser.profilePicUrl}
<div class="mt-3">
<div class="text-xs text-gray-500">Profile Picture</div>
<img
src={selectedUser.profilePicUrl}
alt="Profile"
class="mt-2 h-24 w-24 rounded-lg object-cover"
/>
</div>
{/if}
</div>
<!-- Account Information -->
<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">
Account Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Account Type</div>
<div class="font-medium capitalize">{selectedUser.accountType}</div>
</div>
<div>
<div class="text-xs text-gray-500">Account Role</div>
<div class="font-medium capitalize">{selectedUser.accountRole.replace('_', ' ')}</div>
</div>
<div>
<div class="text-xs text-gray-500">Created</div>
<div class="font-medium">
{new SvelteDate(selectedUser.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Last Login</div>
<div class="font-medium">
{selectedUser.lastLoginAt
? new SvelteDate(selectedUser.lastLoginAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: '—'}
</div>
</div>
</div>
{#if selectedUser.socialLogins && selectedUser.socialLogins.length > 0}
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
<div class="mb-2 text-xs font-semibold text-blue-800">Connected Social Accounts</div>
<div class="flex flex-wrap gap-2">
{#each selectedUser.socialLogins as social (social.provider)}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-800"
>
{social.provider.charAt(0).toUpperCase() + social.provider.slice(1)}
</span>
{/each}
</div>
</div>
{/if}
</div>
<!-- Loyalty & Referrals -->
<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">
Loyalty & Referrals
</h3>
<div class="grid gap-3 md:grid-cols-3">
<div>
<div class="text-xs text-gray-500">Loyalty Stamps</div>
<div class="text-2xl font-bold text-emerald-600">{selectedUser.loyaltyStamps}</div>
</div>
<div>
<div class="text-xs text-gray-500">Referral Code</div>
<div class="font-mono text-sm font-medium">{selectedUser.referralCode}</div>
</div>
<div>
<div class="text-xs text-gray-500">Referrals Made</div>
<div class="text-2xl font-bold text-purple-600">{selectedUser.referralCodeUses}</div>
</div>
</div>
</div>
<!-- GDPR Consents -->
<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">
Privacy & Consent
</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">Privacy Policy & Terms</div>
<div class="text-xs text-gray-500">
{selectedUser.policyConsentUpdatedAt
? `Updated ${new SvelteDate(selectedUser.policyConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
: ''}
</div>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
{selectedUser.privacyPolicyConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
>
{selectedUser.privacyPolicyConsent ? 'Accepted' : 'Declined'}
</span>
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">Data Retention</div>
<div class="text-xs text-gray-500">
{selectedUser.dataConsentUpdatedAt
? `Updated ${new SvelteDate(selectedUser.dataConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
: ''}
</div>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
{selectedUser.dataRetentionConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
>
{selectedUser.dataRetentionConsent ? 'Accepted' : 'Declined'}
</span>
</div>
</div>
</div>
<!-- Staff Notes -->
{#if selectedUser.notes}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<h3 class="mb-2 text-sm font-semibold tracking-wide text-amber-800 uppercase">
Staff Notes
</h3>
<div class="text-sm text-amber-900">{selectedUser.notes}</div>
</div>
{/if}
<!-- Booking History -->
<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">
Booking History ({totalBookings})
</h3>
{#if loadingBookings}
<div class="space-y-2">
{#each Array(3) as _, i (i)}
<div class="h-20 animate-pulse rounded-md bg-gray-200"></div>
{/each}
</div>
{:else if bookingUserHistory.length === 0}
<div class="text-center text-sm text-gray-500">No bookings found</div>
{:else}
<div class="space-y-2">
{#each bookingUserHistory as booking (booking.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="font-medium">
{(() => {
const date = new SvelteDate(booking.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 class="mt-1 flex items-center gap-2 text-xs">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{booking.status === 'confirmed'
? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed'
? 'bg-green-100 text-green-800'
: booking.status === 'cancelled' ||
booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
{booking.status}
</span>
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')}
</span>
</div>
<div class="mt-1 text-sm font-semibold text-gray-900">
£{booking.total_amount.toFixed(2)}
</div>
</div>
<Button
variant="outline"
size="sm"
onclick={() => handleOpenBooking(booking.id)}>View</Button
>
</div>
</div>
{/each}
</div>
{#if totalBookingPages > 1}
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
<Button
variant="outline"
size="sm"
onclick={previousBookingPage}
disabled={currentBookingPage === 1 || loadingBookings}
>
Previous
</Button>
<span class="text-xs text-gray-600">
Page {currentBookingPage} of {totalBookingPages}
</span>
<Button
variant="outline"
size="sm"
onclick={nextBookingPage}
disabled={currentBookingPage === totalBookingPages || loadingBookings}
>
Next
</Button>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
<Button onclick={() => (open = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
@@ -0,0 +1,196 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
interface Props {
openUserModal: (userId: string) => void;
}
let { openUserModal }: Props = $props();
type UserListItem = {
id: string;
fullName: string;
email?: string;
phone?: string;
};
type UserListResponse = {
users: UserListItem[];
total: number;
page: number;
perPage: number;
totalPages: number;
};
let userQuery = $state('');
let users = $state<UserListItem[]>([]);
let totalUsers = $state(0);
let currentPage = $state(1);
let totalPages = $state(1);
let loadingSearch = $state(false);
let initialLoad = $state(true);
async function fetchUsers(page: number = 1, search: string = '') {
loadingSearch = true;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: '10'
});
if (search.trim()) {
params.append('q', search.trim());
}
const response = await fetch(`/api/admin/users?${params}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data: UserListResponse = await response.json();
users = data.users;
totalUsers = data.total;
currentPage = data.page;
totalPages = data.totalPages;
} else {
const text = await response.text();
toast.error('Failed to load users: ' + text);
}
} catch (err) {
console.error('Error fetching users:', err);
toast.error('Network error loading users');
} finally {
loadingSearch = false;
initialLoad = false;
}
}
function searchUsers() {
currentPage = 1;
fetchUsers(1, userQuery);
}
function nextPage() {
if (currentPage < totalPages) {
fetchUsers(currentPage + 1, userQuery);
}
}
function previousPage() {
if (currentPage > 1) {
fetchUsers(currentPage - 1, userQuery);
}
}
// Load initial users on mount
$effect(() => {
fetchUsers();
});
</script>
<Card.Root class="h-full">
<Card.Header>
<div class="flex items-start justify-between">
<div>
<Card.Title class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Users
</Card.Title>
<Card.Description>Search and manage user details.</Card.Description>
</div>
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
<span class="text-xs font-semibold">{totalUsers}</span>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div>
<div class="flex gap-2">
<Input
placeholder="Name, email or phone"
bind:value={userQuery}
onkeyup={(e) => {
if ((e as KeyboardEvent).key === 'Enter') searchUsers();
}}
/>
<Button onclick={searchUsers} disabled={loadingSearch}>
{loadingSearch ? 'Searching...' : 'Search'}
</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#if initialLoad || loadingSearch}
{#each Array(3) as _, i (i)}
<div class="rounded bg-gray-50 p-2">
<Skeleton class="mb-1 h-4 w-32" />
<Skeleton class="h-3 w-48" />
</div>
{/each}
{:else if users.length === 0}
<div class="py-8 text-center text-sm text-gray-500">
{userQuery ? 'No users found matching your search.' : 'No users found.'}
</div>
{:else}
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{user.fullName}</div>
<div class="text-xs text-gray-500">
{user.email || '—'}{user.phone || '—'}
</div>
</div>
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
</div>
{/each}
{/if}
</div>
{#if !initialLoad && totalPages > 1}
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
<Button
variant="outline"
size="sm"
onclick={previousPage}
disabled={currentPage === 1 || loadingSearch}
>
Previous
</Button>
<span class="text-xs text-gray-600">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onclick={nextPage}
disabled={currentPage === totalPages || loadingSearch}
>
Next
</Button>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,537 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { browser } from '$app/environment';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
// =============== Types ===============
type WorkingHourRow = {
weekday: number;
start_time: string;
end_time: string;
is_open: boolean;
};
// =============== State ===============
let defaultHours = $state<WorkingHourRow[]>([]);
let defaultHoursIsLoading = $state(true);
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
let showDefaultHoursModal = $state(false);
let showSaveDefaultHoursAlert = $state(false);
let savingHours = $state(false);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// =============== Helper Functions ===============
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
function formatTime(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
// Special case for 12:00
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}`;
}
/** Convert formatted time back to HH:MM for input fields */
function timeToInputValue(time: string): string {
// Handle special cases
if (time === 'Noon') return '12:00';
if (time === 'Midnight') return '00:00';
// Parse 12-hour format
const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!match) return time; // Return as-is if not in expected format
let hours = parseInt(match[1]);
const minutes = match[2];
const period = match[3].toUpperCase();
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
return `${hours.toString().padStart(2, '0')}:${minutes}`;
}
function weekdayLabel(i: number): string {
return dayNames[i];
}
/** Calculate hours between start and end time */
function calculateHours(startTime: string, endTime: string): string {
// Convert formatted times to 24-hour format for calculation
const start = timeToInputValue(startTime);
const end = timeToInputValue(endTime);
const [startHours, startMinutes] = start.split(':').map(Number);
const [endHours, endMinutes] = end.split(':').map(Number);
const startTotalMinutes = startHours * 60 + startMinutes;
const endTotalMinutes = endHours * 60 + endMinutes;
const diffMinutes = endTotalMinutes - startTotalMinutes;
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (minutes === 0) {
return `${hours}`;
}
return `${hours}.${minutes === 30 ? '5' : Math.round((minutes / 60) * 10)}`;
}
// =============== API Functions ===============
async function fetchDefaultHours() {
if (!browser) return;
defaultHoursIsLoading = true;
let error = null;
try {
const response = await fetch('/api/scheduling/default-hours', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
defaultHours = data.map((hour) => ({
weekday: hour.weekday,
start_time: formatTime(hour.startTime),
end_time: formatTime(hour.endTime),
is_open: hour.isOpen
}));
} else {
const text = await response.text();
error = 'Failed to load working hours: ' + text;
console.error('Error fetching default hours:', text);
}
} catch (err) {
error = 'Network error: ' + (err instanceof Error ? err.message : 'Unknown error');
console.error('Error fetching default hours:', err);
} finally {
if (error) toast.error(error);
defaultHoursIsLoading = false;
}
}
/** Opens the modal and creates a deep copy of current hours for editing. */
function prepareDefaultHoursEdit() {
// Deep copy the current default hours into the draft state
defaultHoursDraft = JSON.parse(JSON.stringify(defaultHours));
// Convert display format back to input format
defaultHoursDraft = defaultHoursDraft.map((row) => ({
...row,
start_time: timeToInputValue(row.start_time),
end_time: timeToInputValue(row.end_time)
}));
showDefaultHoursModal = true;
}
/** Saves the default hours draft after confirmation. */
async function confirmSaveDefaultHours() {
savingHours = true;
const loadingToast = toast.loading('Saving default hours...');
try {
// Map snake_case to camelCase for API
const payload = defaultHoursDraft.map((hour) => ({
weekday: hour.weekday,
startTime: hour.start_time,
endTime: hour.end_time,
isOpen: hour.is_open
}));
const response = await fetch('/api/scheduling/default-hours', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
// Update the main state from the draft state if successful
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
showDefaultHoursModal = false;
showSaveDefaultHoursAlert = false;
toast.success('Default hours saved successfully!', { id: loadingToast });
} else if (response.status === 401 || response.status === 403) {
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
} else {
const text = await response.text();
toast.error('Failed to save: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('save default hours', err);
toast.error('Network error saving hours', { id: loadingToast });
} finally {
savingHours = false;
}
}
// =============== Effects ===============
$effect(() => {
if (browser) {
fetchDefaultHours();
}
});
</script>
<!-- Default Hours Card -->
<Card.Root>
{#if !defaultHoursIsLoading}
<Card.Content class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Weekly Schedule</h3>
<p class="text-sm text-gray-500">
Your standard operating hours for each day of the week
</p>
</div>
<Button onclick={prepareDefaultHoursEdit} disabled={savingHours}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit Schedule
</Button>
</div>
<!-- Desktop Table -->
<div class="hidden w-full overflow-x-auto sm:block">
<table class="w-full table-auto border-collapse">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="px-4 py-3">Day</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3">Opening Time</th>
<th class="px-4 py-3">Closing Time</th>
<th class="px-4 py-3 text-right">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each defaultHours as row (row.weekday)}
<tr class="transition-colors hover:bg-gray-50">
<td class="p-3">
<div class="flex items-center gap-2">
<span class="font-medium"
>{weekdayLabel(row.weekday) === 'Mon'
? 'Monday'
: weekdayLabel(row.weekday) === 'Tue'
? 'Tuesday'
: weekdayLabel(row.weekday) === 'Wed'
? 'Wednesday'
: weekdayLabel(row.weekday) === 'Thu'
? 'Thursday'
: weekdayLabel(row.weekday) === 'Fri'
? 'Friday'
: weekdayLabel(row.weekday) === 'Sat'
? 'Saturday'
: 'Sunday'}</span
>
</div>
</td>
<td class="px-4 py-4 text-center">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {row.is_open
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
? 'bg-emerald-600'
: 'bg-gray-600'}"
></span>
{row.is_open ? 'Open' : 'Closed'}
</span>
</td>
<td class="px-4 py-4">
{#if row.is_open}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="font-medium text-gray-900">{row.start_time}</span>
</div>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-4">
{#if row.is_open}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="font-medium text-gray-900">{row.end_time}</span>
</div>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-4 text-right">
{#if row.is_open}
<span class="inline-flex items-center gap-1 text-sm font-medium text-gray-700">
{calculateHours(row.start_time, row.end_time)}
<span class="text-xs text-gray-500">hrs</span>
</span>
{:else}
<span class="text-gray-400"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="space-y-3 sm:hidden">
{#each defaultHours as row (row.weekday)}
<div class="rounded-lg border p-4 transition-colors hover:bg-gray-50">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900">
{weekdayLabel(row.weekday) === 'Mon'
? 'Monday'
: weekdayLabel(row.weekday) === 'Tue'
? 'Tuesday'
: weekdayLabel(row.weekday) === 'Wed'
? 'Wednesday'
: weekdayLabel(row.weekday) === 'Thu'
? 'Thursday'
: weekdayLabel(row.weekday) === 'Fri'
? 'Friday'
: weekdayLabel(row.weekday) === 'Sat'
? 'Saturday'
: 'Sunday'}
</span>
</div>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {row.is_open
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
? 'bg-emerald-600'
: 'bg-gray-600'}"
></span>
{row.is_open ? 'Open' : 'Closed'}
</span>
</div>
{#if row.is_open}
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="mb-1 text-xs text-gray-500">Opening</div>
<div class="font-medium text-gray-900">{row.start_time}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Closing</div>
<div class="font-medium text-gray-900">{row.end_time}</div>
</div>
</div>
<div class="mt-3 border-t pt-3 text-xs text-gray-600">
Total: <span class="font-medium text-gray-900"
>{calculateHours(row.start_time, row.end_time)} hours</span
>
</div>
{:else}
<div class="text-sm text-gray-500">No hours scheduled for this day</div>
{/if}
</div>
{/each}
</div>
</Card.Content>
{:else}
<!-- Skeleton loading -->
<Card.Content class="space-y-4">
<!-- Desktop Skeleton -->
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse">
<thead>
<tr
class="border-b bg-gray-50 text-left text-xs font-medium tracking-wider text-gray-600 uppercase"
>
<th class="px-4 py-3">Day</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3">Opening Time</th>
<th class="px-4 py-3">Closing Time</th>
<th class="px-4 py-3 text-right">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Array(7) as _, i (i)}
<tr>
<td class="px-4 py-4">
<div class="flex items-center gap-2">
<Skeleton class="h-8 w-8 rounded-full" />
<Skeleton class="h-4 w-20" />
</div>
</td>
<td class="px-4 py-4 text-center">
<Skeleton class="mx-auto h-6 w-16 rounded-full" />
</td>
<td class="px-4 py-4">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-4 py-4">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-4 py-4 text-right">
<Skeleton class="ml-auto h-4 w-12" />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Skeleton -->
<div class="space-y-3 md:hidden">
{#each Array(7) as _, i (i)}
<div class="rounded-lg border p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Skeleton class="h-10 w-10 rounded-full" />
<Skeleton class="h-5 w-24" />
</div>
<Skeleton class="h-6 w-16 rounded-full" />
</div>
<div class="grid grid-cols-2 gap-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
</div>
{/each}
</div>
</Card.Content>
{/if}
</Card.Root>
<!-- Default Hours Modal -->
<Modal.Root bind:open={showDefaultHoursModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Edit Default Working Hours</Modal.Title>
<Modal.Description>
Set the standard open and close times for your business.
</Modal.Description>
</Modal.Header>
<div class="px-4 pb-4">
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-2">Day</th>
<th class="py-2">Open</th>
<th class="py-2">Start</th>
<th class="py-2">End</th>
</tr>
</thead>
<tbody>
{#each defaultHoursDraft as row (row.weekday)}
<tr class="border-t">
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
<td class="py-2">
<input
type="checkbox"
bind:checked={row.is_open}
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary focus:ring-primary"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.start_time}
disabled={!row.is_open}
class="max-w-[70px] text-sm"
/>
</td>
<td class="py-2">
<Input
type="time"
bind:value={row.end_time}
disabled={!row.is_open}
class="max-w-[70px] text-sm"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button
variant="outline"
onclick={() => {
showDefaultHoursModal = false;
}}
>
Cancel
</Button>
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
Save Defaults
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Save Default Hours Confirmation -->
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Save default hours?</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to save these default hours? This will affect future bookings.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSaveDefaultHours}>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -12,7 +12,8 @@
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' }, { href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' }, { href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }, { href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' },
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' } { href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
]; ];
let mobileMenuOpen: boolean = false; let mobileMenuOpen: boolean = false;
@@ -42,7 +43,7 @@
</script> </script>
<nav <nav
class="fixed left-0 top-0 z-50 w-full border-b border-gray-200" class="fixed top-0 left-0 z-50 w-full border-b border-gray-200"
class:frosty-nav={!mobileMenuOpen} class:frosty-nav={!mobileMenuOpen}
class:bg-background={mobileMenuOpen} class:bg-background={mobileMenuOpen}
> >
@@ -54,7 +55,7 @@
href="https://instagram.com" href="https://instagram.com"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="hover:text-primary text-gray-600" class="text-gray-600 hover:text-primary"
aria-label="Instagram" aria-label="Instagram"
> >
<svg <svg
@@ -82,7 +83,7 @@
{#if authStore.isLoading} {#if authStore.isLoading}
<Skeleton class={`h-4 ${link.width} rounded`} /> <Skeleton class={`h-4 ${link.width} rounded`} />
{:else} {:else}
<a href={link.href} class="hover:text-primary font-medium text-gray-800" <a href={link.href} class="font-medium text-gray-800 hover:text-primary"
>{link.label}</a >{link.label}</a
> >
{/if} {/if}
@@ -118,8 +119,8 @@
<!-- Mobile Menu --> <!-- Mobile Menu -->
{#if mobileMenuOpen} {#if mobileMenuOpen}
<div class="bg-background border-b border-gray-200 md:hidden"> <div class="border-b border-gray-200 bg-background md:hidden">
<div class="space-y-1 px-2 pb-3 pt-2"> <div class="space-y-1 px-2 pt-2 pb-3">
{#each links as link} {#each links as link}
{#if canShow(link)} {#if canShow(link)}
{#if authStore.isLoading} {#if authStore.isLoading}
@@ -127,7 +128,7 @@
{:else} {:else}
<a <a
href={link.href} href={link.href}
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800" class="block rounded px-3 py-2 text-center text-primary hover:text-gray-800"
> >
{link.label} {link.label}
</a> </a>
@@ -48,7 +48,7 @@
<img <img
src={image.src} src={image.src}
alt={image.alt} alt={image.alt}
class="h-80 w-64 object-cover saturate-100 transition-all duration-500 md:saturate-25 md:group-hover:saturate-100" class="h-80 w-64 object-cover saturate-100 transition-all duration-500 md:saturate-75 md:group-hover:saturate-100"
loading="lazy" loading="lazy"
/> />
</div> </div>
File diff suppressed because it is too large Load Diff
-4
View File
@@ -620,10 +620,6 @@
} }
} }
function handleBooking() {
alert('Booking submitted! (This is just a prototype - no backend connected yet)');
}
const canProceedStep1 = $derived(selectedServices.length > 0); const canProceedStep1 = $derived(selectedServices.length > 0);
const canProceedStep2 = $derived(selectedDate && selectedTime); const canProceedStep2 = $derived(selectedDate && selectedTime);
const canProceedStep3 = $derived( const canProceedStep3 = $derived(
+19
View File
@@ -5,6 +5,7 @@
-- Enable pgcrypto for generating random IDs -- Enable pgcrypto for generating random IDs
CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ======================================= -- =======================================
-- ENUMS -- ENUMS
@@ -355,6 +356,24 @@ CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
CREATE INDEX idx_users_created_at ON users(created_at); CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status); CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
create table images (
id uuid primary key default gen_random_uuid(),
r2_url text not null,
tag_names text[] not null default '{}', -- for searching and filtering
created_at timestamptz not null default now()
);
-- only for autocomlpete text
create table tags (
id serial primary key,
name text not null unique
);
-- Indexes
create index idx_images_tag_names on images using gin(tag_names);
create index idx_images_created_at on images(created_at desc);
create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops);
-- ======================================= -- =======================================
-- GDPR COMPLIANCE FUNCTIONS -- GDPR COMPLIANCE FUNCTIONS
-- ======================================= -- =======================================