feat: add email verification, profile pictures, deposits, and calendar

export
Backend:
- Add email verification code generation and verification endpoints
- Add profile picture upload with S3 storage and image processing
- Add deposit_required field to users with 48h advance booking
  requirement
- Add loyalty stamps that accumulate on completed bookings
- Auto-transition bookings: confirmed → in_progress → completed
- Add booking cancellation handler with no-show detection
- Add ICS calendar file download endpoint for bookings
- Sync bookings to CalDAV on confirmation
  Frontend:
- Add schedule page route
- Add avatar and image-cropper UI components
- Update shadcn-svelte components (button, dialog)
- Add "Add to Calendar" button in booking modal
  Database:
- Add verification_codes table
- Add profile_pic_url, loyalty_stamps, deposits_required to users
- Various schema updates
This commit is contained in:
2026-02-21 18:48:29 +00:00
parent 88d8469180
commit 970cc5554d
48 changed files with 1984 additions and 175 deletions
+1
View File
@@ -20,6 +20,7 @@ S3_PUBLIC_URL=http://192.168.1.135:9000
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=rustfsadmin
S3_BUCKET=crussell
S3_PROFILE_PICS_BUCKET=crussell-profile-pics
AWS_REGION=eu-west-2
# Prod: Uncomment and fill these for R2
+4 -1
View File
@@ -174,6 +174,10 @@ docker compose exec backend sh
| Portfolio System | ✅ | ✅ | S3/R2 storage abstraction, tag-based filtering, category filters, admin upload, ?img= featured image param |
| Service Eligibility | ✅ | ✅ | Age + patch test filtering; `/api/services/eligible-for/{user_id}` for admin booking flows |
| Image Metadata Stripping | ✅ | ❌ | EXIF/GPS stripped on upload via `imaging` library |
| Profile Pictures | ✅ | ✅ | Upload to separate bucket, cropper, circular display, CalDAV sync |
| Auto-Booking Status | ✅ | ✅ | Auto-transition: confirmed → in_progress → completed based on time |
| Simplified Deposits | ✅ | ❌ | `deposits_required` INT on users table (3 default), reduces on payment |
| Contact Page | ✅ | ✅ | Dynamic data from first admin user via `/api/contact` endpoint |
### ⚠️ Partially Complete
@@ -196,7 +200,6 @@ docker compose exec backend sh
| Location | Issue | Priority |
|----------|-------|----------|
| `BookingFlow.svelte:600` | `submitBooking()` only logs, needs POST implementation | High |
| `BookingCreateModal.svelte:224` | Remove `console.log(users)` debug statement | Low |
| `/api/users/guest` | Guest endpoint for walk-ins not implemented | Medium |
| GDPR Export | Need endpoint for `export_all_user_data()` | Medium |
| Tax Export | Endpoint for VAT return data export | Medium |
+143
View File
@@ -6,6 +6,8 @@ import (
"crussell/db"
"crussell/internal/dav"
"crussell/mw"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"log"
@@ -350,3 +352,144 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken})
}
type VerificationCodeRequest struct {
Email string `json:"email"`
}
type VerifyCodeRequest struct {
Code string `json:"code"`
}
type VerificationResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
var req VerificationCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
var userID string
err := db.DB.QueryRow(r.Context(),
"SELECT id FROM users WHERE LOWER(email) = $1", email,
).Scan(&userID)
if err != nil {
if err == sql.ErrNoRows {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"})
return
}
log.Printf("Failed to look up user: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
expiresAt := time.Now().Add(24 * time.Hour)
var code string
err = db.DB.QueryRow(r.Context(),
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt,
).Scan(&code)
if err != nil {
log.Printf("Failed to insert verification code: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
log.Printf("DEBUG: Verification code for %s: %s (expires at %s)", email, code, expiresAt.Format(time.RFC3339))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
}
func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
var req VerifyCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
code := strings.TrimSpace(req.Code)
if code == "" {
http.Error(w, "code is required", http.StatusBadRequest)
return
}
var userID string
var purpose string
var expiresAt time.Time
err := db.DB.QueryRow(r.Context(),
`SELECT user_id, purpose, expires_at FROM verification_codes
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
code,
).Scan(&userID, &purpose, &expiresAt)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
log.Printf("Failed to verify code: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
_, err = tx.Exec(r.Context(),
`UPDATE verification_codes SET used_at = NOW() WHERE code = $1`,
code,
)
if err != nil {
log.Printf("Failed to mark code as used: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if purpose == "email_verify" {
_, err = tx.Exec(r.Context(),
`UPDATE users SET account_role = 'verified_email' WHERE id = $1 AND account_role = 'unverified_email'`,
userID,
)
if err != nil {
log.Printf("Failed to update user role: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit verification: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"})
}
func generateSecureCode(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
log.Printf("Failed to generate random code: %v", err)
return strings.ToLower(fmt.Sprintf("%x", time.Now().UnixNano()))
}
return strings.ToLower(fmt.Sprintf("%x", bytes))
}
+253 -1
View File
@@ -3,6 +3,7 @@ package bookings
import (
"crussell/db"
"crussell/handlers/notifications"
"crussell/internal/dav"
"crussell/mw"
"database/sql"
"encoding/json"
@@ -34,6 +35,12 @@ type Booking struct {
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *string `json:"created_by,omitempty"`
// Deposit fields
DepositRequired bool `json:"deposit_required"`
DepositAmount float64 `json:"deposit_amount,omitempty"`
DepositPaid bool `json:"deposit_paid"`
DepositDeadline *string `json:"deposit_deadline,omitempty"`
// Joined fields
User *UserSummary `json:"user,omitempty"`
Services []BookingService `json:"services,omitempty"`
@@ -1221,6 +1228,29 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Calculate total price and check deposit requirements
var totalPrice float64
tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, booking.ID).Scan(&totalPrice)
// Check if user needs to pay deposit (deposits_required > 0)
var depositsRequired int
tx.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired)
// If deposits_required > 0, require min 48h notice
if depositsRequired > 0 {
minStartTime := time.Now().Add(48 * time.Hour)
if req.StartTime.Before(minStartTime) {
tx.Rollback(r.Context())
http.Error(w, "You must book at least 48 hours in advance. Complete more appointments to remove this requirement.", http.StatusBadRequest)
return
}
}
// Insert booking services
serviceQuery := `
INSERT INTO booking_services (booking_id, service_id)
@@ -1427,6 +1457,31 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
}
// Add loyalty stamp when booking completed - max 1 per day per user
_, err = db.DB.Exec(r.Context(),
`UPDATE users
SET loyalty_stamps = loyalty_stamps + 1
WHERE id = $1
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.user_id = users.id
AND b.status = 'completed'
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
AND b.id != $2
)`,
booking.User.ID, bookingID,
)
if err != nil {
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
}
// Reduce deposits_required if payment was made for this booking
var paymentCount int
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount)
if paymentCount > 0 {
db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID)
}
}
// Return updated booking
@@ -1447,7 +1502,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Parse and validate request
var req ConfirmBookingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode request: %v", err)
@@ -1570,6 +1624,20 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Sync to CalDAV when booking confirmed - DB is source of truth
if dav.Service != nil {
var durationMinutes int
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1)) FROM booking_services WHERE booking_id = $1`, bookingID).Scan(&durationMinutes)
if durationMinutes == 0 {
durationMinutes = 60
}
dav.Service.CreateEvent(1, dav.EventInput{
Summary: "Crussell Booking",
Start: booking.StartTime,
End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute),
})
}
// Return confirmed booking
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@@ -1580,6 +1648,69 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
// POST /api/admin/bookings/{id}/cancel
func CancelBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" {
http.Error(w, "Booking ID is required", http.StatusBadRequest)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
updateQuery := `
UPDATE bookings
SET status = 'we_cancelled', updated_at = NOW()
WHERE id = $1 AND status NOT IN ('completed', 'cancelled', 'client_cancelled', 'we_cancelled')
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`
var booking Booking
booking.User = &UserSummary{}
err = tx.QueryRow(r.Context(), updateQuery, bookingID).Scan(
&booking.ID,
&booking.User.ID,
&booking.StartTime,
&booking.Status,
&booking.Notes,
&booking.CreatedAt,
&booking.UpdatedAt,
&booking.CreatedBy,
)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Booking not found or cannot be cancelled", http.StatusNotFound)
return
}
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('cancelled_booking', $1, $2)
`
tx.Exec(r.Context(), notificationQuery, bookingID, booking.User.ID)
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit booking cancellation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(booking)
}
// DELETE /api/bookings/{id}
func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
@@ -1669,6 +1800,25 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
// Only notify on cancellation if booking was confirmed (not pending)
if originalStatus == "confirmed" {
// Check notice period
var startTime time.Time
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
noticeHours := startTime.Sub(time.Now()).Hours()
if noticeHours < 12 {
// Less than 12h notice = count as no-show, add 3 deposits required
tx.Exec(r.Context(), "UPDATE users SET deposits_required = deposits_required + 3 WHERE id = $1", userID)
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
} else if noticeHours < 24 {
// Less than 24h notice - create admin notification about potential deposit requirement
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`
tx.Exec(r.Context(), notificationQuery, "late_cancellation", bookingID, userID)
}
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
@@ -1976,3 +2126,105 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/bookings/{id}/calendar - returns standalone .ics file
func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" {
http.Error(w, "Booking ID is required", http.StatusBadRequest)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var bookingIDDB, userIDDB, status, notes, createdBy string
var startTime, createdAt, updatedAt time.Time
var durationMinutes int
err := db.DB.QueryRow(r.Context(), `
SELECT id, user_id, start_time, status, COALESCE(notes, ''), created_by, created_at, updated_at,
COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id), 60)
FROM bookings
WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, &notes, &createdBy, &createdAt, &updatedAt, &durationMinutes)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch booking for calendar: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rows, err := db.DB.Query(r.Context(), `
SELECT s.name, COALESCE(bs.override_price, s.price)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services: %v", err)
}
defer rows.Close()
var services []string
var totalPrice float64
for rows.Next() {
var name string
var price float64
rows.Scan(&name, &price)
services = append(services, name)
totalPrice += price
}
serviceList := strings.Join(services, ", ")
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
icalContent := generateICS(serviceList, startTime, endTime, status, notes, totalPrice)
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"booking-%s.ics\"", bookingID))
w.WriteHeader(http.StatusOK)
w.Write([]byte(icalContent))
}
func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano())
dtstamp := time.Now().UTC().Format("20060102T150405Z")
dtstart := start.Format("20060102T150405")
dtend := end.Format("20060102T150405")
summary := "Crussell Appointment"
if serviceList != "" {
summary = "Crussell: " + serviceList
}
description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", status, serviceList, price)
if notes != "" {
description += "\\nNotes: " + notes
}
return fmt.Sprintf(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Crussell//Booking//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:%s
DTSTAMP:%s
DTSTART:%s
DTEND:%s
SUMMARY:%s
DESCRIPTION:%s
STATUS:%s
END:VEVENT
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status)
}
+83
View File
@@ -45,6 +45,47 @@ type CurrentNextResponse struct {
func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
AND start_time <= $1
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
0
) || ' minutes'
)::interval
) > $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
0
) || ' minutes'
)::interval
) <= $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to completed: %v", err)
}
var current *AppointmentInfo
var next *AppointmentInfo
@@ -248,6 +289,48 @@ type TodayAppointmentsResponse struct {
// GET /api/admin/today/appointments
func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
AND start_time <= $1
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
0
) || ' minutes'
)::interval
) > $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
AND (
start_time + (
COALESCE(
(SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id),
0
) || ' minutes'
)::interval
) <= $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to completed: %v", err)
}
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(24 * time.Hour)
+216 -7
View File
@@ -5,23 +5,34 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kovidgoyal/imaging"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"crussell/db"
"crussell/handlers/auth"
"crussell/internal/s3"
"crussell/mw"
)
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}
var titleCaser = cases.Title(language.English)
type UserProfile struct {
@@ -128,15 +139,18 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
// PUT /api/user/profile
// updateCardDAV updates an existing contact in SabreDAV using user ID
func updateCardDAV(userID, firstName, lastName, email, phone, dob string) error {
// Use user ID as filename - consistent with registration
func updateCardDAV(userID, firstName, lastName, email, phone, dob, profilePicURL string) error {
filename := fmt.Sprintf("%s.vcf", userID)
url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename)
// Create vCard with user ID as UID (no need to fetch existing)
timestamp := time.Now().UTC().Format("20060102T150405Z")
uid := fmt.Sprintf("%s@example.com", userID)
var photoLine string
if profilePicURL != "" {
photoLine = fmt.Sprintf("PHOTO;VALUE=URI:%s", profilePicURL)
}
vcard := fmt.Sprintf(`BEGIN:VCARD
VERSION:3.0
UID:%s
@@ -145,8 +159,9 @@ N:%s;%s;;;
EMAIL;TYPE=INTERNET:%s
TEL;TYPE=CELL:%s
BDAY:%s
%s
REV:%s
END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, timestamp)
END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, photoLine, timestamp)
// PUT updated vCard
req, err := http.NewRequest("PUT", url, bytes.NewBufferString(vcard))
@@ -233,9 +248,10 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
// Fetch user's email and DOB for CardDAV update
var email string
var dob time.Time
var profilePicURL sql.NullString
err = db.DB.QueryRow(r.Context(), `
SELECT email, date_of_birth FROM users WHERE id = $1
`, userID).Scan(&email, &dob)
SELECT email, date_of_birth, profile_pic_url FROM users WHERE id = $1
`, userID).Scan(&email, &dob, &profilePicURL)
if err != nil {
http.Error(w, "failed to fetch user data", http.StatusInternalServerError)
@@ -257,7 +273,7 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
// Update CardDAV (non-blocking)
go func() {
dobStr := dob.Format("2006-01-02")
if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr); err != nil {
if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr, profilePicURL.String); err != nil {
fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err)
}
}()
@@ -658,3 +674,196 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}
type UserPatchTest struct {
ID string `json:"id"`
ServiceID string `json:"serviceId"`
ServiceName string `json:"serviceName"`
LastTime time.Time `json:"lastTime"`
}
func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
if userID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
rows, err := db.DB.Query(r.Context(), `
SELECT p.id, p.service_id, s.name, p.last_time
FROM user_service_patch_tests p
JOIN services s ON p.service_id = s.id
WHERE p.user_id = $1
ORDER BY p.last_time DESC
`, userID)
if err != nil {
log.Printf("Failed to get patch tests: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var tests []UserPatchTest
for rows.Next() {
var t UserPatchTest
if err := rows.Scan(&t.ID, &t.ServiceID, &t.ServiceName, &t.LastTime); err != nil {
log.Printf("Failed to scan patch test: %v", err)
continue
}
tests = append(tests, t)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tests)
}
func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
testID := chi.URLParam(r, "test_id")
if userID == "" || testID == "" {
http.Error(w, "User ID and Test ID are required", http.StatusBadRequest)
return
}
result, err := db.DB.Exec(r.Context(), `
DELETE FROM user_service_patch_tests WHERE id = $1 AND user_id = $2
`, testID, userID)
if err != nil {
log.Printf("Failed to delete patch test: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if result.RowsAffected() == 0 {
http.Error(w, "patch test not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
type UploadProfilePicResponse struct {
URL string `json:"url"`
}
func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
if s3.Client == nil {
log.Printf("S3 client not initialized")
http.Error(w, "Storage not configured", http.StatusInternalServerError)
return
}
r.ParseMultipartForm(10 << 20)
file, header, err := r.FormFile("file")
if err != nil {
log.Printf("Failed to get file: %v", err)
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
ext := ".jpg"
if idx := strings.LastIndex(header.Filename, "."); idx != -1 {
ext = strings.ToLower(header.Filename[idx:])
}
key := fmt.Sprintf("profiles/%s%s", userID, ext)
fileBytes, err := io.ReadAll(file)
if err != nil {
log.Printf("Failed to read file: %v", err)
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
fileBytes, err = processProfileImage(fileBytes)
if err != nil {
log.Printf("Failed to process image: %v", err)
http.Error(w, "Failed to process image", http.StatusInternalServerError)
return
}
bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics")
if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes)); err != nil {
log.Printf("Failed to upload profile picture to S3: %v", err)
http.Error(w, "Failed to upload image", http.StatusInternalServerError)
return
}
url, err := s3.Client.GetURL(r.Context(), bucket, key)
if err != nil {
log.Printf("Failed to get URL: %v", err)
http.Error(w, "Failed to get image URL", http.StatusInternalServerError)
return
}
_, err = db.DB.Exec(r.Context(), `UPDATE users SET profile_pic_url = $1 WHERE id = $2`, url, userID)
if err != nil {
log.Printf("Failed to update user profile pic: %v", err)
http.Error(w, "Failed to save profile picture", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(UploadProfilePicResponse{URL: url})
}
func processProfileImage(data []byte) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
img = imaging.Thumbnail(img, 350, 350, imaging.Linear)
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85))
if err != nil {
return nil, fmt.Errorf("failed to encode image: %w", err)
}
return buf.Bytes(), nil
}
type ContactInfo struct {
Name string `json:"name"`
Role string `json:"role"`
Phone string `json:"phone"`
Email string `json:"email"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
}
// GET /api/contact
func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) {
var contact ContactInfo
err := db.DB.QueryRow(r.Context(), `
SELECT
COALESCE(n_first_name, '') || ' ' || COALESCE(n_last_name, '') as name,
COALESCE(phone, ''),
COALESCE(email, ''),
profile_pic_url
FROM users
WHERE account_role = 'admin'
ORDER BY created_at ASC
LIMIT 1
`).Scan(&contact.Name, &contact.Phone, &contact.Email, &contact.ProfilePicURL)
if err != nil {
log.Printf("Failed to get contact info: %v", err)
http.Error(w, "contact not found", http.StatusNotFound)
return
}
contact.Role = "Owner / Beauty Specialist"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(contact)
}
+34
View File
@@ -66,6 +66,11 @@ func Connect() error {
bucket = "crussell"
}
profilePicsBucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
if profilePicsBucket == "" {
profilePicsBucket = "crussell-profile-pics"
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = "eu-west-2"
@@ -129,6 +134,35 @@ func Connect() error {
log.Printf("Bucket policy: %v (may already exist)", err)
}
// Create profile pics bucket if it doesn't exist
if profilePicsBucket != bucket {
_, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(profilePicsBucket),
})
if err != nil {
log.Printf("Profile pics bucket creation: %v (may already exist)", err)
}
// Set bucket policy for public read access
profilePolicy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::%s/*"
}]
}`, profilePicsBucket)
_, err = s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: aws.String(profilePicsBucket),
Policy: aws.String(profilePolicy),
})
if err != nil {
log.Printf("Profile pics bucket policy: %v (may already exist)", err)
}
}
log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint)
return nil
}
+10 -1
View File
@@ -94,6 +94,13 @@ func main() {
// Login: Has its own internal rate limiting
r.Post("/login", authHandlers.LoginHandler)
// Email verification
r.Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
r.Post("/verify/check", authHandlers.VerifyCodeHandler)
// Public contact info
r.Get("/contact", user.GetContactInfoHandler)
// Portfolio
r.Route("/portfolio", func(r chi.Router) {
r.Get("/images", portfolio.ListImages)
@@ -139,6 +146,7 @@ func main() {
r.Get("/user/profile", user.GetProfileHandler)
r.Put("/user/profile", user.UpdateProfileHandler)
r.Post("/user/profile-picture", user.UploadProfilePictureHandler)
r.Put("/user/change-password", user.ChangePasswordHandler)
r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler)
@@ -147,6 +155,7 @@ func main() {
r.Post("/", bookings.CreateBookingHandler)
r.Get("/", bookings.GetAllUserBookingsHandler)
r.Get("/{id}", bookings.GetBookingHandler)
r.Get("/{id}/calendar", bookings.GetBookingCalendarHandler)
r.Put("/{id}", bookings.EditBookingHandler)
r.Delete("/{id}", bookings.DeleteBookingHandler)
})
@@ -172,7 +181,7 @@ func main() {
r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.CancelBookingHandler)
})
r.Route("/admin/users", func(r chi.Router) {
+25 -19
View File
@@ -3,6 +3,10 @@
"private": true,
"version": "0.0.1",
"type": "module",
"overrides": {
"cookie": "^0.7.0",
"minimatch": "^10.2.1"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -16,36 +20,38 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.22.0",
"@internationalized/date": "^3.9.0",
"@lucide/svelte": "^0.544.0",
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.562.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22",
"@types/swiper": "^5.4.3",
"bits-ui": "^2.11.5",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0",
"@types/node": "^22.19.11",
"bits-ui": "^2.16.1",
"clsx": "^2.1.1",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"formsnap": "^2.0.1",
"globals": "^16.0.0",
"globals": "^17.3.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"shadcn-svelte": "^1.0.8",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"runed": "^0.37.1",
"shadcn-svelte": "^1.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.5",
"svelte-check": "^4.4.3",
"svelte-easy-crop": "^5.0.0",
"svelte-sonner": "^1.0.7",
"svelte-toolbelt": "^0.10.6",
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.8",
"typescript": "^5.0.0",
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
},
@@ -53,6 +59,6 @@
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"swiper": "^10.3.1"
"cropperjs": "^1.6.2"
}
}
@@ -280,6 +280,15 @@
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking}
<Button variant="outline" onclick={() => {
if (selectedBooking) {
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
}
}}>
Add to Calendar
</Button>
{/if}
<Button onclick={() => (open = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
@@ -35,7 +35,14 @@
overrideDurationMinutes?: number;
};
let notes = $state(booking.notes || '');
let notes = $state('');
// Sync notes with booking.notes when booking changes
$effect(() => {
if (booking?.notes !== undefined) {
notes = booking.notes || '';
}
});
let serviceOverrides = $state<
Record<
string,
@@ -8,12 +8,13 @@
const links = [
{ href: '/', label: 'Home', showWhen: 'always', width: 'w-12' },
{ href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' },
{ href: '/book', label: 'Book your appointment', showWhen: 'auth', width: 'w-36' },
{ href: '/schedule', label: 'My Schedule', showWhen: 'auth', width: 'w-24' },
{ href: '/book', label: 'Book an appointment', showWhen: 'auth', width: 'w-36' },
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' },
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' },
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }
];
let mobileMenuOpen = $state(false);
@@ -131,7 +131,7 @@
interval = setInterval(() => {
calculateTimes();
}, 60000); // Update every minute
}, 15000); // Update every 15 seconds
return () => {
if (interval) clearInterval(interval);
@@ -193,18 +193,22 @@
{#if activeAppointment}
{#if isInProgress}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<span class="relative mr-2 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
In Progress • {timeRemaining} min remaining
{#if freeTimeAfter > 0}
{freeTimeAfter} min free
<div class="flex flex-col gap-2 items-end">
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<span class="relative mr-2 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
In Progress • {timeRemaining} min remaining
</Badge>
{#if freeTimeAfter > 0 && timeRemaining > 29}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
{freeTimeAfter} min free afterwards
</Badge>
{/if}
</Badge>
</div>
{:else}
<Badge class="bg-amber-100 px-3 py-1 text-sm text-amber-800">
Starts in {timeRemaining} min
@@ -259,10 +263,11 @@
class="h-20 w-20 rounded-full object-cover ring-4 ring-blue-200"
/>
{:else}
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-2xl font-bold text-gray-600 ring-4 ring-blue-200"
>
{activeAppointment.user?.full_name?.charAt(0) || '?'}
{initials}
</div>
{/if}
<div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn('aspect-square size-full', className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
data-slot="avatar"
class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
{...restProps}
/>
@@ -0,0 +1,13 @@
import Root from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback
};
@@ -1,82 +1,124 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
import type { WithChildren, WithoutChildren } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-md text-sm font-medium whitespace-nowrap outline-hidden transition-all select-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs',
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-2xs',
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 border shadow-2xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-2xs',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
export type ButtonPropsWithoutHTML = WithChildren<{
ref?: HTMLElement | null;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
'data-slot'?: string;
onClickPromise?: (
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => Promise<void>;
}>;
export type AnchorElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLAnchorAttributes, 'href' | 'type'>> & {
href: HTMLAnchorAttributes['href'];
type?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLButtonAttributes, 'type' | 'href'>> & {
type?: HTMLButtonAttributes['type'];
href?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonProps = AnchorElementProps | ButtonElementProps;
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
variant = 'default',
size = 'default',
href = undefined,
type = "button",
disabled,
type = 'button',
loading = false,
disabled = false,
tabindex = 0,
onclick,
onClickPromise,
class: className,
'data-slot': dataSlot = 'button',
children,
...restProps
...rest
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
<!-- This approach to disabled links is inspired by bits-ui see: https://github.com/huntabyte/bits-ui/pull/1055 -->
<svelte:element
this={href ? 'a' : 'button'}
{...rest}
data-slot={dataSlot}
type={href ? undefined : type}
href={href && !disabled ? href : undefined}
disabled={href ? undefined : disabled || loading}
aria-disabled={href ? disabled : undefined}
role={href && disabled ? 'link' : undefined}
tabindex={href && disabled ? -1 : tabindex}
class={cn(buttonVariants({ variant, size }), className)}
bind:this={ref}
onclick={async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
e: any
) => {
onclick?.(e);
if (type === undefined) return;
if (onClickPromise) {
loading = true;
await onClickPromise(e);
loading = false;
}
}}
>
{#if type !== undefined && loading}
<div class="flex animate-spin place-items-center justify-center">
<LoaderCircleIcon class="size-4" />
</div>
<span class="sr-only">Loading</span>
{/if}
{@render children?.()}
</svelte:element>
@@ -2,8 +2,11 @@ import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML,
buttonVariants
} from './button.svelte';
export {
Root,
@@ -14,4 +17,7 @@ export {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML
};
@@ -1,5 +1,5 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
@@ -1,21 +1,21 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
hideClose = false,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
hideClose?: boolean;
} = $props();
</script>
@@ -25,15 +25,15 @@
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
{#if !hideClose}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
class="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)}
class={cn('text-lg leading-none font-semibold', className)}
{...restProps}
/>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
+10 -10
View File
@@ -1,13 +1,13 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import { Dialog as DialogPrimitive } from 'bits-ui';
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
import Title from './dialog-title.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Trigger from './dialog-trigger.svelte';
import Close from './dialog-close.svelte';
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
Close as DialogClose
};
@@ -0,0 +1,34 @@
<script lang="ts">
import { type ButtonElementProps, Button } from '$lib/components/ui/button';
import { useImageCropperCancel } from './image-cropper.svelte.js';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
let {
ref = $bindable(null),
variant = 'outline',
size = 'sm',
onclick,
...rest
}: ButtonElementProps = $props();
const cancelState = useImageCropperCancel();
</script>
<Button
{...rest}
bind:ref
{size}
{variant}
onclick={(
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => {
onclick?.(e);
cancelState.onclick();
}}
>
<Trash2Icon />
<span>Cancel</span>
</Button>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { ImageCropperControlsProps } from './types';
let {
ref = $bindable(null),
class: className,
children,
...rest
}: ImageCropperControlsProps = $props();
</script>
<div
{...rest}
bind:this={ref}
class={cn('flex w-full place-items-center justify-center gap-2', className)}
>
{@render children?.()}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { type ButtonElementProps, Button } from '$lib/components/ui/button';
import { useImageCropperCrop } from './image-cropper.svelte.js';
import CropIcon from '@lucide/svelte/icons/crop';
let {
ref = $bindable(null),
variant = 'default',
size = 'sm',
onclick,
...rest
}: ButtonElementProps = $props();
const cropState = useImageCropperCrop();
</script>
<Button
{...rest}
bind:ref
{size}
{variant}
onclick={(
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => {
onclick?.(e);
cropState.onclick();
}}
>
<CropIcon />
<span>Crop</span>
</Button>
@@ -0,0 +1,26 @@
<script lang="ts">
import Cropper from 'svelte-easy-crop';
import { useImageCropperCropper } from './image-cropper.svelte.js';
import type { ImageCropperCropperProps } from './types.js';
let {
cropShape = 'round',
aspect = 1,
showGrid = false,
...rest
}: ImageCropperCropperProps = $props();
const cropperState = useImageCropperCropper();
</script>
<!-- This needs to be relative https://github.com/ValentinH/svelte-easy-crop#basic-usage -->
<div class="relative h-full w-full">
<Cropper
{...rest}
{cropShape}
{aspect}
{showGrid}
image={cropperState.rootState.tempUrl}
oncropcomplete={cropperState.onCropComplete}
/>
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { cn } from '$lib/utils.js';
import { useImageCropperDialog } from './image-cropper.svelte.js';
import type { ImageCropperDialogProps } from './types';
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
const dialogState = useImageCropperDialog();
</script>
<Dialog.Root bind:open={dialogState.rootState.open}>
<Dialog.Content
{...rest}
hideClose
class={cn(
'min-h-96 max-w-full rounded-none border-x-0 sm:max-w-lg sm:rounded-lg sm:border-x',
className
)}
>
<div class="flex flex-col gap-4">
{@render children?.()}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import type { ImageCropperPreviewProps } from './types';
import { useImageCropperPreview } from './image-cropper.svelte.js';
import UploadIcon from '@lucide/svelte/icons/upload';
import { cn } from '$lib/utils.js';
let { child, class: className }: ImageCropperPreviewProps = $props();
const previewState = useImageCropperPreview();
</script>
{#if child}
{@render child({ src: previewState.rootState.src })}
{:else}
<Avatar.Root
class={cn('ring-accent ring-offset-background size-20 ring-2 ring-offset-2', className)}
>
<Avatar.Image src={previewState.rootState.src} />
<Avatar.Fallback>
<UploadIcon class="size-4" />
<span class="sr-only">Upload image</span>
</Avatar.Fallback>
</Avatar.Root>
{/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import { useImageCropperTrigger } from './image-cropper.svelte.js';
import type { ImageCropperUploadTriggerProps } from './types';
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
const triggerState = useImageCropperTrigger();
</script>
<label {...rest} bind:this={ref} for={triggerState.rootState.id} class="hover:cursor-pointer">
{@render children?.()}
</label>
@@ -0,0 +1,43 @@
<script lang="ts">
import { box } from 'svelte-toolbelt';
import { useImageCropperRoot } from './image-cropper.svelte.js';
import type { ImageCropperRootProps } from './types';
import { onDestroy } from 'svelte';
import { useId } from 'bits-ui';
let {
id = useId(),
src = $bindable(''),
onCropped = () => {},
onUnsupportedFile = () => {},
children,
...rest
}: ImageCropperRootProps = $props();
const rootState = useImageCropperRoot({
id: box.with(() => id),
src: box.with(
() => src,
(v) => (src = v)
),
onCropped: box.with(() => onCropped),
onUnsupportedFile: box.with(() => onUnsupportedFile)
});
onDestroy(() => rootState.dispose());
</script>
{@render children?.()}
<input
{...rest}
onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
rootState.onUpload(file);
// reset so that we can reupload the same file
(e.target! as HTMLInputElement).value = '';
}}
type="file"
{id}
style="display: none;"
/>
@@ -0,0 +1,167 @@
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
import { Context } from 'runed';
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
import { getCroppedImg } from './utils';
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
export const VALID_IMAGE_TYPES = [
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp'
];
export type ImageCropperRootProps = WritableBoxedValues<{
src: string;
}> &
ReadableBoxedValues<{
id: string;
onCropped: (url: string) => void;
onUnsupportedFile: (file: File) => void;
}>;
class ImageCropperRootState {
#createdUrls = $state<string[]>([]);
open = $state(false);
tempUrl = $state<string>();
pixelCrop = $state<CropArea>();
constructor(readonly opts: ImageCropperRootProps) {
this.onUpload = this.onUpload.bind(this);
this.onCancel = this.onCancel.bind(this);
this.onCrop = this.onCrop.bind(this);
this.dispose = this.dispose.bind(this);
}
onUpload(file: File) {
if (!VALID_IMAGE_TYPES.includes(file.type)) {
this.opts.onUnsupportedFile.current(file);
return;
}
this.tempUrl = URL.createObjectURL(file);
this.#createdUrls.push(this.tempUrl);
this.open = true;
}
onCancel() {
this.tempUrl = undefined;
this.open = false;
this.pixelCrop = undefined;
}
async onCrop() {
if (!this.pixelCrop || !this.tempUrl) return;
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
this.open = false;
this.opts.onCropped.current(this.opts.src.current);
}
get src() {
return this.opts.src.current;
}
get id() {
return this.opts.id.current;
}
dispose() {
for (const url of this.#createdUrls) {
URL.revokeObjectURL(url);
}
}
}
export type ImageCropperTriggerProps = ReadableBoxedValues<{
id?: string;
}>;
class ImageCropperTriggerState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperPreviewState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperDialogState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperCropperState {
constructor(readonly rootState: ImageCropperRootState) {
this.onCropComplete = this.onCropComplete.bind(this);
}
onCropComplete(e: DispatchEvents['cropcomplete']) {
this.rootState.pixelCrop = e.pixels;
}
}
class ImageCropperCropState {
constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this);
}
onclick() {
this.rootState.onCrop();
}
}
class ImageCropperCancelState {
constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this);
}
onclick() {
this.rootState.onCancel();
}
}
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
return ImageCropperRootContext.set(new ImageCropperRootState(props));
};
export const useImageCropperTrigger = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperTriggerState(rootState);
};
export const useImageCropperPreview = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperPreviewState(rootState);
};
export const useImageCropperDialog = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperDialogState(rootState);
};
export const useImageCropperCropper = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCropperState(rootState);
};
export const useImageCropperCrop = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCropState(rootState);
};
export const useImageCropperCancel = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCancelState(rootState);
};
@@ -0,0 +1,13 @@
import Root from './image-cropper.svelte';
import UploadTrigger from './image-cropper-upload-trigger.svelte';
import Preview from './image-cropper-preview.svelte';
import Dialog from './image-cropper-dialog.svelte';
import Cropper from './image-cropper-cropper.svelte';
import Controls from './image-cropper-controls.svelte';
import Crop from './image-cropper-crop.svelte';
import Cancel from './image-cropper-cancel.svelte';
import { getFileFromUrl } from './utils';
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
export type * from './types';
@@ -0,0 +1,44 @@
import type {
AvatarRootProps,
DialogContentProps,
WithChildren,
WithoutChild,
WithoutChildren
} from 'bits-ui';
import type { Snippet } from 'svelte';
import type { CropperProps } from 'svelte-easy-crop';
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
id?: string;
src?: string;
onCropped?: (url: string) => void;
onUnsupportedFile?: (file: File) => void;
}>;
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
export type ImageCropperDialogProps = DialogContentProps;
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
export type ImageCropperControlsWithoutHTML = WithChildren<{
ref?: HTMLDivElement | null;
}>;
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
export type ImageCropperPreviewPropsWithoutHTML = {
child?: Snippet<[{ src: string }]>;
};
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
WithoutChild<AvatarRootProps>;
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
ref?: HTMLLabelElement | null;
}>;
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
@@ -0,0 +1,85 @@
import type { CropArea } from 'svelte-easy-crop';
export const getFileFromUrl = async (url: string, fileName = 'cropped.png'): Promise<File> => {
// Fetch the file data from the URL
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch resource: ${response.status} ${response.statusText}`);
}
// Convert the response into a Blob
const blob = await response.blob();
// Create and return a File. You can set a custom type if needed.
return new File([blob], fileName, { type: blob.type });
};
const createImage = (url: string): Promise<HTMLImageElement> => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
};
const getRadianAngle = (degreeValue: number) => {
return (degreeValue * Math.PI) / 180;
};
/** Gets the cropped image from the src using the cropped area
*
* @param imageSrc
* @param pixelCrop
* @param rotation
* @returns
*/
export const getCroppedImg = async (
imageSrc: string,
pixelCrop: CropArea,
rotation = 0
): Promise<string> => {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Error getting 2d rendering context');
}
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2);
ctx.rotate(getRadianAngle(rotation));
ctx.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
const data = ctx.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
return new Promise((resolve) => {
canvas.toBlob((file) => {
resolve(URL.createObjectURL(file!));
}, 'image/png');
});
};
+3 -3
View File
@@ -182,9 +182,9 @@ class AuthStore {
return;
}
// Refresh if token expires in less than 2 weeks
const threeDays = 2 * 7 * 24 * 60 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < threeDays) {
// Refresh if token expires in less than 14 days
const fourteenDays = 14 * 24 * 60 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < fourteenDays) {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
+4 -4
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+152
View File
@@ -28,6 +28,8 @@
import { Separator } from '$lib/components/ui/separator';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Dialog from '$lib/components/ui/dialog';
import Cropper from 'svelte-easy-crop';
// =============== Auth & Page State ===============
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
@@ -80,6 +82,96 @@
let userData = $state<User | null>(null);
let loadingUser = $state(true);
let stamps = $state(0);
let uploadingPic = $state(false);
// Image cropper state
let cropDialogOpen = $state(false);
let cropImageUrl = $state('');
let cropArea = $state<{ x: number; y: number; width: number; height: number } | null>(null);
let crop = $state({ x: 0, y: 0 });
let zoom = $state(1);
let previewUrl = $state('');
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
cropImageUrl = URL.createObjectURL(file);
cropDialogOpen = true;
}
}
async function handleCropSave() {
if (!cropArea || !cropImageUrl) return;
const img = new Image();
img.src = cropImageUrl;
await new Promise(resolve => { img.onload = resolve; });
const canvas = document.createElement('canvas');
canvas.width = 350;
canvas.height = 350;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(
img,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, 350, 350
);
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
previewUrl = url;
handleProfilePicUpload(blob).then(() => {
URL.revokeObjectURL(cropImageUrl);
cropImageUrl = '';
cropArea = null;
cropDialogOpen = false;
});
}, 'image/jpeg', 0.9);
}
function handleCropCancel() {
if (cropImageUrl) {
URL.revokeObjectURL(cropImageUrl);
}
cropImageUrl = '';
cropArea = null;
cropDialogOpen = false;
}
async function handleProfilePicUpload(blob: Blob) {
uploadingPic = true;
try {
const formData = new FormData();
formData.append('file', blob, 'profile.jpg');
const uploadResponse = await fetch('/api/user/profile-picture', {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.currentToken}`
},
body: formData
});
if (uploadResponse.ok) {
const data = await uploadResponse.json();
if (userData) {
userData.profilePicUrl = data.url;
}
toast.success('Profile picture updated');
} else {
toast.error('Failed to upload profile picture');
}
} catch (err) {
console.error('Upload error:', err);
toast.error('Failed to upload profile picture');
} finally {
uploadingPic = false;
}
}
// =============== Phone Edit Mode ===============
let editingPhone = $state(false);
@@ -607,6 +699,66 @@
<Card.Description>Your personal details and account information</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if userData}
{@const initials = userData.firstName && userData.lastName ? userData.firstName.split(' ').map(n => n[0]).join('') + userData.lastName.split(' ').map(n => n[0]).join('') : ''}
{@const hasImage = !!userData.profilePicUrl || !!previewUrl}
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
<div class="flex flex-col items-center gap-4">
{#if hasImage || initials}
{#if hasImage}
<img src={displayUrl} alt="Profile" class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200" />
{:else}
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200">
{initials}
</div>
{/if}
{:else}
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
{/if}
<Button variant="outline" onclick={() => document.getElementById('profile-pic-input')?.click()}>
Upload profile picture
</Button>
<input
id="profile-pic-input"
type="file"
accept="image/*"
class="hidden"
onchange={handleFileSelect}
/>
</div>
{/if}
<Dialog.Root bind:open={cropDialogOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>Crop Profile Picture</Dialog.Title>
</Dialog.Header>
<div class="relative h-64 w-full">
{#if cropImageUrl}
<Cropper
image={cropImageUrl}
aspect={1}
cropShape="round"
showGrid={false}
bind:crop
bind:zoom
oncropcomplete={(e) => {
cropArea = e.pixels;
}}
/>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={handleCropCancel}>Cancel</Button>
<Button onclick={handleCropSave}>Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#if loadingUser}
{#each Array(6) as _, i (i)}
<Skeleton class="h-12 w-full" />
+55 -8
View File
@@ -1,15 +1,62 @@
<script lang="ts">
import ContactCard from '$lib/components/layout/ContactCard.svelte';
import { onMount } from 'svelte';
type ContactInfo = {
name: string;
role: string;
phone: string;
email: string;
profilePicUrl?: string;
};
let contact = $state<ContactInfo | null>(null);
let loading = $state(true);
onMount(async () => {
try {
const res = await fetch('/api/contact');
if (res.ok) {
contact = await res.json();
}
} catch (err) {
console.error('Failed to load contact info:', err);
} finally {
loading = false;
}
});
</script>
<section class="py-12">
<h1 class="mb-8 text-center text-2xl font-semibold">Contact Me</h1>
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
instagram="crussell"
address="Business Centre, Office Street, Work"
/>
{#if loading}
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
<div class="mb-4 flex justify-center">
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
</div>
<div class="mb-4 text-center">
<div class="mx-auto mb-2 h-6 w-40 rounded bg-gray-200"></div>
<div class="mx-auto h-4 w-32 rounded bg-gray-200"></div>
</div>
</div>
{:else if contact}
<ContactCard
name={contact.name}
role={contact.role}
phone={contact.phone}
email={contact.email}
instagram="crussell"
address="Business Centre, Office Street, Work"
profileImage={contact.profilePicUrl || 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
/>
{:else}
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
instagram="crussell"
address="Business Centre, Office Street, Work"
/>
{/if}
</section>
+167
View File
@@ -0,0 +1,167 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
let bookings = $state<any[]>([]);
let loading = $state(false);
let selectedBookingId = $state<string | null>(null);
let showBookingModal = $state(false);
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
goto('/login', { replaceState: true });
return;
}
pageState = 'authorized';
fetchBookings();
});
async function fetchBookings() {
loading = true;
try {
const today = new Date().toISOString().split('T')[0];
const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!response.ok) {
toast.error('Failed to load bookings');
return;
}
const data = await response.json();
const now = new Date();
bookings = (data.bookings || [])
.filter((b: any) => {
const startTime = new Date(b.start_time);
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
})
.sort(
(a: any, b: any) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
} catch (err) {
console.error('Error fetching bookings:', err);
toast.error('Network error');
} finally {
loading = false;
}
}
function openBooking(id: string) {
selectedBookingId = id;
showBookingModal = true;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-gray-100 text-gray-800'
};
</script>
{#if pageState === 'loading'}
<div class="mx-auto max-w-4xl p-6">
<div class="animate-pulse space-y-4">
<div class="h-8 w-48 rounded bg-gray-200"></div>
<div class="h-64 rounded bg-gray-200"></div>
</div>
</div>
{:else if pageState === 'unauthorized'}
<div class="mx-auto max-w-4xl p-6 text-center">
<p>Please log in to view your schedule.</p>
</div>
{:else}
<div class="mx-auto max-w-4xl p-6">
<h1 class="mb-6 text-2xl font-bold">My Schedule</h1>
{#if loading}
<div class="text-center">Loading...</div>
{:else if bookings.length === 0}
<Card.Root>
<Card.Header>
<Card.Title>No Upcoming Appointments</Card.Title>
<Card.Description>You don't have any upcoming appointments.</Card.Description>
</Card.Header>
<Card.Content>
<Button href="/book">Book an Appointment</Button>
</Card.Content>
</Card.Root>
{:else}
<div class="space-y-4">
{#each bookings as booking (booking.id)}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div>
<Card.Title class="text-lg">
{new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Card.Title>
<Card.Description>
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit'
})}
{#if booking.duration_minutes}
<span class="text-gray-500"> · {booking.duration_minutes} min</span>
{/if}
</Card.Description>
</div>
<span
class="rounded-full px-2 py-1 text-xs font-medium {statusColors[booking.status] ||
'bg-gray-100 text-gray-800'}"
>
{booking.status}
</span>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div>
{#if booking.services && booking.services.length > 0}
<p class="font-medium">
{booking.services.map((s: any) => s.service_name).join(', ')}
</p>
{/if}
{#if booking.total_amount}
<p class="text-sm text-gray-500">£{booking.total_amount.toFixed(2)}</p>
{/if}
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openBooking(booking.id)}>View Details</Button>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</div>
{/if}
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId || ''} />
+8 -3
View File
@@ -51,6 +51,8 @@ CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT g
CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_referral_code()
RETURNS CHAR(12) AS $$
DECLARE
@@ -107,6 +109,8 @@ CREATE TABLE users (
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Deposit tracking: remaining deposits needed (0-3). Reduces by 1 when booking with payment completes.
deposits_required INT NOT NULL DEFAULT 3,
-- staff fields
notes TEXT
);
@@ -134,7 +138,7 @@ CREATE TYPE verification_purpose AS ENUM ('email_verify', 'password_reset');
CREATE TABLE verification_codes (
id BIGSERIAL PRIMARY KEY,
code CHAR(32) NOT NULL UNIQUE,
code CHAR(12) NOT NULL UNIQUE DEFAULT generate_verification_code(),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose verification_purpose NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
@@ -273,7 +277,8 @@ CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications
-- =======================================
-- PAYMENTS TABLE
-- =======================================
-- PAYMENTS TABLE
-- =======================================
CREATE SEQUENCE invoice_number_seq
START WITH 1
INCREMENT BY 1
@@ -360,7 +365,7 @@ INSERT INTO business_settings (
'https://www.website.co.uk'
);
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim');
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid');
CREATE TABLE admin_notifications (
id SERIAL PRIMARY KEY,
+45 -14
View File
@@ -46,8 +46,8 @@
- [x] `/api/admin/bookings/{id}/progress` - Progress booking status
- [x] `/api/admin/bookings/{id}/confirm` - Confirm booking
- [x] `/api/admin/bookings/{id}/cancel` - Cancel booking
- [ ] **In-progress auto-infer** - Status should auto-set based on time
- [ ] **Begin button on Today** - Manual start for early arrivals (gray out if >3hrs away)
- [x] **In-progress auto-infer** - Status auto-sets based on time (confirmed → in_progress → completed)
- [x] **Auto-complete** - Bookings auto-complete when duration elapses
#### Admin Endpoints
- [x] `/api/admin/services` - Create, delete, list, toggle
@@ -69,11 +69,24 @@
#### User Endpoints
- [x] `/api/user/profile` - GET, PUT
- [x] `/api/user/profile-picture` - POST upload profile picture (separate bucket)
- [x] `/api/user/account` - DELETE (GDPR compliant)
- [x] `/api/user/loyalty` - GET loyalty stamps
- [x] `/api/contact` - Public endpoint returning first admin's contact info (name, phone, email, profilePicUrl)
- [ ] **GDPR data export** - `export_all_user_data()` exists but not wired to endpoint
- [ ] **Tax data export** - Admin endpoint for tax-software-compatible format
#### Deposits System (Simplified)
- [x] `users.deposits_required` INT DEFAULT 3
- [x] 48h notice required when `deposits_required > 0`
- [x] Reduces by 1 when booking completes with payment
- [x] Increases by 3 on <12h cancellation (bad behavior)
- [ ] Frontend display of deposits_required
#### CalDAV Contact Sync
- [x] Profile photos synced to CardDAV contacts (PHOTO field in vCard)
- [x] Auto-updates when profile is changed
#### Not Yet Wired
- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
- [ ] Analytics (`handlers/admin/analytics.go` exists, not imported)
@@ -91,13 +104,15 @@
#### Core Pages
- [x] Home (`/`)
- [x] Prices (`/prices`)
- [x] Contact (`/contact`)
- [x] Contact (`/contact`) - Dynamic, fetches from `/api/contact`
- [x] Book (`/book`) - Full wizard with service selection, date/time, customer details
- [x] Portfolio (`/portfolio`) - S3/R2 storage with tag filtering, category filters, pagination, ?img= featured image, admin upload
- [x] Today (`/today`) - Admin only, real-time schedule view
- [x] Account (`/account`)
- [x] Today (`/today`) - Admin only, real-time schedule view with auto-status transitions
- [x] Schedule (`/schedule`) - User's upcoming bookings with .ics export
- [x] Account (`/account`) - Profile management, profile picture upload with cropper
- [x] Login (`/login`)
- [x] Manage (`/manage`)
- [x] Manage (`/manage`)
#### Admin Dashboard (`/admin`)
- [x] Auth guard with role check
@@ -149,6 +164,7 @@
- [x] CardDAV sync for contacts (SabreDAV)
- [x] CalDAV ready
- [x] Profile pics bucket - separate bucket `crussell-profile-pics` for user profile pictures
- [ ] Email/SMS reminders - not yet implemented
- [ ] Square payment - placeholder only
- [x] S3/R2 image hosting - Rustfs for dev, Cloudflare R2 for prod via build tags
@@ -356,17 +372,21 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
| Task | Description | Files Affected |
| ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------- |
| **Customer booking submit** | `submitBooking()` at line 600 only logs, needs `POST /api/bookings` | `frontend/src/lib/components/booking/BookingFlow.svelte` |
| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600`, `BookingCreateModal.svelte:224` | Frontend components |
| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600` | Frontend components |
| **Guest user endpoint** | Create `/api/users/guest` for walk-in bookings | `backend/handlers/user/` (new file) |
| **In-progress auto-infer** | Auto-set `in_progress` status based on time | Backend booking logic |
| ~~In-progress auto-infer~~ | ~~Auto-set `in_progress` status based on time~~ DONE | Backend booking logic |
| ~~Auto-complete~~ | ~~Auto-complete bookings when duration elapses~~ DONE | Backend today handlers |
| ~~Profile picture upload~~ | ~~Upload with cropper to separate bucket, sync to CalDAV~~ DONE | Backend + Account page |
| ~~Contact page dynamic~~ | ~~Fetch from `/api/contact` using first admin~~ DONE | Backend + Contact page |
| ~~Simplified deposits~~ | ~~`deposits_required` INT on users, 48h check, reduce on payment~~ DONE | Backend booking logic |
| **Begin button (Today)** | Manual start for early arrivals, gray out if >3hrs away | `CurrentAppointment.svelte` + backend |
| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals |
| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours |
| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic |
| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` |
| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step |
| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page |
| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page |
| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals |
| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours |
| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic |
| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` |
| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step |
| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page |
| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page |
### Medium Priority
@@ -402,6 +422,17 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
| `POSTGRES_USER` | Database username | Docker |
| `POSTGRES_PASSWORD` | Database password | Docker |
| `POSTGRES_DB` | Database name | Docker |
| `S3_BUCKET` | Main image bucket (portfolio) | No (default: crussell) |
| `S3_PROFILE_PICS_BUCKET` | Profile pictures bucket | No (default: crussell-profile-pics) |
| `S3_ENDPOINT` | S3/Rustfs endpoint | Dev |
| `S3_PUBLIC_URL` | Public URL for S3 bucket | Dev |
| `S3_ACCESS_KEY` | S3 access key | Dev |
| `S3_SECRET_KEY` | S3 secret key | Dev |
| `R2_ENDPOINT` | Cloudflare R2 endpoint | Prod |
| `R2_BUCKET` | R2 bucket name | Prod |
| `R2_PUBLIC_URL` | R2 public URL | Prod |
| `R2_ACCESS_KEY` | R2 access key | Prod |
| `R2_SECRET_KEY` | R2 secret key | Prod |
---
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Crussell",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}