commit 708b741a323eda47fd40bfa2ed4c50737e4fdccb Author: Stephen Adamson Date: Sun Oct 12 22:13:19 2025 +0100 Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e06dc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,99 @@ +# ------------------------------------ +# 1. Project-wide Secrets and Config +# ------------------------------------ + +# Environment/Secrets files +.env +.env.* +.env_shared +*/.env +*/.env.* + +# Local Certificate files +nginx/certs/ + +# Docker volumes +pgdata/ + +# ------------------------------------ +# 2. Go (Backend) +# ------------------------------------ + +# Compiled binaries +backend/bin/ +backend/vendor/ + +# macOS/Linux standard binary names +backend/app +backend/main + +# Dependency management cache +backend/.DS_Store + +# Go build cache +.cache/ + +# Go test outputs +*.out +!go.mod +!go.sum + +# ------------------------------------ +# 3. Node/Svelte (Frontend) +# ------------------------------------ + +# Dependencies +frontend/node_modules +frontend/package-lock.json + +# SvelteKit/Vite build artifacts and cache +frontend/.svelte-kit +frontend/.vite +frontend/.svelte2tsx-language-server-files +frontend/build/ +frontend/dist/ + +# ------------------------------------ +# 4. PHP/SabreDAV +# ------------------------------------ + +# Composer dependencies +sabredav/vendor/ + +# ------------------------------------ +# 5. Local Tools and Notes +# ------------------------------------ + +# Obsidian notes and config +obsidian/ + +# Bruno testing environments (keep collection files, ignore secrets) +bruno/*/environments/ + +# IDE files (e.g., VS Code) +.vscode/ + +# OS-generated files +.DS_Store +.Trash/ +Thumbs.db + +# ------------------------------------ +# Git-specific +# ------------------------------------ +*.orig # Git merge conflict files + +# ------------------------------------ +# Database dumps/backups +# ------------------------------------ +*.sql.gz +*.dump + +# ------------------------------------ +# Nginx +# ------------------------------------ +nginx/logs/ +nginx/*.log + +# Temp files +frontend/node_modules/.vite-temp \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3afeb76 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:latest + +WORKDIR /app + +# Copy in your prebuilt Go binary (from local ./backend/bin/backend) +COPY bin/backend ./backend + +# Copy env file if you want to bake it in (or mount via volume/env_file in compose) +COPY .env ./ + +EXPOSE 8080 + +ENTRYPOINT ["./backend"] diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go new file mode 100644 index 0000000..d9ec1d1 --- /dev/null +++ b/backend/auth/jwt.go @@ -0,0 +1,56 @@ +// auth/jwt.go +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/go-chi/jwtauth/v5" +) + +var TokenAuth *jwtauth.JWTAuth + +// AuthResponse is the response structure for login/refresh endpoints +type AuthResponse struct { + Token string `json:"token"` +} + +func InitJWT(secret string) { + TokenAuth = jwtauth.New("HS256", []byte(secret), nil) +} + +// GenerateToken creates a JWT with user_id and role +func GenerateToken(userID string, role string) (string, error) { + _, tokenString, err := TokenAuth.Encode(map[string]interface{}{ + "user_id": userID, + "role": role, + "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days + }) + return tokenString, err +} + +// VerifyToken validates JWT and returns user_id and role +func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, err error) { + token, err := TokenAuth.Decode(tokenString) + if err != nil { + return "", "", err + } + + claims, err := token.AsMap(ctx) + if err != nil { + return "", "", err + } + + userID, ok := claims["user_id"].(string) + if !ok { + return "", "", fmt.Errorf("invalid user_id claim") + } + + role, ok = claims["role"].(string) + if !ok { + return "", "", fmt.Errorf("invalid role claim") + } + + return userID, role, nil +} diff --git a/backend/auth/password.go b/backend/auth/password.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/backend/auth/password.go @@ -0,0 +1 @@ +package auth diff --git a/backend/db/db.go b/backend/db/db.go new file mode 100644 index 0000000..0741fd6 --- /dev/null +++ b/backend/db/db.go @@ -0,0 +1,65 @@ +//go:build !dev +// +build !dev + +package db + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var DB *pgxpool.Pool + +func Connect() error { + // Connect to Postgres on local network (127.x.x.x) + dsn := fmt.Sprintf( + "postgres://%s:%s@%s:5432/%s", + getEnv("POSTGRES_USER"), + getEnv("POSTGRES_PASSWORD"), + getEnv("POSTGRES_HOST"), + getEnv("POSTGRES_DB"), + ) + + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return err + } + + DB = pool + + err = testDB() + if err != nil { + return err + } + + return nil +} + +func testDB() error { + ctx := context.Background() + conn, err := DB.Acquire(ctx) + if err != nil { + return err + } + defer conn.Release() + + row := conn.QueryRow(ctx, "SELECT 1") + var result int + err = row.Scan(&result) + if err != nil { + return err + } + return nil +} + +func getEnv(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + log.Fatal("FATAL: Environment variable not set:", key) + return "" +} diff --git a/backend/db/db_dev.go b/backend/db/db_dev.go new file mode 100644 index 0000000..d5f973b --- /dev/null +++ b/backend/db/db_dev.go @@ -0,0 +1,64 @@ +//go:build dev +// +build dev + +package db + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var DB *pgxpool.Pool + +func Connect() error { + // Connect to Postgres inside Docker network + dsn := fmt.Sprintf( + "postgres://%s:%s@localhost:5432/%s?sslmode=disable", + getEnv("POSTGRES_USER"), + getEnv("POSTGRES_PASSWORD"), + getEnv("POSTGRES_DB"), + ) + + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return err + } + + DB = pool + + err = testDB() + if err != nil { + return err + } + + return nil +} + +func testDB() error { + ctx := context.Background() + conn, err := DB.Acquire(ctx) + if err != nil { + return err + } + defer conn.Release() + + row := conn.QueryRow(ctx, "SELECT 1") + var result int + err = row.Scan(&result) + if err != nil { + return err + } + return nil +} + +func getEnv(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + log.Fatal("FATAL: Environment variable not set:", key) + return "" +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c6d2a13 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,34 @@ +module crussell + +go 1.25.1 + +require ( + github.com/go-chi/jwtauth/v5 v5.3.3 + golang.org/x/text v0.30.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect + golang.org/x/sync v0.17.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 + github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgx/v5 v5.7.6 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/nyaruka/phonenumbers v1.6.6 + github.com/segmentio/asm v1.2.1 // indirect + golang.org/x/crypto v0.43.0 + golang.org/x/sys v0.37.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..a6520d7 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,62 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= +github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/nyaruka/phonenumbers v1.6.6 h1:cZv5/vslJh65zuOrLjdVDHKHzVEwVuUsXAPQi3bjGJU= +github.com/nyaruka/phonenumbers v1.6.6/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA= +golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/handlers/admin/analytics.go b/backend/handlers/admin/analytics.go new file mode 100644 index 0000000..d78da5d --- /dev/null +++ b/backend/handlers/admin/analytics.go @@ -0,0 +1 @@ +package admin diff --git a/backend/handlers/admin/bookings.go b/backend/handlers/admin/bookings.go new file mode 100644 index 0000000..d78da5d --- /dev/null +++ b/backend/handlers/admin/bookings.go @@ -0,0 +1 @@ +package admin diff --git a/backend/handlers/admin/users.go b/backend/handlers/admin/users.go new file mode 100644 index 0000000..d78da5d --- /dev/null +++ b/backend/handlers/admin/users.go @@ -0,0 +1 @@ +package admin diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go new file mode 100644 index 0000000..7eec7ee --- /dev/null +++ b/backend/handlers/auth/local.go @@ -0,0 +1,354 @@ +package auth + +import ( + "context" + "crussell/auth" + "crussell/db" + "crussell/internal/dav" + "crussell/mw" + "encoding/json" + "fmt" + "log" + "net/http" + "net/mail" + "regexp" + "strings" + "sync" + "time" + + "github.com/nyaruka/phonenumbers" + "golang.org/x/crypto/bcrypt" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + titleCaser = cases.Title(language.English) +) + +// Login state management +var ( + loginStateMu sync.Mutex + loginInProgress = make(map[string]bool) + loginAttempts = make(map[string]time.Time) +) + +func init() { + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + loginStateMu.Lock() + now := time.Now() + for userID, lastAttempt := range loginAttempts { + // Remove attempts older than 1 hour + if now.Sub(lastAttempt) > 1*time.Hour { + delete(loginAttempts, userID) + } + } + loginStateMu.Unlock() + } + }() +} + +type RegisterRequest struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Password string `json:"password"` + Phone string `json:"phone"` + DateOfBirth string `json:"dateOfBirth"` + AgreedToPolicy bool `json:"agreedToPolicy"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// POST /api/register +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Must accept terms + if !req.AgreedToPolicy { + http.Error(w, "must agree to terms", http.StatusBadRequest) + return + } + + // Normalize input + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.FirstName = strings.TrimSpace(req.FirstName) + req.LastName = strings.TrimSpace(req.LastName) + req.Phone = strings.TrimSpace(req.Phone) + req.DateOfBirth = strings.TrimSpace(req.DateOfBirth) + + // Check required fields + if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" || req.DateOfBirth == "" { + http.Error(w, "all fields are required", http.StatusBadRequest) + return + } + + // Validate name (unicode letters, spaces, hyphen, apostrophe, dot) + nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`) + + if !nameRegex.MatchString(req.FirstName) { + http.Error(w, "invalid characters in name", http.StatusBadRequest) + return + } + + // Validate length + if len(req.FirstName) > 50 || len(req.FirstName) < 1 { + http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest) + return + } + if len(req.LastName) > 50 || len(req.LastName) < 1 { + http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest) + return + } + + // Validate email format + _, err := mail.ParseAddress(req.Email) + if err != nil { + http.Error(w, "invalid email format", http.StatusBadRequest) + return + } + + // Normalize phone (remove spaces, hyphens, parentheses) + req.Phone = strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' || r == '+' { + return r + } + return -1 + }, req.Phone) + + // Validate UK phone number + phone, err := ValidateUKPhoneNumber(req.Phone) + if err != nil { + http.Error(w, "invalid phone number format", http.StatusBadRequest) + return + } + req.Phone = strings.TrimSpace(phone) + + // Convert names to title case + req.FirstName = titleCaser.String(strings.ToLower(req.FirstName)) + req.LastName = titleCaser.String(strings.ToLower(req.LastName)) + + // Parse date of birth + dob, err := time.Parse("2006-01-02", req.DateOfBirth) + if err != nil { + http.Error(w, "invalid date format", http.StatusBadRequest) + return + } + + // Reject if younger than 16 + if !dob.Before(time.Now().AddDate(-16, 0, 0)) { + http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest) + return + } + + // Hash password + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + fmt.Println(err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + now := time.Now() + + // Insert and return the generated ID + var userID string + err = tx.QueryRow(r.Context(), ` + INSERT INTO users + (n_first_name, n_last_name, phone, date_of_birth, email, password_hash, + account_type, privacy_policy_and_terms_consent, policy_consent_updated_at, + created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8) + RETURNING id + `, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID) + + if err != nil { + fmt.Println(err) + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, "an account with this email already exists", http.StatusConflict) + } else { + http.Error(w, "could not create user", http.StatusInternalServerError) + } + return + } + + if err := tx.Commit(r.Context()); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + go func() { + input := dav.ContactInput{ + UserID: userID, + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + Phone: req.Phone, + DOB: req.DateOfBirth, + } + if err := dav.Service.CreateContact(1, userID, input); err != nil { + log.Printf("Warning: Failed to create contact in DAV for user %s: %v", userID, err) + } + }() + + w.WriteHeader(http.StatusCreated) +} + +func ValidateUKPhoneNumber(phone string) (string, error) { + num, err := phonenumbers.Parse(phone, "GB") + if err != nil { + return "", err + } + + if !phonenumbers.IsValidNumber(num) { + return "", fmt.Errorf("invalid phone number") + } + + // Check if it's actually a UK number + if phonenumbers.GetRegionCodeForNumber(num) != "GB" { + return "", fmt.Errorf("only UK numbers allowed") + } + + // Format in E.164 format (+44...) + return phonenumbers.Format(num, phonenumbers.E164), nil +} + +// POST /api/login +func LoginHandler(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Normalize email + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + + var userID, passwordHash, role string + ctx := context.Background() + err := db.DB.QueryRow(ctx, ` + SELECT id, password_hash, account_role + FROM users + WHERE email = $1 AND account_type = 'email' + `, req.Email).Scan(&userID, &passwordHash, &role) + + if err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + // Check if user is already logging in + loginStateMu.Lock() + if loginInProgress[userID] { + loginStateMu.Unlock() + http.Error(w, "login already in progress", http.StatusConflict) // 409 + return + } + loginInProgress[userID] = true + loginStateMu.Unlock() + + // Always clear flag when done + defer func() { + loginStateMu.Lock() + delete(loginInProgress, userID) + loginStateMu.Unlock() + }() + + // Enforce 1 attempt per 5s + loginStateMu.Lock() + if last, ok := loginAttempts[userID]; ok { + since := time.Since(last) + if since < 5*time.Second { + wait := 5*time.Second - since + loginStateMu.Unlock() + time.Sleep(wait) + } else { + loginStateMu.Unlock() + } + } else { + loginStateMu.Unlock() + } + + // Verify password + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { + loginStateMu.Lock() + loginAttempts[userID] = time.Now() + loginStateMu.Unlock() + + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + // On success, clear attempts + loginStateMu.Lock() + delete(loginAttempts, userID) + loginStateMu.Unlock() + + // Update last login + _, err = db.DB.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID) + if err != nil { + fmt.Println("Failed to update last_login_at:", err) + } + + // Generate JWT + tokenString, err := auth.GenerateToken(userID, role) + if err != nil { + http.Error(w, "could not generate token", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString}) +} + +// POST /api/refresh-token (requires auth middleware) +func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := mw.GetUserID(r.Context()) + role, _ := mw.GetUserRole(r.Context()) + + // Verify user still exists and role hasn't changed + var currentRole string + err := db.DB.QueryRow(r.Context(), ` + SELECT account_role FROM users WHERE id = $1 + `, userID).Scan(¤tRole) + + if err != nil { + http.Error(w, "user not found", http.StatusUnauthorized) + return + } + + // If role changed, force re-login + if currentRole != role { + http.Error(w, "role changed, please log in again", http.StatusUnauthorized) + return + } + + // Generate new token + newToken, err := auth.GenerateToken(userID, currentRole) + if err != nil { + http.Error(w, "could not generate token", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken}) +} diff --git a/backend/handlers/auth/social.go b/backend/handlers/auth/social.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/backend/handlers/auth/social.go @@ -0,0 +1 @@ +package auth diff --git a/backend/handlers/user/account.go b/backend/handlers/user/account.go new file mode 100644 index 0000000..7b70973 --- /dev/null +++ b/backend/handlers/user/account.go @@ -0,0 +1,12 @@ +package user + +import ( + "net/http" +) + +// DELETE /api/user/account +func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { + // TODO: Delete user's data + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/handlers/user/loyalty.go b/backend/handlers/user/loyalty.go new file mode 100644 index 0000000..4ba2703 --- /dev/null +++ b/backend/handlers/user/loyalty.go @@ -0,0 +1,34 @@ +package user + +import ( + "encoding/json" + "net/http" + + "crussell/db" + "crussell/mw" +) + +type LoyaltyResponse struct { + Stamps int `json:"stamps"` + ReferralCode string `json:"referralCode"` +} + +// GET /api/user/loyalty +func GetLoyaltyHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := mw.GetUserID(r.Context()) + + var loyalty LoyaltyResponse + err := db.DB.QueryRow(r.Context(), ` + SELECT loyalty_stamps, referral_code + FROM users + WHERE id = $1 + `, userID).Scan(&loyalty.Stamps, &loyalty.ReferralCode) + + if err != nil { + http.Error(w, "failed to get loyalty info", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(loyalty) +} diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go new file mode 100644 index 0000000..ab0a04d --- /dev/null +++ b/backend/handlers/user/profile.go @@ -0,0 +1,209 @@ +package user + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "crussell/db" + "crussell/handlers/auth" + "crussell/mw" +) + +var titleCaser = cases.Title(language.English) + +type UserProfile struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Phone *string `json:"phone,omitempty"` + DateOfBirth *string `json:"dateOfBirth,omitempty"` + Role string `json:"role"` + LoyaltyStamps int `json:"loyaltyStamps"` + ReferralCode string `json:"referralCode"` + ProfilePicURL *string `json:"profilePicUrl,omitempty"` +} + +type UpdateProfileRequest struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Phone string `json:"phone"` +} + +// GET /api/user/profile +func GetProfileHandler(w http.ResponseWriter, r *http.Request) { + userID, ok := mw.GetUserID(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var user UserProfile + err := db.DB.QueryRow(r.Context(), ` + SELECT + id, email, n_first_name, n_last_name, phone, + date_of_birth::text, account_role, loyalty_stamps, + referral_code, profile_pic_url + FROM users + WHERE id = $1 + `, userID).Scan( + &user.ID, &user.Email, &user.FirstName, &user.LastName, + &user.Phone, &user.DateOfBirth, &user.Role, + &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, + ) + + if err != nil { + http.Error(w, "user not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} + +// 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 + 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) + + vcard := fmt.Sprintf(`BEGIN:VCARD +VERSION:3.0 +UID:%s +FN:%s %s +N:%s;%s;;; +EMAIL;TYPE=INTERNET:%s +TEL;TYPE=CELL:%s +BDAY:%s +REV:%s +END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, timestamp) + + // PUT updated vCard + req, err := http.NewRequest("PUT", url, bytes.NewBufferString(vcard)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "text/vcard; charset=utf-8") + req.SetBasicAuth("admin", "admin") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to update CardDAV: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("CardDAV returned status: %d", resp.StatusCode) + } + + return nil +} + +// PUT /api/user/profile +func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := mw.GetUserID(r.Context()) + + var req UpdateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Normalize input + req.FirstName = strings.TrimSpace(req.FirstName) + req.LastName = strings.TrimSpace(req.LastName) + req.Phone = strings.TrimSpace(req.Phone) + + // Required fields + if req.FirstName == "" || req.LastName == "" || req.Phone == "" { + http.Error(w, "first name, last name and phone are required", http.StatusBadRequest) + return + } + + // Validate names (unicode letters, spaces, hyphen, apostrophe, dot) + nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`) + + if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) { + http.Error(w, "invalid characters in name", http.StatusBadRequest) + return + } + + // Validate lengths + if len(req.FirstName) < 1 || len(req.FirstName) > 50 { + http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest) + return + } + if len(req.LastName) < 1 || len(req.LastName) > 50 { + http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest) + return + } + + // Normalize phone (strip spaces, hyphens, brackets) + req.Phone = strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '+' { + return r + } + return -1 + }, req.Phone) + + // Validate UK phone number + phone, err := auth.ValidateUKPhoneNumber(req.Phone) + if err != nil { + http.Error(w, "invalid phone number format", http.StatusBadRequest) + return + } + req.Phone = strings.TrimSpace(phone) + + // Title case names + req.FirstName = titleCaser.String(strings.ToLower(req.FirstName)) + req.LastName = titleCaser.String(strings.ToLower(req.LastName)) + + // Fetch user's email and DOB for CardDAV update + var email string + var dob time.Time + err = db.DB.QueryRow(r.Context(), ` + SELECT email, date_of_birth FROM users WHERE id = $1 + `, userID).Scan(&email, &dob) + + if err != nil { + http.Error(w, "failed to fetch user data", http.StatusInternalServerError) + return + } + + // Update DB + _, err = db.DB.Exec(r.Context(), ` + UPDATE users + SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW() + WHERE id = $4 + `, req.FirstName, req.LastName, req.Phone, userID) + + if err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + + // 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 { + fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err) + } + }() + + w.WriteHeader(http.StatusOK) +} diff --git a/backend/internal/dav/service_dev.go b/backend/internal/dav/service_dev.go new file mode 100644 index 0000000..fd40bfd --- /dev/null +++ b/backend/internal/dav/service_dev.go @@ -0,0 +1,54 @@ +//go:build dev +// +build dev + +package dav + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var Service *BaseService + +func init() { + if err := connect(); err != nil { + log.Fatalf("failed to initialize dev service: %v", err) + } +} + +func connect() error { + dsn := fmt.Sprintf( + "postgres://%s:%s@localhost:5432/%s?sslmode=disable", + getEnv("POSTGRES_USER"), + getEnv("POSTGRES_PASSWORD"), + getEnv("POSTGRES_DB"), + ) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return err + } + + Service = newBaseService(pool) + return testDB(pool) +} + +func testDB(pool *pgxpool.Pool) error { + var n int + err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n) + if err != nil || n != 1 { + return fmt.Errorf("db test failed: %w", err) + } + return nil +} + +func getEnv(key string) string { + if v := os.Getenv(key); v != "" { + return v + } + log.Fatalf("FATAL: environment variable %s not set", key) + return "" +} diff --git a/backend/internal/dav/service_prod.go b/backend/internal/dav/service_prod.go new file mode 100644 index 0000000..41bb9ef --- /dev/null +++ b/backend/internal/dav/service_prod.go @@ -0,0 +1,55 @@ +//go:build !dev +// +build !dev + +package dav + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var Service *BaseService + +func init() { + if err := connect(); err != nil { + log.Fatalf("failed to initialize prod service: %v", err) + } +} + +func connect() error { + dsn := fmt.Sprintf( + "postgres://%s:%s@%s:5432/%s", + getEnv("POSTGRES_USER"), + getEnv("POSTGRES_PASSWORD"), + getEnv("POSTGRES_HOST"), + getEnv("POSTGRES_DB"), + ) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return err + } + + Service = newBaseService(pool) + return testDB(pool) +} + +func testDB(pool *pgxpool.Pool) error { + var n int + err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n) + if err != nil || n != 1 { + return fmt.Errorf("db test failed: %w", err) + } + return nil +} + +func getEnv(key string) string { + if v := os.Getenv(key); v != "" { + return v + } + log.Fatalf("FATAL: environment variable %s not set", key) + return "" +} diff --git a/backend/internal/dav/shared.go b/backend/internal/dav/shared.go new file mode 100644 index 0000000..b0805a7 --- /dev/null +++ b/backend/internal/dav/shared.go @@ -0,0 +1,276 @@ +package dav + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// BaseService holds shared DB connection +type BaseService struct { + db *pgxpool.Pool +} + +// newBaseService returns a new BaseService instance +func newBaseService(db *pgxpool.Pool) *BaseService { + return &BaseService{db: db} +} + +// ============================================================================ +// Calendar Functions +// ============================================================================ + +func (s *BaseService) ListEventsForMonth(year int, month time.Month) ([]CalendarEvent, error) { + start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0).Add(-time.Second) + + query := ` + SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype, + firstoccurence, lastoccurence, uid + FROM dav_calendarobjects + WHERE firstoccurence >= $1 AND firstoccurence <= $2 + ORDER BY firstoccurence + ` + return s.queryEventsWithContacts(query, start.Unix(), end.Unix()) +} + +func (s *BaseService) ListEventsBetween(start, end time.Time) ([]CalendarEvent, error) { + query := ` + SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype, + firstoccurence, lastoccurence, uid + FROM dav_calendarobjects + WHERE firstoccurence >= $1 AND firstoccurence <= $2 + ORDER BY firstoccurence + ` + return s.queryEventsWithContacts(query, start.Unix(), end.Unix()) +} + +func (s *BaseService) ListEventsTomorrow() ([]CalendarEvent, error) { + now := time.Now() + tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + dayAfter := tomorrow.Add(24 * time.Hour) + return s.ListEventsBetween(tomorrow, dayAfter) +} + +func (s *BaseService) ListEventsThisWeek() ([]CalendarEvent, error) { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { // Sunday + weekday = 7 + } + monday := now.AddDate(0, 0, -weekday+1) + monday = time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, now.Location()) + sunday := monday.AddDate(0, 0, 7) + return s.ListEventsBetween(monday, sunday) +} + +// ============================================================================ +// Contact Functions +// ============================================================================ + +func (s *BaseService) GetContactByURI(addressBookID int, uri string) (*Contact, error) { + query := ` + SELECT id, addressbookid, uri, carddata, lastmodified, etag, size + FROM dav_cards + WHERE addressbookid = $1 AND uri = $2 + ` + var c Contact + err := s.db.QueryRow(context.Background(), query, addressBookID, uri).Scan( + &c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size, + ) + if err != nil { + return nil, fmt.Errorf("contact not found: %w", err) + } + return &c, nil +} + +func (s *BaseService) ListAllContacts() ([]Contact, error) { + query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards ORDER BY lastmodified DESC` + rows, err := s.db.Query(context.Background(), query) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []Contact + for rows.Next() { + var c Contact + if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, nil +} + +func (s *BaseService) ListRecentContacts(days int) ([]Contact, error) { + cutoff := time.Now().AddDate(0, 0, -days).Unix() + query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards WHERE lastmodified >= $1 ORDER BY lastmodified DESC` + rows, err := s.db.Query(context.Background(), query, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []Contact + for rows.Next() { + var c Contact + if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, nil +} + +// ============================================================================ +// Helper Methods +// ============================================================================ + +func (s *BaseService) queryEventsWithContacts(query string, args ...interface{}) ([]CalendarEvent, error) { + rows, err := s.db.Query(context.Background(), query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []CalendarEvent + for rows.Next() { + var e CalendarEvent + if err := rows.Scan( + &e.ID, &e.CalendarID, &e.URI, &e.CalendarData, &e.LastModified, &e.Etag, &e.Size, + &e.ComponentType, &e.FirstOccurence, &e.LastOccurence, &e.UID, + ); err != nil { + return nil, err + } + e.ContactURIs = extractContactURIsFromICalendar(e.CalendarData) + events = append(events, e) + } + return events, nil +} + +func extractContactURIsFromICalendar(icalData string) []string { + var uris []string + for _, line := range strings.Split(icalData, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "ATTENDEE") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + uris = append(uris, strings.TrimSpace(parts[len(parts)-1])) + } + } + } + return uris +} + +// CreateContact adds a new contact to an address book +func (s *BaseService) CreateContact(addressBookID int, userID string, input ContactInput) error { + now := time.Now().Unix() + uri := fmt.Sprintf("%s.vcf", userID) + cardData := GenerateVCard(input) + + query := ` + INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size) + VALUES ($1, $2, $3, $4, $5, $6) + ` + _, err := s.db.Exec(context.Background(), query, + addressBookID, + uri, + cardData, + now, + fmt.Sprintf("%d", now), + len(cardData), + ) + return err +} + +// UpdateContact updates an existing contact by URI +func (s *BaseService) UpdateContact(addressBookID int, uri string, input ContactInput) error { + now := time.Now().Unix() + cardData := GenerateVCard(input) + query := ` + UPDATE dav_cards + SET carddata = $1, lastmodified = $2, etag = $3, size = $4 + WHERE addressbookid = $5 AND uri = $6 + ` + _, err := s.db.Exec(context.Background(), query, cardData, now, fmt.Sprintf("%d", now), len(cardData), addressBookID, uri) + return err +} + +// DeleteContact deletes a contact by URI +func (s *BaseService) DeleteContact(addressBookID int, uri string) error { + query := `DELETE FROM dav_cards WHERE addressbookid = $1 AND uri = $2` + _, err := s.db.Exec(context.Background(), query, addressBookID, uri) + return err +} + +// CreateEvent adds a new event to a calendar +func (s *BaseService) CreateEvent(calendarID int, input EventInput) error { + uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano()) + now := time.Now().Unix() + calendarData := GenerateICalEvent(input) + + query := ` + INSERT INTO dav_calendarobjects + (calendarid, uri, calendardata, lastmodified, etag, size, componenttype, + firstoccurence, lastoccurence, uid) + VALUES ($1, $2, $3, $4, $5, $6, 'VEVENT', $7, $8, $9) + ` + _, err := s.db.Exec(context.Background(), query, + calendarID, + uid+".ics", + calendarData, + now, + fmt.Sprintf("%d", now), + len(calendarData), + input.Start.Unix(), + input.End.Unix(), + uid, + ) + return err +} + +// UpdateEvent updates an existing calendar event by UID +func (s *BaseService) UpdateEvent(calendarID int, uid string, input EventInput) error { + now := time.Now().Unix() + calendarData := GenerateICalEvent(input) + + query := ` + UPDATE dav_calendarobjects + SET calendardata = $1, lastmodified = $2, etag = $3, size = $4, + firstoccurence = $5, lastoccurence = $6 + WHERE calendarid = $7 AND uid = $8 + ` + _, err := s.db.Exec(context.Background(), query, + calendarData, now, fmt.Sprintf("%d", now), len(calendarData), + input.Start.Unix(), input.End.Unix(), + calendarID, uid, + ) + return err +} + +// DeleteEvent deletes an event by UID +func (s *BaseService) DeleteEvent(calendarID int, uid string) error { + query := `DELETE FROM dav_calendarobjects WHERE calendarid = $1 AND uid = $2` + _, err := s.db.Exec(context.Background(), query, calendarID, uid) + return err +} + +// ListEventsForContactSQL returns all calendar events where the given contact URI is an attendee (SQL optimized) +func (s *BaseService) ListEventsForContact(contactURI string) ([]CalendarEvent, error) { + // Use pattern matching to find events containing the contact URI in ATTENDEE lines + likePattern := "%" + contactURI + "%" + + query := ` + SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype, + firstoccurence, lastoccurence, uid + FROM dav_calendarobjects + WHERE calendardata LIKE $1 + ORDER BY firstoccurence + ` + + return s.queryEventsWithContacts(query, likePattern) +} diff --git a/backend/internal/dav/types.go b/backend/internal/dav/types.go new file mode 100644 index 0000000..8f87dd9 --- /dev/null +++ b/backend/internal/dav/types.go @@ -0,0 +1,187 @@ +package dav + +import ( + "fmt" + "time" +) + +// ============================================================================ +// Calendar Types +// ============================================================================ + +type Calendar struct { + ID int `json:"id"` + PrincipalURI string `json:"principaluri"` + DisplayName string `json:"displayname"` + URI string `json:"uri"` + Description string `json:"description"` + CalendarOrder int `json:"calendarorder"` + CalendarColor string `json:"calendarcolor"` + Components string `json:"components"` +} + +type CalendarEvent struct { + ID int `json:"id"` + CalendarID int `json:"calendarid"` + URI string `json:"uri"` + CalendarData string `json:"calendardata"` + LastModified int64 `json:"lastmodified"` + Etag string `json:"etag"` + Size int `json:"size"` + ComponentType string `json:"componenttype"` + FirstOccurence int64 `json:"firstoccurence"` + LastOccurence int64 `json:"lastoccurence"` + UID string `json:"uid"` + ContactURIs []string `json:"contact_uris"` // Extracted from ATTENDEE fields +} + +type EventInput struct { + Summary string + Description string + Location string + Start time.Time + End time.Time + AllDay bool + ContactURIs []string // URIs of contacts to attach as attendees +} + +// ============================================================================ +// CardDAV Types +// ============================================================================ + +type AddressBook struct { + ID int `json:"id"` + PrincipalURI string `json:"principaluri"` + DisplayName string `json:"displayname"` + URI string `json:"uri"` + Description string `json:"description"` + SyncToken int `json:"synctoken"` +} + +type Contact struct { + ID int `json:"id"` + AddressBookID int `json:"addressbookid"` + URI string `json:"uri"` + CardData string `json:"carddata"` + LastModified int64 `json:"lastmodified"` + Etag string `json:"etag"` + Size int `json:"size"` +} + +type ContactInput struct { + UserID string + FirstName string + LastName string + Email string + Phone string + DOB string +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +// GenerateICalEvent creates iCalendar format for UK timezone +func GenerateICalEvent(input EventInput) string { + uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano()) + dtstamp := time.Now().UTC().Format("20060102T150405Z") + + var dtstart, dtend string + if input.AllDay { + dtstart = fmt.Sprintf("DTSTART;VALUE=DATE:%s", input.Start.Format("20060102")) + dtend = fmt.Sprintf("DTEND;VALUE=DATE:%s", input.End.Format("20060102")) + } else { + dtstart = fmt.Sprintf("DTSTART;TZID=Europe/London:%s", input.Start.Format("20060102T150405")) + dtend = fmt.Sprintf("DTEND;TZID=Europe/London:%s", input.End.Format("20060102T150405")) + } + + // Build attendees section + attendees := "" + for _, contactURI := range input.ContactURIs { + attendees += fmt.Sprintf("ATTENDEE;CN=%s:%s\n", contactURI, contactURI) + } + + ical := fmt.Sprintf(`BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Your App//EN +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/London +BEGIN:DAYLIGHT +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +TZNAME:BST +DTSTART:19700329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +TZNAME:GMT +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:%s +DTSTAMP:%s +%s +%s +SUMMARY:%s +DESCRIPTION:%s +LOCATION:%s +%sSEQUENCE:0 +STATUS:CONFIRMED +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR`, uid, dtstamp, dtstart, dtend, + escapeICalText(input.Summary), + escapeICalText(input.Description), + escapeICalText(input.Location), + attendees) + + return ical +} + +// GenerateVCard creates vCard format (version 3.0) +func GenerateVCard(input ContactInput) string { + vcard := fmt.Sprintf(`BEGIN:VCARD +VERSION:3.0 +UID:%s +FN:%s %s +N:%s;%s;;; +EMAIL;TYPE=INTERNET:%s +TEL;TYPE=CELL:%s +BDAY:%s +REV:%s +END:VCARD`, + input.UserID, + input.FirstName, input.LastName, + input.LastName, input.FirstName, + input.Email, + input.Phone, + input.DOB, + time.Now().UTC().Format("20060102T150405Z")) + + return vcard +} + +func escapeICalText(text string) string { + text = replaceAll(text, "\\", "\\\\") + text = replaceAll(text, "\n", "\\n") + text = replaceAll(text, ",", "\\,") + text = replaceAll(text, ";", "\\;") + return text +} + +func replaceAll(s, old, new string) string { + result := "" + for _, char := range s { + if string(char) == old { + result += new + } else { + result += string(char) + } + } + return result +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..f9fa87f --- /dev/null +++ b/backend/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "crussell/auth" + "crussell/internal/dav" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "crussell/db" + "crussell/mw" + + authHandlers "crussell/handlers/auth" + userHandlers "crussell/handlers/user" +) + +func init() { + // 1. Read the environment variable + jwtSecret := os.Getenv("JWT_SECRET_KEY") + + // 2. Add a check to ensure the secret is set + if jwtSecret == "" { + log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.") + } + + // 3. Use the environment variable for initialization + auth.InitJWT(jwtSecret) +} + +func initDB() { + if err := db.Connect(); err != nil { + log.Fatal("Failed to connect to DB:", err) + } + fmt.Println("Connected to DB successfully") +} + +func initDav() { + if dav.Service == nil { + log.Fatal("Failed to initialize DAV service") + } + fmt.Println("DAV Service connected successfully") +} + +func main() { + initDB() + initDav() + + r := chi.NewRouter() + + r.Use(middleware.RequestID) // Add X-Request-ID header + r.Use(middleware.RealIP) // Get real IP from headers + r.Use(middleware.Logger) // Basic logging + r.Use(middleware.Recoverer) // Panic recovery + r.Use(middleware.Timeout(15 * time.Second)) // Request timeout + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + next.ServeHTTP(w, r) + }) + }) + + // Public auth routes + r.Post("/api/register", authHandlers.RegisterHandler) + r.Post("/api/login", authHandlers.LoginHandler) + + // Protected routes - any authenticated user + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + + // Auth + r.Post("/api/refresh-token", authHandlers.RefreshTokenHandler) + + // User profile + r.Get("/api/user/profile", userHandlers.GetProfileHandler) + r.Put("/api/user/profile", userHandlers.UpdateProfileHandler) + r.Delete("/api/user/account", userHandlers.DeleteAccountHandler) + + // Loyalty + r.Get("/api/user/loyalty", userHandlers.GetLoyaltyHandler) + }) + + // Protected routes - verified users only + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireVerified) + + // Add booking routes, etc. + }) + + // Admin routes + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireAdmin) + + // Add admin routes + }) + + fmt.Println("Server is listening on :8080") + http.ListenAndServe(":8080", r) +} diff --git a/backend/mw/auth.go b/backend/mw/auth.go new file mode 100644 index 0000000..bd0db3b --- /dev/null +++ b/backend/mw/auth.go @@ -0,0 +1,91 @@ +package mw + +import ( + "context" + "net/http" + "strings" + + "crussell/auth" +) + +type contextKey string + +const ( + UserIDKey contextKey = "user_id" + UserRoleKey contextKey = "user_role" +) + +// RequireAuth middleware - validates JWT and adds user info to context +func RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "missing or invalid authorization header", http.StatusUnauthorized) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + userID, role, err := auth.VerifyToken(tokenString, r.Context()) + if err != nil { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + // Add user info to context + ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, UserRoleKey, role) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireRole middleware - checks if user has required role(s) +func RequireRole(allowedRoles ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role, ok := r.Context().Value(UserRoleKey).(string) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Check if user has one of the allowed roles + hasRole := false + for _, allowedRole := range allowedRoles { + if role == allowedRole { + hasRole = true + break + } + } + + if !hasRole { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// RequireVerified middleware - only allows verified_email and admin +func RequireVerified(next http.Handler) http.Handler { + return RequireRole("verified_email", "admin")(next) +} + +// RequireAdmin middleware - only allows admin +func RequireAdmin(next http.Handler) http.Handler { + return RequireRole("admin")(next) +} + +// Helper functions to get user info from context +func GetUserID(ctx context.Context) (string, bool) { + userID, ok := ctx.Value(UserIDKey).(string) + return userID, ok +} + +func GetUserRole(ctx context.Context) (string, bool) { + role, ok := ctx.Value(UserRoleKey).(string) + return role, ok +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..1c427f7 --- /dev/null +++ b/compose.yml @@ -0,0 +1,78 @@ +services: + postgres: + image: postgres:17 + container_name: postgres + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "5432:5432" # locally, do not push to prod + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-scripts/init-script.sql:/docker-entrypoint-initdb.d/init-script.sql:ro + networks: + - appnet + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: backend + restart: always + env_file: + - ./backend/.env + volumes: + - ./backend/bin:/app/bin # Mount your compiled binary + depends_on: + - postgres + networks: + - appnet + + sabredav: + image: php:8.2-fpm + container_name: sabredav + restart: always + working_dir: /var/www/dav + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./sabredav:/var/www/dav + depends_on: + - postgres + networks: + - appnet + command: > + bash -c " apt-get update && apt-get install -y git unzip libzip-dev libpq-dev && docker-php-ext-install pdo pdo_pgsql zip && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && if [ ! -f /var/www/dav/vendor/autoload.php ]; then + cd /var/www/dav && composer install --no-dev --optimize-autoloader; + fi && php-fpm " + + nginx: + image: nginx:stable + container_name: nginx + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/certs:/etc/nginx/certs + - ./frontend/build:/usr/share/nginx/html # Serve frontend + - ./sabredav:/var/www/dav + depends_on: + - backend + - sabredav + networks: + - appnet + +volumes: + pgdata: + + +networks: + appnet: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..8103a0b --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/app.css" +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..75842c4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..c5d91b4 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..2c49fa6 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,41 @@ +import prettier from 'eslint-config-prettier'; +import { fileURLToPath } from 'node:url'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..65e5d0e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,57 @@ +{ + "name": "crussell", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.22.0", + "@internationalized/date": "^3.9.0", + "@lucide/svelte": "^0.515.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.10.0", + "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", + "mode-watcher": "^1.1.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "svelte-sonner": "^1.0.5", + "sveltekit-superforms": "^2.27.1", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^1.0.0", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.3.8", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^7.0.4" + }, + "dependencies": { + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", + "swiper": "^10.3.1" + } +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..e9a1ea5 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,132 @@ +@import "tailwindcss"; + +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } +} + +/* Add this to your app.css or global styles */ +.calendar-container { + z-index: 49 !important; +} + +/* Or target the specific calendar component if needed */ +[data-calendar] { + z-index: 49 !important; +} \ No newline at end of file diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..d6ed927 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,14 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export { }; +declare module 'swiper/svelte'; \ No newline at end of file diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/components/layout/Calendar.svelte b/frontend/src/lib/components/layout/Calendar.svelte new file mode 100644 index 0000000..751b312 --- /dev/null +++ b/frontend/src/lib/components/layout/Calendar.svelte @@ -0,0 +1,70 @@ + + + + +
+ bookedDates.some((d) => d.compare(date) === 0)} + class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden" + weekdayFormat="short" + /> +
+
+
+ {#each timeSlots as time (time)} + + {/each} +
+
+
+ +
+ {#if value && selectedTime} + Your meeting is booked for + + {value.toDate(getLocalTimeZone()).toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'short' + })} + + at {selectedTime}. + {:else} + Select a date and time for your meeting. + {/if} +
+ +
+
diff --git a/frontend/src/lib/components/layout/ContactCard.svelte b/frontend/src/lib/components/layout/ContactCard.svelte new file mode 100644 index 0000000..bf6f827 --- /dev/null +++ b/frontend/src/lib/components/layout/ContactCard.svelte @@ -0,0 +1,112 @@ + + +
+
+ +
+
+ {altText} +
+
+ +
+

{name}

+

{role}

+
+ + +
+
+ + {address} +
+ +
+ + {phone} +
+ +
+ + {email} +
+ +
+ + @{instagram} +
+
+
+
diff --git a/frontend/src/lib/components/layout/NavBar.svelte b/frontend/src/lib/components/layout/NavBar.svelte new file mode 100644 index 0000000..703c3a7 --- /dev/null +++ b/frontend/src/lib/components/layout/NavBar.svelte @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/lib/components/layout/PortfolioCarousel.svelte b/frontend/src/lib/components/layout/PortfolioCarousel.svelte new file mode 100644 index 0000000..96e9ef1 --- /dev/null +++ b/frontend/src/lib/components/layout/PortfolioCarousel.svelte @@ -0,0 +1,79 @@ + + +
+
+

Our Portfolio

+
+ +
+
+ {#each [...portfolioImages, ...portfolioImages] as image} +
+ {image.alt} +
+ {/each} +
+
+
+ + diff --git a/frontend/src/lib/components/layout/RequiredLabel.svelte b/frontend/src/lib/components/layout/RequiredLabel.svelte new file mode 100644 index 0000000..880717e --- /dev/null +++ b/frontend/src/lib/components/layout/RequiredLabel.svelte @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..6cd6f79 --- /dev/null +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -0,0 +1,81 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/frontend/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/frontend/src/lib/components/ui/calendar/calendar-caption.svelte b/frontend/src/lib/components/ui/calendar/calendar-caption.svelte new file mode 100644 index 0000000..5c93037 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-caption.svelte @@ -0,0 +1,76 @@ + + +{#snippet MonthSelect()} + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> +{/snippet} + +{#snippet YearSelect()} + +{/snippet} + +{#if captionLayout === "dropdown"} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === "dropdown-months"} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === "dropdown-years"} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} +{:else} + {formatMonth(month)} {formatYear(month)} +{/if} diff --git a/frontend/src/lib/components/ui/calendar/calendar-cell.svelte b/frontend/src/lib/components/ui/calendar/calendar-cell.svelte new file mode 100644 index 0000000..5f295d6 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-day.svelte b/frontend/src/lib/components/ui/calendar/calendar-day.svelte new file mode 100644 index 0000000..32e9c83 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-day.svelte @@ -0,0 +1,35 @@ + + +span]:text-xs [&>span]:opacity-70", + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte new file mode 100644 index 0000000..8cd86de --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte new file mode 100644 index 0000000..333edc4 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte new file mode 100644 index 0000000..9032236 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid.svelte new file mode 100644 index 0000000..e0c8627 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte b/frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte new file mode 100644 index 0000000..131807e --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-header.svelte b/frontend/src/lib/components/ui/calendar/calendar-header.svelte new file mode 100644 index 0000000..5b7e397 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-heading.svelte b/frontend/src/lib/components/ui/calendar/calendar-heading.svelte new file mode 100644 index 0000000..a9b9810 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-month-select.svelte b/frontend/src/lib/components/ui/calendar/calendar-month-select.svelte new file mode 100644 index 0000000..e4b536a --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-month-select.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-month.svelte b/frontend/src/lib/components/ui/calendar/calendar-month.svelte new file mode 100644 index 0000000..e747fae --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-month.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/calendar/calendar-months.svelte b/frontend/src/lib/components/ui/calendar/calendar-months.svelte new file mode 100644 index 0000000..f717a9d --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-months.svelte @@ -0,0 +1,19 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/calendar/calendar-nav.svelte b/frontend/src/lib/components/ui/calendar/calendar-nav.svelte new file mode 100644 index 0000000..27f33d7 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-nav.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-next-button.svelte b/frontend/src/lib/components/ui/calendar/calendar-next-button.svelte new file mode 100644 index 0000000..d8eb4ef --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-next-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte b/frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte new file mode 100644 index 0000000..3e4471a --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-year-select.svelte b/frontend/src/lib/components/ui/calendar/calendar-year-select.svelte new file mode 100644 index 0000000..3842037 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-year-select.svelte @@ -0,0 +1,43 @@ + + + + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + + diff --git a/frontend/src/lib/components/ui/calendar/calendar.svelte b/frontend/src/lib/components/ui/calendar/calendar.svelte new file mode 100644 index 0000000..c7362c6 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar.svelte @@ -0,0 +1,120 @@ + + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value) + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} + diff --git a/frontend/src/lib/components/ui/calendar/index.ts b/frontend/src/lib/components/ui/calendar/index.ts new file mode 100644 index 0000000..f3a16d2 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/index.ts @@ -0,0 +1,40 @@ +import Root from "./calendar.svelte"; +import Cell from "./calendar-cell.svelte"; +import Day from "./calendar-day.svelte"; +import Grid from "./calendar-grid.svelte"; +import Header from "./calendar-header.svelte"; +import Months from "./calendar-months.svelte"; +import GridRow from "./calendar-grid-row.svelte"; +import Heading from "./calendar-heading.svelte"; +import GridBody from "./calendar-grid-body.svelte"; +import GridHead from "./calendar-grid-head.svelte"; +import HeadCell from "./calendar-head-cell.svelte"; +import NextButton from "./calendar-next-button.svelte"; +import PrevButton from "./calendar-prev-button.svelte"; +import MonthSelect from "./calendar-month-select.svelte"; +import YearSelect from "./calendar-year-select.svelte"; +import Month from "./calendar-month.svelte"; +import Nav from "./calendar-nav.svelte"; +import Caption from "./calendar-caption.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + Nav, + Month, + YearSelect, + MonthSelect, + Caption, + // + Root as Calendar, +}; diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..cf43353 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..8a91abb --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..22586e6 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..c56d8c2 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/frontend/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/frontend/src/lib/components/ui/checkbox/checkbox.svelte b/frontend/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..1622e05 --- /dev/null +++ b/frontend/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,36 @@ + + + + {#snippet children({ checked, indeterminate })} +
+ {#if checked} + + {:else if indeterminate} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/ui/checkbox/index.ts b/frontend/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/frontend/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/frontend/src/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..840b2f6 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..a647d56 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,43 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3845023 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-footer.svelte b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..e7ff446 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/dialog/dialog-header.svelte b/frontend/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..fc90cd9 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f81ad83 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-title.svelte b/frontend/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..067e55e --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..9d1e801 --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..dce1d9d --- /dev/null +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -0,0 +1,37 @@ +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"; + +const Root = DialogPrimitive.Root; +const Portal = DialogPrimitive.Portal; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/frontend/src/lib/components/ui/form/form-button.svelte b/frontend/src/lib/components/ui/form/form-button.svelte new file mode 100644 index 0000000..cc0c590 --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-button.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/form/form-description.svelte b/frontend/src/lib/components/ui/form/form-description.svelte new file mode 100644 index 0000000..a5f42be --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/form/form-element-field.svelte b/frontend/src/lib/components/ui/form/form-element-field.svelte new file mode 100644 index 0000000..c3ba111 --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-element-field.svelte @@ -0,0 +1,24 @@ + + + + {#snippet children({ constraints, errors, tainted, value })} +
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/ui/form/form-field-errors.svelte b/frontend/src/lib/components/ui/form/form-field-errors.svelte new file mode 100644 index 0000000..b4c6fba --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-field-errors.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children({ errors, errorProps })} + {#if childrenProp} + {@render childrenProp({ errors, errorProps })} + {:else} + {#each errors as error (error)} +
{error}
+ {/each} + {/if} + {/snippet} +
diff --git a/frontend/src/lib/components/ui/form/form-field.svelte b/frontend/src/lib/components/ui/form/form-field.svelte new file mode 100644 index 0000000..7481fda --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-field.svelte @@ -0,0 +1,29 @@ + + + + {#snippet children({ constraints, errors, tainted, value })} +
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/ui/form/form-fieldset.svelte b/frontend/src/lib/components/ui/form/form-fieldset.svelte new file mode 100644 index 0000000..2c85857 --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-fieldset.svelte @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/lib/components/ui/form/form-label.svelte b/frontend/src/lib/components/ui/form/form-label.svelte new file mode 100644 index 0000000..8749360 --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-label.svelte @@ -0,0 +1,24 @@ + + + + {#snippet child({ props })} + + {/snippet} + diff --git a/frontend/src/lib/components/ui/form/form-legend.svelte b/frontend/src/lib/components/ui/form/form-legend.svelte new file mode 100644 index 0000000..9d52f6a --- /dev/null +++ b/frontend/src/lib/components/ui/form/form-legend.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/ui/form/index.ts b/frontend/src/lib/components/ui/form/index.ts new file mode 100644 index 0000000..0713927 --- /dev/null +++ b/frontend/src/lib/components/ui/form/index.ts @@ -0,0 +1,33 @@ +import * as FormPrimitive from "formsnap"; +import Description from "./form-description.svelte"; +import Label from "./form-label.svelte"; +import FieldErrors from "./form-field-errors.svelte"; +import Field from "./form-field.svelte"; +import Fieldset from "./form-fieldset.svelte"; +import Legend from "./form-legend.svelte"; +import ElementField from "./form-element-field.svelte"; +import Button from "./form-button.svelte"; + +const Control = FormPrimitive.Control; + +export { + Field, + Control, + Label, + Button, + FieldErrors, + Description, + Fieldset, + Legend, + ElementField, + // + Field as FormField, + Control as FormControl, + Description as FormDescription, + Label as FormLabel, + FieldErrors as FormFieldErrors, + Fieldset as FormFieldset, + Legend as FormLegend, + ElementField as FormElementField, + Button as FormButton, +}; diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/frontend/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..19c6dae --- /dev/null +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -0,0 +1,51 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/frontend/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d0afda3 --- /dev/null +++ b/frontend/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..9e8d3e9 --- /dev/null +++ b/frontend/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; + +const Root = SelectPrimitive.Root; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, +}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..dc16d65 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,40 @@ + + + + + + + {@render children?.()} + + + + diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..5454fdb --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..49dbbd7 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..d405187 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..09d88f4 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/sonner/index.ts b/frontend/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/frontend/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/frontend/src/lib/components/ui/sonner/sonner.svelte b/frontend/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..1f50e1e --- /dev/null +++ b/frontend/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/lib/components/ui/table/index.ts b/frontend/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/frontend/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-caption.svelte b/frontend/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..1a2f033 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-footer.svelte b/frontend/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..e9dd237 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..0df769e --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
+ + {@render children?.()} +
+
diff --git a/frontend/src/lib/components/ui/textarea/index.ts b/frontend/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/frontend/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/frontend/src/lib/components/ui/textarea/textarea.svelte b/frontend/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..545b377 --- /dev/null +++ b/frontend/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts new file mode 100644 index 0000000..0c71ab9 --- /dev/null +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -0,0 +1,202 @@ +// src/lib/stores/auth.svelte.ts +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; + +export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest'; + +export interface DecodedToken { + user_id: string; + role: UserRole; + exp: number; +} + +export interface User { + id: string; + email: string; + role: UserRole; + firstName: string; + lastName: string; + phone?: string; + dateOfBirth?: string; + loyaltyStamps?: number; + referralCode?: string; + profilePicUrl?: string; +} + +class AuthStore { + private token = $state(null); + private user = $state(null); + private loading = $state(true); + + constructor() { + if (browser) { + this.initializeAuth(); + } + } + + get isAuthenticated() { + return this.token !== null && this.user !== null; + } + + get currentUser() { + return this.user; + } + + get currentToken() { + return this.token; + } + + get isLoading() { + return this.loading; + } + + private initializeAuth() { + const storedToken = localStorage.getItem('authToken'); + if (storedToken) { + const decoded = this.decodeToken(storedToken); + if (decoded && !this.isTokenExpired(decoded)) { + this.token = storedToken; + // Set basic user info from token + this.user = { + id: decoded.user_id, + role: decoded.role, + email: '', + firstName: '', + lastName: '' + }; + this.fetchUserProfile(); + } else { + this.clearAuth(); + } + } + this.loading = false; + } + + private decodeToken(token: string): DecodedToken | null { + try { + const payload = token.split('.')[1]; + const decoded = JSON.parse(atob(payload)); + return decoded; + } catch (e) { + console.error('Failed to decode token:', e); + return null; + } + } + + private isTokenExpired(decoded: DecodedToken): boolean { + return decoded.exp * 1000 < Date.now(); + } + + // Simple setters - UI handles the API calls + setToken(token: string) { + this.token = token; + if (browser) { + localStorage.setItem('authToken', token); + } + + // Decode to get basic info + const decoded = this.decodeToken(token); + if (decoded) { + this.user = { + id: decoded.user_id, + role: decoded.role, + email: '', + firstName: '', + lastName: '' + }; + this.fetchUserProfile(); + } + } + + private async fetchUserProfile() { + if (!this.token) return; + + try { + const response = await fetch('/api/user/profile', { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + throw new Error('Failed to fetch profile'); + } + + const userData = await response.json(); + this.user = userData; + } catch (error) { + console.error('Failed to fetch user profile:', error); + this.clearAuth(); + } + } + + // inside AuthStore + logout = () => { + this.clearAuth(); + goto('/'); + }; + + + private clearAuth() { + this.token = null; + this.user = null; + if (browser) { + localStorage.removeItem('authToken'); + } + } + + hasRole(requiredRole: UserRole | UserRole[]): boolean { + if (!this.user) return false; + + const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]; + return roles.includes(this.user.role); + } + + isAdmin(): boolean { + return this.hasRole('admin'); + } + + isVerified(): boolean { + return this.hasRole(['verified_email', 'admin']); + } + + // Refresh token before it expires + async refreshTokenIfNeeded() { + if (!this.token) return; + + const decoded = this.decodeToken(this.token); + if (!decoded) { + this.clearAuth(); + 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) { + try { + const response = await fetch('/api/refresh-token', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (response.ok) { + const data = await response.json(); + this.setToken(data.token); + } else { + this.clearAuth(); + } + } catch (error) { + console.error('Token refresh failed:', error); + } + } + } + + // Manual refresh method + async refreshProfile() { + await this.fetchUserProfile(); + } +} + +export const authStore = new AuthStore(); \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..55b3a91 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } 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 extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..ac3f983 --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,21 @@ + + +
+

{status}

+

+ {status === 404 + ? "Oops! The page you are looking for doesn't exist." + : 'Something went wrong. Please try again later.'} +

+ + {#if error?.message} +

{error.message}

+ {/if} + + +
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..049e796 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,47 @@ + + + + + + + + + +
+ {#if authStore.currentUser?.role === 'unverified_email'} +
+ Please verify your email address to continue. Didn't recieve the email? Check your spam + folder, or + to resend it. +
+ {/if} + {@render children?.()} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..a84308c --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,64 @@ + + +
+

Welcome to Crussell Nails

+

+ Professional beauty treatments in a calm and friendly environment. +

+ +
+ +
+

Our Services

+
+
+

Hands

+

+ Lorem hand ipsum nail dolor art sit paint amet, skin consectetur cuticle adipiscing file + elit. +

+
+
+

Feet

+

+ Lorem foot ipsum nail dolor art sit paint amet, skin consectetur cuticle adipiscing file + elit. +

+
+
+

Brows

+

+ Lorem brow ipsum eye dolor art sit shape amet, tint consectetur colour adipiscing tweaze + elit. +

+
+
+

Wax

+

+ Lorem wax ipsum leg dolor body sit sticky amet, back consectetur sack adipiscing crack elit. +

+
+
+
+ + + +
+
+

Why Choose Us?

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus + tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices + diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci + nec nonummy molestie, +

+
+
+ +
+

Ready to Treat Yourself?

+ +
diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000..bb38f48 --- /dev/null +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -0,0 +1,40 @@ +// src/routes/api/[...path]/+server.ts +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080'; + +async function proxyRequest(request: Request, path: string) { + const url = `${BACKEND_URL}/api/${path}`; + + try { + const headers = new Headers(request.headers); + headers.delete('host'); + + const backendRes = await fetch(url, { + method: request.method, + headers, + body: ['GET', 'HEAD'].includes(request.method) + ? undefined + : await request.text() + }); + + // Forward everything transparently + const resHeaders = new Headers(backendRes.headers); + return new Response(backendRes.body, { + status: backendRes.status, + headers: resHeaders + }); + } catch (err) { + console.error('Proxy error:', err); + return error(502, 'Backend unreachable'); + } +} + + +export const GET: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); +export const POST: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); +export const PUT: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); +export const DELETE: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); +export const PATCH: RequestHandler = ({ request, params }) => proxyRequest(request, params.path); \ No newline at end of file diff --git a/frontend/src/routes/book/+page.svelte b/frontend/src/routes/book/+page.svelte new file mode 100644 index 0000000..8f40984 --- /dev/null +++ b/frontend/src/routes/book/+page.svelte @@ -0,0 +1,535 @@ + + +
+
+

Book Your Appointment

+

Professional beauty treatments in a calm and friendly environment

+
+ + +
+ {#each ['Service', 'Date & Time', 'Details', 'Payment'] as step, index} +
+
+ {index + 1} +
+ + {step} + + {#if index < 3} + + {/if} +
+ {/each} +
+ + + {#if currentStep === 1} + + + Choose Your Services + Select one or more treatments for your appointment + + +
+ {#each services as service} + + {/each} +
+ + {#if selectedServices.length > 0} +
+

Selected Services

+
+ {#each selectedServices as service} +
+ {service.name} + {service.duration} mins • £{service.price} +
+ {/each} + +
+ Total Duration: + {getTotalDuration()} minutes +
+
+ Total Cost: + £{getTotalPrice()} +
+
+
+ {/if} +
+ + + +
+ {/if} + + + {#if currentStep === 2} + + + Choose Date & Time + + {selectedServices.map((s) => s.name).join(', ')} • {getTotalDuration()} minutes total • £{getTotalPrice()} + + + + + +
+ bookedDates.some((d) => d.compare(date) === 0)} + class="data-unavailable:line-through data-unavailable:opacity-100 bg-transparent p-0 [--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden" + weekdayFormat="short" + /> +
+
+
+ {#if timeSlots.length > 0} + {#each timeSlots as time (time)} + {@const timeStatus = isTimeInDuration(time, selectedTime, getTotalDuration())} + + {/each} + {:else if selectedServices.length === 0} +

Select services first

+ {:else} +

No available slots

+ {/if} +
+
+
+
+
+ + +
+ {#if selectedDate && selectedTime} + Appointment for + + {selectedDate.toDate(getLocalTimeZone()).toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'short' + })} + + at {selectedTime} + {:else} + Select a date and time + {/if} +
+ + + +
+ + + +
+
+
+ {/if} + + + {#if currentStep === 3} + + + Your Details + Please provide your contact information + + + +
+

Booking Summary

+
+
+ Services: +
+ {#each selectedServices as service} +
+ {service.name} + £{service.price} +
+ {/each} +
+
+
+ Date: + + {selectedDate?.toDate(getLocalTimeZone()).toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + month: 'long' + })} + +
+
+ Time: + {selectedTime} +
+
+ Total Duration: + {getTotalDuration()} minutes +
+ +
+ Total Cost: + £{getTotalPrice()} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +