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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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, ¬es, &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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,6 +193,7 @@
|
||||
|
||||
{#if activeAppointment}
|
||||
{#if isInProgress}
|
||||
<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
|
||||
@@ -201,10 +202,13 @@
|
||||
<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
|
||||
{/if}
|
||||
</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}
|
||||
</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> & {
|
||||
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"
|
||||
<!-- 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)}
|
||||
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}
|
||||
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;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{#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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
<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>
|
||||
{#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"
|
||||
@@ -12,4 +58,5 @@
|
||||
instagram="crussell"
|
||||
address="Business Centre, Office Street, Work"
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -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 || ''} />
|
||||
@@ -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,
|
||||
|
||||
@@ -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,9 +372,13 @@ 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 |
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Crussell",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user