From cc9ad204b445a15e42bcb797fb7a845746657a16 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Sun, 24 May 2026 20:50:17 +0530 Subject: [PATCH 01/13] Setup basic auth microservice (Login and Verify) --- auth-service/cmd/main.go | 24 +++++++ auth-service/go.mod | 5 ++ auth-service/go.sum | 2 + auth-service/internal/auth/jwt.go | 65 ++++++++++++++++++ auth-service/internal/auth/service.go | 32 +++++++++ .../internal/delivery/http/handler.go | 67 +++++++++++++++++++ 6 files changed, 195 insertions(+) create mode 100644 auth-service/cmd/main.go create mode 100644 auth-service/go.mod create mode 100644 auth-service/go.sum create mode 100644 auth-service/internal/auth/jwt.go create mode 100644 auth-service/internal/auth/service.go create mode 100644 auth-service/internal/delivery/http/handler.go diff --git a/auth-service/cmd/main.go b/auth-service/cmd/main.go new file mode 100644 index 0000000000..0dca02553f --- /dev/null +++ b/auth-service/cmd/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "auth-service/internal/auth" + delivery "auth-service/internal/delivery/http" + "log" + "net/http" +) + +func main() { + jwtSecret := "secret" + + jwtManager := auth.NewJWTManager(jwtSecret) + authSvc := auth.NewAuthService(jwtManager) + + mux := http.NewServeMux() + delivery.NewAuthHandler(mux, authSvc) + + port := ":8080" + log.Printf("auth-service listening: %s", port) + if err := http.ListenAndServe(port, mux); err != nil { + log.Fatalf("Fatal: Server hit an error: %v", err) + } +} diff --git a/auth-service/go.mod b/auth-service/go.mod new file mode 100644 index 0000000000..84ebbb73b5 --- /dev/null +++ b/auth-service/go.mod @@ -0,0 +1,5 @@ +module auth-service + +go 1.25.2 + +require github.com/golang-jwt/jwt/v5 v5.3.1 // indirect diff --git a/auth-service/go.sum b/auth-service/go.sum new file mode 100644 index 0000000000..c0f729031c --- /dev/null +++ b/auth-service/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= diff --git a/auth-service/internal/auth/jwt.go b/auth-service/internal/auth/jwt.go new file mode 100644 index 0000000000..919ae925eb --- /dev/null +++ b/auth-service/internal/auth/jwt.go @@ -0,0 +1,65 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrInvalidToken = errors.New("invalid or altered token") + ErrExpiredToken = errors.New("token has expired") +) + +type JWTManager struct { + secretKey []byte +} + +func NewJWTManager(secret string) *JWTManager { + return &JWTManager{secretKey: []byte(secret)} +} + +type JWTClaims struct { + UserId string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func (m *JWTManager) GenerateToken(userID, role string, duration time.Duration) (string, error) { + claims := JWTClaims{ + Role: role, + UserId: userID, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.secretKey) +} + +func (m *JWTManager) VerifyToken(tokenStr string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return m.secretKey, nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } + + return nil, ErrInvalidToken +} diff --git a/auth-service/internal/auth/service.go b/auth-service/internal/auth/service.go new file mode 100644 index 0000000000..3b480897bf --- /dev/null +++ b/auth-service/internal/auth/service.go @@ -0,0 +1,32 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type AuthService interface { + Login(username, password string) (string, error) + Verify(token string) (jwt.MapClaims, error) +} + +type authService struct { + jwtManager *JWTManager +} + +func NewAuthService(jwt *JWTManager) AuthService { + return &authService{jwtManager: jwt} +} + +func (s *authService) Login(username, password string) (string, error) { + if username == "admin" && password == "supersecret" { + return s.jwtManager.GenerateToken("usr_9921", "admin", time.Hour) + } + return "", errors.New("invalid credentials") +} + +func (s *authService) Verify(token string) (jwt.MapClaims, error) { + return s.jwtManager.VerifyToken(token) +} diff --git a/auth-service/internal/delivery/http/handler.go b/auth-service/internal/delivery/http/handler.go new file mode 100644 index 0000000000..efa2857f37 --- /dev/null +++ b/auth-service/internal/delivery/http/handler.go @@ -0,0 +1,67 @@ +package http + +import ( + "auth-service/internal/auth" + "encoding/json" + "net/http" + "strings" +) + +type AuthHandler struct { + Service auth.AuthService +} + +func NewAuthHandler(mux *http.ServeMux, svc auth.AuthService) { + h := &AuthHandler{Service: svc} + mux.HandleFunc("/login", h.Login) + mux.HandleFunc("/verify", h.Verify) +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Malformed request", http.StatusBadRequest) + return + } + + token, err := h.Service.Login(req.Username, req.Password) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "Missing or malformed token", http.StatusUnauthorized) + return + } + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + claims, err := h.Service.Verify(tokenStr) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(claims) +} From bf3a349392379046ff67fd6e23bc1fd7556a630c Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Sun, 24 May 2026 21:06:56 +0530 Subject: [PATCH 02/13] refactor: jsonutil for request and response handling Built a simple jsonutils to prevent code duplication (setting headers etc on every handler) --- .../internal/delivery/http/handler.go | 35 ++++++++------- .../internal/pkg/jsonutil/jsonutil.go | 44 +++++++++++++++++++ 2 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 auth-service/internal/pkg/jsonutil/jsonutil.go diff --git a/auth-service/internal/delivery/http/handler.go b/auth-service/internal/delivery/http/handler.go index efa2857f37..66375dd5a1 100644 --- a/auth-service/internal/delivery/http/handler.go +++ b/auth-service/internal/delivery/http/handler.go @@ -2,6 +2,7 @@ package http import ( "auth-service/internal/auth" + "auth-service/internal/pkg/jsonutil" "encoding/json" "net/http" "strings" @@ -11,6 +12,15 @@ type AuthHandler struct { Service auth.AuthService } +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` +} + func NewAuthHandler(mux *http.ServeMux, svc auth.AuthService) { h := &AuthHandler{Service: svc} mux.HandleFunc("/login", h.Login) @@ -19,49 +29,44 @@ func NewAuthHandler(mux *http.ServeMux, svc auth.AuthService) { func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") return } - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } - + var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Malformed request", http.StatusBadRequest) + jsonutil.WriteError(w, http.StatusBadRequest, "Malformed request") return } token, err := h.Service.Login(req.Username, req.Password) if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + jsonutil.WriteError(w, http.StatusUnauthorized, "Invalid Credentials") return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"token": token}) + res := LoginResponse{Token: token} + jsonutil.Write(w, http.StatusOK, res) } func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") return } authHeader := r.Header.Get("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Missing or malformed token", http.StatusUnauthorized) + jsonutil.WriteError(w, http.StatusUnauthorized, "Missing or malformed token") return } tokenStr := strings.TrimPrefix(authHeader, "Bearer ") claims, err := h.Service.Verify(tokenStr) if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + jsonutil.WriteError(w, http.StatusUnauthorized, err.Error()) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(claims) + jsonutil.Write(w, http.StatusOK, claims) } diff --git a/auth-service/internal/pkg/jsonutil/jsonutil.go b/auth-service/internal/pkg/jsonutil/jsonutil.go new file mode 100644 index 0000000000..657c382153 --- /dev/null +++ b/auth-service/internal/pkg/jsonutil/jsonutil.go @@ -0,0 +1,44 @@ +package jsonutil + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +func Write(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + if data != nil { + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError) + } + } +} + +// WriteError is a helper specifically designed to format uniform error responses. +func WriteError(w http.ResponseWriter, status int, message string) { + Write(w, status, map[string]string{"error": message}) +} + +// Read decodes the JSON request body into the target destination struct. +// It automatically closes the body and handles basic validation checks. +func Read(r *http.Request, dst interface{}) error { + if r.Body == nil { + return errors.New("request body is empty") + } + defer r.Body.Close() + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(dst); err != nil { + if errors.Is(err, io.EOF) { + return errors.New("request body is empty") + } + return err + } + return nil +} From 8a1dfc2db5ef4b73b68c3c4e72c33bac400ba769 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Sun, 24 May 2026 22:37:40 +0530 Subject: [PATCH 03/13] feat: Add register endpoint + use postgres Setup a pooled postgres connection and use it for auth instead of hardcoded credentials. Also add a register endpoint. --- auth-service/cmd/main.go | 21 ++++- auth-service/compose.yaml | 16 ++++ auth-service/go.mod | 11 ++- auth-service/go.sum | 25 ++++++ auth-service/internal/auth/service.go | 60 ++++++++++++-- .../internal/delivery/http/handler.go | 48 +++++++++++ auth-service/internal/domain/user.go | 20 +++++ .../internal/repository/postgres_repo.go | 79 +++++++++++++++++++ 8 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 auth-service/compose.yaml create mode 100644 auth-service/internal/domain/user.go create mode 100644 auth-service/internal/repository/postgres_repo.go diff --git a/auth-service/cmd/main.go b/auth-service/cmd/main.go index 0dca02553f..4ad1ba14b6 100644 --- a/auth-service/cmd/main.go +++ b/auth-service/cmd/main.go @@ -3,15 +3,32 @@ package main import ( "auth-service/internal/auth" delivery "auth-service/internal/delivery/http" + "auth-service/internal/repository" + "context" + "github.com/jackc/pgx/v5/pgxpool" "log" "net/http" + "time" ) func main() { - jwtSecret := "secret" + // Setup Database + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + connStr := "postgres://postgres:secret@localhost:5432/wecode_auth?sslmode=disable" + dbPool, err := pgxpool.New(ctx, connStr) + if err != nil { + log.Fatalf("DB Connection failed: %v", err) + } + defer dbPool.Close() + jwtSecret := "secret" jwtManager := auth.NewJWTManager(jwtSecret) - authSvc := auth.NewAuthService(jwtManager) + + userRepo := repository.NewPostgresUserRepository(dbPool) + + authSvc := auth.NewAuthService(userRepo, jwtManager) mux := http.NewServeMux() delivery.NewAuthHandler(mux, authSvc) diff --git a/auth-service/compose.yaml b/auth-service/compose.yaml new file mode 100644 index 0000000000..57f7733bbb --- /dev/null +++ b/auth-service/compose.yaml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:latest + container_name: postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: secret + POSTGRES_DB: wecode_auth + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql + +volumes: + postgres_data: diff --git a/auth-service/go.mod b/auth-service/go.mod index 84ebbb73b5..de7906df76 100644 --- a/auth-service/go.mod +++ b/auth-service/go.mod @@ -2,4 +2,13 @@ module auth-service go 1.25.2 -require github.com/golang-jwt/jwt/v5 v5.3.1 // indirect +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/auth-service/go.sum b/auth-service/go.sum index c0f729031c..8aeb27d41e 100644 --- a/auth-service/go.sum +++ b/auth-service/go.sum @@ -1,2 +1,27 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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= diff --git a/auth-service/internal/auth/service.go b/auth-service/internal/auth/service.go index 3b480897bf..87a23ca304 100644 --- a/auth-service/internal/auth/service.go +++ b/auth-service/internal/auth/service.go @@ -1,30 +1,80 @@ package auth import ( + "crypto/rand" "errors" + "fmt" + "log" "time" + "golang.org/x/crypto/bcrypt" + + "auth-service/internal/domain" + "github.com/golang-jwt/jwt/v5" ) type AuthService interface { Login(username, password string) (string, error) Verify(token string) (jwt.MapClaims, error) + Register(name, username, email, role, password string) (*domain.User, error) } type authService struct { + repo domain.UserRepository jwtManager *JWTManager } -func NewAuthService(jwt *JWTManager) AuthService { - return &authService{jwtManager: jwt} +func NewAuthService(repo domain.UserRepository, jwt *JWTManager) AuthService { + return &authService{jwtManager: jwt, repo: repo} } func (s *authService) Login(username, password string) (string, error) { - if username == "admin" && password == "supersecret" { - return s.jwtManager.GenerateToken("usr_9921", "admin", time.Hour) + user, err := s.repo.GetByUsername(username) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + log.Printf("user not found") + return "", errors.New("invalid credentials") + } + return "", err + } + log.Printf("%v", user) + + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + if err != nil { + log.Printf("invalid credentials") + return "", errors.New("invalid credentials") } - return "", errors.New("invalid credentials") + + return s.jwtManager.GenerateToken(user.ID, user.Role, time.Hour) +} + +func (s *authService) Register(name, username, email, role, password string) (*domain.User, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing") + return nil, err + } + + b := make([]byte, 8) + rand.Read(b) + userID := fmt.Sprintf("usr_%x", b) + if role == "" { + role = "user" + } + newUser := &domain.User{ + ID: userID, + Username: username, + Name: name, + Email: email, + PasswordHash: string(hashedPassword), + Role: role, + } + if err := s.repo.Create(newUser); err != nil { + return nil, err // Will pass up domain.ErrDuplicateUsername cleanly + } + + return newUser, nil } func (s *authService) Verify(token string) (jwt.MapClaims, error) { diff --git a/auth-service/internal/delivery/http/handler.go b/auth-service/internal/delivery/http/handler.go index 66375dd5a1..f9e9fbdafc 100644 --- a/auth-service/internal/delivery/http/handler.go +++ b/auth-service/internal/delivery/http/handler.go @@ -2,8 +2,10 @@ package http import ( "auth-service/internal/auth" + "auth-service/internal/domain" "auth-service/internal/pkg/jsonutil" "encoding/json" + "errors" "net/http" "strings" ) @@ -12,6 +14,21 @@ type AuthHandler struct { Service auth.AuthService } +type RegisterRequest struct { + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` +} + +type RegisterResponse struct { + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` +} + type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -24,6 +41,7 @@ type LoginResponse struct { func NewAuthHandler(mux *http.ServeMux, svc auth.AuthService) { h := &AuthHandler{Service: svc} mux.HandleFunc("/login", h.Login) + mux.HandleFunc("/register", h.Register) mux.HandleFunc("/verify", h.Verify) } @@ -49,6 +67,36 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { jsonutil.Write(w, http.StatusOK, res) } +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonutil.WriteError(w, http.StatusBadRequest, "Malformed request") + return + } + + if req.Username == "" || req.Password == "" { + jsonutil.WriteError(w, http.StatusBadRequest, "Username and password are required") + return + } + + user, err := h.Service.Register(req.Name, req.Username, req.Email, req.Role, req.Password) + if err != nil { + if errors.Is(err, domain.ErrDuplicateUsername) { + jsonutil.WriteError(w, http.StatusConflict, err.Error()) + return + } + jsonutil.WriteError(w, http.StatusInternalServerError, "Internal server error") + return + } + jsonutil.Write(w, http.StatusCreated, user) + +} + func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") diff --git a/auth-service/internal/domain/user.go b/auth-service/internal/domain/user.go new file mode 100644 index 0000000000..cb0e0e3c04 --- /dev/null +++ b/auth-service/internal/domain/user.go @@ -0,0 +1,20 @@ +package domain + +import "errors" + +var ErrUserNotFound = errors.New("user not found") +var ErrDuplicateUsername = errors.New("user already exists") + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + PasswordHash string `json:"-"` //NOTE: Hidden from JSON outputs + Role string `json:"role"` +} + +type UserRepository interface { + GetByUsername(username string) (*User, error) + Create(user *User) error +} diff --git a/auth-service/internal/repository/postgres_repo.go b/auth-service/internal/repository/postgres_repo.go new file mode 100644 index 0000000000..7e47d3f89e --- /dev/null +++ b/auth-service/internal/repository/postgres_repo.go @@ -0,0 +1,79 @@ +package repository + +import ( + "auth-service/internal/domain" + "context" + "errors" + "log" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresUserRepository struct { + db *pgxpool.Pool +} + +// NewPostgresUserRepository instantiates a concrete database worker mapping to our domain contract +func NewPostgresUserRepository(db *pgxpool.Pool) domain.UserRepository { + return &PostgresUserRepository{db: db} +} + +func (r *PostgresUserRepository) Create(user *domain.User) error { + ctx := context.Background() + query := `INSERT INTO users (id, name, username, email, password_hash, role) VALUES ($1, $2, $3, $4, $5, $6)` + + _, err := r.db.Exec(ctx, query, user.ID, user.Name, user.Username, user.Email, user.PasswordHash, user.Role) + if err != nil { + //NOTE: parse unique constraint violations here if needed + log.Println(err) + return domain.ErrDuplicateUsername + // return err + } + return nil +} + +func (r *PostgresUserRepository) GetByID(id string) (*domain.User, error) { + ctx := context.Background() + query := `SELECT id, name,username, email FROM users WHERE id = $1` + + var user domain.User + err := r.db.QueryRow(ctx, query, id).Scan( + &user.ID, + &user.Name, + &user.Username, + &user.Email, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *PostgresUserRepository) GetByUsername(username string) (*domain.User, error) { + ctx := context.Background() + query := `SELECT id, name, username, email, password_hash, role FROM users WHERE username = $1` + + var user domain.User + err := r.db.QueryRow(ctx, query, username).Scan( + &user.ID, + &user.Name, + &user.Username, + &user.Email, + &user.PasswordHash, + &user.Role, + ) + if err != nil { + log.Print(err) + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, err + } + + return &user, nil +} From 7a0b805bf98e611ed0d49f5d0079e99914aa7e5e Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 20:39:27 +0530 Subject: [PATCH 04/13] Make email as identifier + add migrations --- auth-service/go.mod | 9 ++-- auth-service/go.sum | 11 ++-- auth-service/internal/auth/service.go | 24 +++++---- .../internal/delivery/http/handler.go | 20 +++++++- auth-service/internal/domain/user.go | 6 +-- .../internal/repository/postgres_repo.go | 51 +++++++++++++++---- .../001_create_users_table.down.sql | 1 + .../migrations/001_create_users_table.up.sql | 9 ++++ 8 files changed, 99 insertions(+), 32 deletions(-) create mode 100644 auth-service/migrations/001_create_users_table.down.sql create mode 100644 auth-service/migrations/001_create_users_table.up.sql diff --git a/auth-service/go.mod b/auth-service/go.mod index de7906df76..b19b7deecb 100644 --- a/auth-service/go.mod +++ b/auth-service/go.mod @@ -3,12 +3,15 @@ module auth-service go 1.25.2 require ( - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.9.2 + golang.org/x/crypto v0.52.0 +) + +require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.37.0 // indirect ) diff --git a/auth-service/go.sum b/auth-service/go.sum index 8aeb27d41e..3d78f712b1 100644 --- a/auth-service/go.sum +++ b/auth-service/go.sum @@ -1,4 +1,6 @@ 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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -9,19 +11,20 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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/auth-service/internal/auth/service.go b/auth-service/internal/auth/service.go index 87a23ca304..dc7e9aaac1 100644 --- a/auth-service/internal/auth/service.go +++ b/auth-service/internal/auth/service.go @@ -1,9 +1,7 @@ package auth import ( - "crypto/rand" "errors" - "fmt" "log" "time" @@ -17,7 +15,8 @@ import ( type AuthService interface { Login(username, password string) (string, error) Verify(token string) (jwt.MapClaims, error) - Register(name, username, email, role, password string) (*domain.User, error) + Register(name, email, role, password string) (*domain.User, error) + List() ([]domain.User, error) } type authService struct { @@ -29,8 +28,8 @@ func NewAuthService(repo domain.UserRepository, jwt *JWTManager) AuthService { return &authService{jwtManager: jwt, repo: repo} } -func (s *authService) Login(username, password string) (string, error) { - user, err := s.repo.GetByUsername(username) +func (s *authService) Login(email, password string) (string, error) { + user, err := s.repo.GetByEmail(email) if err != nil { if errors.Is(err, domain.ErrUserNotFound) { log.Printf("user not found") @@ -49,22 +48,17 @@ func (s *authService) Login(username, password string) (string, error) { return s.jwtManager.GenerateToken(user.ID, user.Role, time.Hour) } -func (s *authService) Register(name, username, email, role, password string) (*domain.User, error) { +func (s *authService) Register(name, email, role, password string) (*domain.User, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { log.Printf("Error hashing") return nil, err } - b := make([]byte, 8) - rand.Read(b) - userID := fmt.Sprintf("usr_%x", b) if role == "" { role = "user" } newUser := &domain.User{ - ID: userID, - Username: username, Name: name, Email: email, PasswordHash: string(hashedPassword), @@ -80,3 +74,11 @@ func (s *authService) Register(name, username, email, role, password string) (*d func (s *authService) Verify(token string) (jwt.MapClaims, error) { return s.jwtManager.VerifyToken(token) } + +func (s *authService) List() ([]domain.User, error) { + users, err := s.repo.ListUsers() + if err != nil { + return nil, err + } + return users, nil +} diff --git a/auth-service/internal/delivery/http/handler.go b/auth-service/internal/delivery/http/handler.go index f9e9fbdafc..8baf6827e7 100644 --- a/auth-service/internal/delivery/http/handler.go +++ b/auth-service/internal/delivery/http/handler.go @@ -43,6 +43,7 @@ func NewAuthHandler(mux *http.ServeMux, svc auth.AuthService) { mux.HandleFunc("/login", h.Login) mux.HandleFunc("/register", h.Register) mux.HandleFunc("/verify", h.Verify) + mux.HandleFunc("/list", h.List) } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { @@ -84,9 +85,9 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { return } - user, err := h.Service.Register(req.Name, req.Username, req.Email, req.Role, req.Password) + user, err := h.Service.Register(req.Name, req.Email, req.Role, req.Password) if err != nil { - if errors.Is(err, domain.ErrDuplicateUsername) { + if errors.Is(err, domain.ErrDuplicateEmail) { jsonutil.WriteError(w, http.StatusConflict, err.Error()) return } @@ -118,3 +119,18 @@ func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { jsonutil.Write(w, http.StatusOK, claims) } + +func (h *AuthHandler) List(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + users, err := h.Service.List() + if err != nil { + jsonutil.WriteError(w, http.StatusInternalServerError, "Internal Server Error") + return + } + jsonutil.Write(w, http.StatusOK, users) + +} diff --git a/auth-service/internal/domain/user.go b/auth-service/internal/domain/user.go index cb0e0e3c04..1db9aef42e 100644 --- a/auth-service/internal/domain/user.go +++ b/auth-service/internal/domain/user.go @@ -3,11 +3,10 @@ package domain import "errors" var ErrUserNotFound = errors.New("user not found") -var ErrDuplicateUsername = errors.New("user already exists") +var ErrDuplicateEmail = errors.New("user already exists") type User struct { ID string `json:"id"` - Username string `json:"username"` Name string `json:"name"` Email string `json:"email"` PasswordHash string `json:"-"` //NOTE: Hidden from JSON outputs @@ -15,6 +14,7 @@ type User struct { } type UserRepository interface { - GetByUsername(username string) (*User, error) + GetByEmail(email string) (*User, error) Create(user *User) error + ListUsers() ([]User, error) } diff --git a/auth-service/internal/repository/postgres_repo.go b/auth-service/internal/repository/postgres_repo.go index 7e47d3f89e..d08dc75da8 100644 --- a/auth-service/internal/repository/postgres_repo.go +++ b/auth-service/internal/repository/postgres_repo.go @@ -21,13 +21,13 @@ func NewPostgresUserRepository(db *pgxpool.Pool) domain.UserRepository { func (r *PostgresUserRepository) Create(user *domain.User) error { ctx := context.Background() - query := `INSERT INTO users (id, name, username, email, password_hash, role) VALUES ($1, $2, $3, $4, $5, $6)` + query := `INSERT INTO users (name, email, password_hash, role) VALUES ($1, $2, $3, $4)` - _, err := r.db.Exec(ctx, query, user.ID, user.Name, user.Username, user.Email, user.PasswordHash, user.Role) + _, err := r.db.Exec(ctx, query, user.Name, user.Email, user.PasswordHash, user.Role) if err != nil { //NOTE: parse unique constraint violations here if needed log.Println(err) - return domain.ErrDuplicateUsername + return domain.ErrDuplicateEmail // return err } return nil @@ -35,14 +35,14 @@ func (r *PostgresUserRepository) Create(user *domain.User) error { func (r *PostgresUserRepository) GetByID(id string) (*domain.User, error) { ctx := context.Background() - query := `SELECT id, name,username, email FROM users WHERE id = $1` + query := `SELECT id, name, email, role FROM users WHERE id = $1` var user domain.User err := r.db.QueryRow(ctx, query, id).Scan( &user.ID, &user.Name, - &user.Username, &user.Email, + &user.Role, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -54,15 +54,14 @@ func (r *PostgresUserRepository) GetByID(id string) (*domain.User, error) { return &user, nil } -func (r *PostgresUserRepository) GetByUsername(username string) (*domain.User, error) { +func (r *PostgresUserRepository) GetByEmail(email string) (*domain.User, error) { ctx := context.Background() - query := `SELECT id, name, username, email, password_hash, role FROM users WHERE username = $1` + query := `SELECT id, name, email, password_hash, role FROM users WHERE email = $1` var user domain.User - err := r.db.QueryRow(ctx, query, username).Scan( + err := r.db.QueryRow(ctx, query, email).Scan( &user.ID, &user.Name, - &user.Username, &user.Email, &user.PasswordHash, &user.Role, @@ -77,3 +76,37 @@ func (r *PostgresUserRepository) GetByUsername(username string) (*domain.User, e return &user, nil } + +func (r *PostgresUserRepository) ListUsers() ([]domain.User, error) { + ctx := context.Background() + query := `SELECT id, name, email, password_hash, role FROM users` + + rows, err := r.db.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var users []domain.User + for rows.Next() { + var user domain.User + + err := rows.Scan( + &user.ID, + &user.Name, + &user.Email, + &user.PasswordHash, + &user.Role, + ) + if err != nil { + return nil, err + } + + users = append(users, user) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return users, nil +} diff --git a/auth-service/migrations/001_create_users_table.down.sql b/auth-service/migrations/001_create_users_table.down.sql new file mode 100644 index 0000000000..c99ddcdc8a --- /dev/null +++ b/auth-service/migrations/001_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/auth-service/migrations/001_create_users_table.up.sql b/auth-service/migrations/001_create_users_table.up.sql new file mode 100644 index 0000000000..11a3360c89 --- /dev/null +++ b/auth-service/migrations/001_create_users_table.up.sql @@ -0,0 +1,9 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' +); From 78fd6fb8c6257d8c78f2974b6e975485f39fe26b Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 21:11:21 +0530 Subject: [PATCH 05/13] Initialise basic frontend --- frontend/.gitignore | 41 + frontend/AGENTS.md | 5 + frontend/CLAUDE.md | 1 + frontend/README.md | 36 + frontend/eslint.config.mjs | 18 + frontend/next.config.ts | 7 + frontend/package.json | 26 + frontend/pnpm-lock.yaml | 4106 ++++++++++++++++++++++ frontend/pnpm-workspace.yaml | 3 + frontend/postcss.config.mjs | 7 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend/src/app/globals.css | 272 ++ frontend/src/app/layout.tsx | 31 + frontend/src/app/page.tsx | 365 ++ frontend/src/components/AuthGuard.tsx | 51 + frontend/src/components/BookingModal.tsx | 286 ++ frontend/src/components/Navbar.tsx | 212 ++ frontend/src/components/SearchBar.tsx | 107 + frontend/src/components/VenueCard.tsx | 187 + frontend/src/lib/auth.ts | 97 + frontend/src/lib/bookings.ts | 96 + frontend/src/lib/venues.ts | 239 ++ frontend/tsconfig.json | 34 + 28 files changed, 6232 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/AGENTS.md create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/src/app/favicon.ico create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/AuthGuard.tsx create mode 100644 frontend/src/components/BookingModal.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/SearchBar.tsx create mode 100644 frontend/src/components/VenueCard.tsx create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/bookings.ts create mode 100644 frontend/src/lib/venues.ts create mode 100644 frontend/tsconfig.json diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..5ef6a52078 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000000..8bd0e39085 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes β€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000..e215bc4ccf --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000000..05e726d1b4 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000000..e9ffa3083a --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000..ce3cfc0f91 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000000..21e8450046 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,4106 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + next: + specifier: 16.2.6 + version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.3.0 + '@types/node': + specifier: ^20 + version: 20.19.41 + '@types/react': + specifier: ^19 + version: 19.2.15 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.15) + eslint: + specifier: ^9 + version: 9.39.4(jiti@2.7.0) + eslint-config-next: + specifier: 16.2.6 + version: 16.2.6(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.3.0 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + + '@next/eslint-plugin-next@16.2.6': + resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==} + + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.0': + resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + + '@typescript-eslint/eslint-plugin@8.60.0': + resolution: {integrity: sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.60.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.60.0': + resolution: {integrity: sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.60.0': + resolution: {integrity: sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.60.0': + resolution: {integrity: sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.60.0': + resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.60.0': + resolution: {integrity: sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.60.0': + resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.60.0': + resolution: {integrity: sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.60.0': + resolution: {integrity: sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.60.0': + resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.4: + resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.363: + resolution: {integrity: sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + engines: {node: '>=10.13.0'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.2.6: + resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.60.0: + resolution: {integrity: sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.21: + resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': + dependencies: + eslint: 9.39.4(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@next/env@16.2.6': {} + + '@next/eslint-plugin-next@16.2.6': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.2.6': + optional: true + + '@next/swc-darwin-x64@16.2.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.6': + optional: true + + '@next/swc-linux-arm64-musl@16.2.6': + optional: true + + '@next/swc-linux-x64-gnu@16.2.6': + optional: true + + '@next/swc-linux-x64-musl@16.2.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.6': + optional: true + + '@next/swc-win32-x64-msvc@16.2.6': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@rtsao/scc@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/postcss@4.3.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + postcss: 8.5.15 + tailwindcss: 4.3.0 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.15)': + dependencies: + '@types/react': 19.2.15 + + '@types/react@19.2.15': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.60.0 + '@typescript-eslint/type-utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.60.0 + eslint: 9.39.4(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.60.0 + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.60.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.60.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) + '@typescript-eslint/types': 8.60.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.60.0': + dependencies: + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/visitor-keys': 8.60.0 + + '@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.60.0': {} + + '@typescript-eslint/typescript-estree@8.60.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.60.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/visitor-keys': 8.60.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.60.0 + '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.60.0': + dependencies: + '@typescript-eslint/types': 8.60.0 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.4: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.32: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.363 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001793: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.363: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.22.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.21 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.2.6(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.2.6 + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) + globals: 16.4.0 + typescript-eslint: 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + get-tsconfig: 4.14.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.16 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + hasown: 2.0.3 + is-core-module: 2.16.2 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.7.0)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.4 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@2.7.0) + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.4(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4(jiti@2.7.0) + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.8.1 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.21 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.46: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react@19.2.4: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.60.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.21 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.21: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000000..581a9d5b59 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000000..61e36849cf --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000000..004145cddf --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000000..567f17b0d7 --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000000..5174b28c56 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000000..7705396033 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000000..b2b2a44f6e --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000000..73675a1e39 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,272 @@ +@import "tailwindcss"; + +/* ── Design Tokens ─────────────────────────────────────────── */ +:root { + /* Background layers */ + --bg-base: #0d0f1a; + --bg-surface: #12152a; + --bg-elevated: #181c35; + --bg-card: #1e2340; + + /* Accent – indigo/violet */ + --accent-500: #6366f1; + --accent-400: #818cf8; + --accent-300: #a5b4fc; + --accent-600: #4f46e5; + --accent-glow: rgba(99, 102, 241, 0.35); + + /* Text */ + --text-primary: #f0f2ff; + --text-secondary: #94a3b8; + --text-muted: #4a5568; + + /* Border */ + --border-subtle: rgba(99, 102, 241, 0.18); + --border-card: rgba(255, 255, 255, 0.07); + + /* Status */ + --success: #34d399; + --warning: #fbbf24; + --error: #f87171; + + /* Radii */ + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-card: 0 4px 32px rgba(0, 0, 0, 0.45); + --shadow-glow: 0 0 24px var(--accent-glow); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +@theme inline { + --color-background: var(--bg-base); + --color-foreground: var(--text-primary); + --font-sans: var(--font-inter); +} + +/* ── Base Resets ───────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + font-size: 16px; +} + +body { + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-inter), "Inter", system-ui, sans-serif; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +/* ── Scrollbar ─────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg-base); +} +::-webkit-scrollbar-thumb { + background: var(--accent-600); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--accent-500); +} + +/* ── Selection ─────────────────────────────────────────────── */ +::selection { + background: var(--accent-600); + color: white; +} + +/* ── Links ─────────────────────────────────────────────────── */ +a { + color: inherit; + text-decoration: none; +} + +/* ── Focus ─────────────────────────────────────────────────── */ +:focus-visible { + outline: 2px solid var(--accent-400); + outline-offset: 3px; + border-radius: var(--radius-sm); +} + +/* ── Utility animations ────────────────────────────────────── */ +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 12px var(--accent-glow); } + 50% { box-shadow: 0 0 28px var(--accent-glow), 0 0 48px rgba(99,102,241,0.15); } +} + +.animate-fade-in-up { + animation: fadeInUp 0.5s ease forwards; +} + +.animate-fade-in { + animation: fadeIn 0.4s ease forwards; +} + +/* ── Skeleton loading ──────────────────────────────────────── */ +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-card) 25%, + var(--bg-elevated) 50%, + var(--bg-card) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.6s infinite; + border-radius: var(--radius-sm); +} + +/* ── Glass effect ──────────────────────────────────────────── */ +.glass { + background: rgba(18, 21, 42, 0.72); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-card); +} + +/* ── Button base ───────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.5rem; + border-radius: var(--radius-full); + font-weight: 600; + font-size: 0.9375rem; + cursor: pointer; + transition: all 0.2s ease; + border: none; + outline: none; + white-space: nowrap; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-600), var(--accent-500)); + color: white; + box-shadow: 0 4px 18px rgba(99,102,241,0.4); +} +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 28px rgba(99,102,241,0.55); +} +.btn-primary:active { + transform: translateY(0); +} + +.btn-outline { + background: transparent; + color: var(--accent-400); + border: 1.5px solid var(--accent-500); +} +.btn-outline:hover { + background: rgba(99,102,241,0.12); + transform: translateY(-1px); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); +} +.btn-ghost:hover { + background: rgba(255,255,255,0.06); + color: var(--text-primary); +} + +/* ── Input base ────────────────────────────────────────────── */ +.input { + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-elevated); + border: 1.5px solid var(--border-card); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.9375rem; + transition: border-color 0.2s, box-shadow 0.2s; + outline: none; +} +.input::placeholder { + color: var(--text-muted); +} +.input:focus { + border-color: var(--accent-500); + box-shadow: 0 0 0 3px rgba(99,102,241,0.18); +} + +/* ── Card base ─────────────────────────────────────────────── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s; +} +.card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-card), var(--shadow-glow); + border-color: var(--border-subtle); +} + +/* ── Badge ─────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.65rem; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; +} +.badge-accent { + background: rgba(99,102,241,0.18); + color: var(--accent-300); + border: 1px solid rgba(99,102,241,0.3); +} +.badge-success { + background: rgba(52,211,153,0.15); + color: var(--success); + border: 1px solid rgba(52,211,153,0.3); +} +.badge-warning { + background: rgba(251,191,36,0.15); + color: var(--warning); + border: 1px solid rgba(251,191,36,0.3); +} +.badge-error { + background: rgba(248,113,113,0.15); + color: var(--error); + border: 1px solid rgba(248,113,113,0.3); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000000..4eada56873 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import Navbar from "@/components/Navbar"; + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], + display: "swap", +}); + +export const metadata: Metadata = { + title: "BookMyVenue β€” Find & Book Your Perfect Space", + description: + "Discover and book venues for meetings, events, weddings, and more. A free, open community platform by WeCode.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
{children}
+ + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000000..0727c448c3 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,365 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+ {/* ── Hero ─────────────────────────────────────────────── */} +
+ {/* Background glow orbs */} +
+
+ + {/* Grid pattern overlay */} +
+ +
+ {/* Pill badge */} +
+ ✨ + Open Source · Built by WeCode Community +
+ +

+ Find Your Perfect +
+ Venue in Minutes +

+ +

+ Discover and book unique spaces for meetings, weddings, parties, and + everything in between β€” completely free, powered by community. +

+ +
+ + πŸ” Browse Venues + + + Create Account + +
+ + {/* Social proof */} +

+ πŸŽ‰ 10+ curated venues Β· 7 categories Β· Instant booking +

+
+
+ + {/* ── Features ─────────────────────────────────────────── */} +
+
+
+

+ Why BookMyVenue? +

+

+ Everything you need to find and book the perfect space, hassle-free. +

+
+ +
+ {[ + { + icon: "πŸ—ΊοΈ", + title: "Centralised Marketplace", + desc: "One platform to discover hundreds of venues β€” from corporate boardrooms to scenic outdoor lawns.", + color: "#6366f1", + }, + { + icon: "⚑", + title: "Instant Booking", + desc: "Real-time availability and upfront pricing. Book in just a few clicks, no back-and-forth needed.", + color: "#10b981", + }, + { + icon: "πŸ”", + title: "Verified Listings", + desc: "High-quality photos, comprehensive amenity lists, and transparent reviews from real attendees.", + color: "#f59e0b", + }, + { + icon: "πŸ’Έ", + title: "Always Free", + desc: "BookMyVenue is a community-driven open source project β€” no commissions, no hidden fees, ever.", + color: "#ec4899", + }, + { + icon: "🀝", + title: "Empower Owners", + desc: "Local cafes, studios, and community halls get a free platform to share underused spaces.", + color: "#8b5cf6", + }, + { + icon: "πŸ“±", + title: "Beautiful & Fast", + desc: "A modern, responsive interface that feels great on any device, any screen size.", + color: "#14b8a6", + }, + ].map((feature, i) => ( +
{ + e.currentTarget.style.transform = "translateY(-4px)"; + e.currentTarget.style.borderColor = `${feature.color}44`; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = "translateY(0)"; + e.currentTarget.style.borderColor = "var(--border-card)"; + }} + > +
+ {feature.icon} +
+

+ {feature.title} +

+

+ {feature.desc} +

+
+ ))} +
+
+
+ + {/* ── CTA Banner ───────────────────────────────────────── */} +
+
+

+ Ready to find your space? +

+

+ Join the WeCode community and start exploring venues today. +

+
+ + Get Started β€” It's Free + + + Browse Venues + +
+
+
+ + {/* Footer */} +
+ BookMyVenue Β· Open Source by{" "} + WeCode Community Β· MIT + License +
+
+ ); +} diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx new file mode 100644 index 0000000000..5da583d51e --- /dev/null +++ b/frontend/src/components/AuthGuard.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { isAuthenticated } from "@/lib/auth"; + +interface AuthGuardProps { + children: React.ReactNode; +} + +export default function AuthGuard({ children }: AuthGuardProps) { + const router = useRouter(); + + useEffect(() => { + if (!isAuthenticated()) { + router.replace("/login"); + } + }, [router]); + + if (!isAuthenticated()) { + return ( +
+
+ +

+ Redirecting to login… +

+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/components/BookingModal.tsx b/frontend/src/components/BookingModal.tsx new file mode 100644 index 0000000000..e78406a5d3 --- /dev/null +++ b/frontend/src/components/BookingModal.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useState } from "react"; +import { Venue } from "@/lib/venues"; +import { createBooking, formatPrice } from "@/lib/bookings"; + +interface BookingModalProps { + venue: Venue; + onClose: () => void; + onSuccess: () => void; +} + +const TIME_SLOTS = [ + "08:00", "09:00", "10:00", "11:00", "12:00", + "13:00", "14:00", "15:00", "16:00", "17:00", + "18:00", "19:00", "20:00", "21:00", "22:00", +]; + +function calcHours(start: string, end: string): number { + const [sh, sm] = start.split(":").map(Number); + const [eh, em] = end.split(":").map(Number); + const diff = (eh * 60 + em - (sh * 60 + sm)) / 60; + return Math.max(diff, 0); +} + +export default function BookingModal({ venue, onClose, onSuccess }: BookingModalProps) { + const today = new Date().toISOString().split("T")[0]; + const [date, setDate] = useState(today); + const [startTime, setStartTime] = useState("10:00"); + const [endTime, setEndTime] = useState("12:00"); + const [guestCount, setGuestCount] = useState(10); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const hours = calcHours(startTime, endTime); + const totalPrice = Math.round(hours * venue.pricePerHour); + const isValid = hours > 0 && guestCount > 0 && guestCount <= venue.capacity; + + async function handleBook() { + if (!isValid) return; + setLoading(true); + setError(""); + try { + await new Promise((r) => setTimeout(r, 800)); // Simulate API delay + createBooking({ + venueId: venue.id, + venueName: venue.name, + venueLocation: venue.location, + venueImage: venue.images[0], + date, + startTime, + endTime, + guestCount, + pricePerHour: venue.pricePerHour, + }); + onSuccess(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Booking failed. Please try again."); + } finally { + setLoading(false); + } + } + + return ( + /* Backdrop */ +
+ {/* Modal */} +
e.stopPropagation()} + style={{ + background: "var(--bg-card)", + border: "1px solid var(--border-card)", + borderRadius: "var(--radius-xl)", + width: "100%", + maxWidth: "480px", + boxShadow: "var(--shadow-card), var(--shadow-glow)", + animation: "fadeInUp 0.3s ease", + overflow: "hidden", + }} + > + {/* Header */} +
+
+

+ Book Venue +

+

+ {venue.name} +

+
+ +
+ + {/* Form */} +
+ {/* Date */} +
+ + setDate(e.target.value)} + style={{ colorScheme: "dark" }} + /> +
+ + {/* Time */} +
+
+ + +
+
+ + +
+
+ + {/* Guests */} +
+ + setGuestCount(Number(e.target.value))} + /> +
+ + {/* Price summary */} +
+
+ {formatPrice(venue.pricePerHour)} Γ— {hours > 0 ? `${hours}h` : "β€”"} + {hours > 0 ? formatPrice(totalPrice) : "β€”"} +
+
+ Total + + {hours > 0 ? formatPrice(totalPrice) : "β€”"} + +
+
+ + {error && ( +

+ {error} +

+ )} + + {!isValid && hours <= 0 && ( +

+ ⚠️ End time must be after start time. +

+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000000..34c62417ba --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,212 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { isAuthenticated, getUser, logout } from "@/lib/auth"; + +export default function Navbar() { + const [authed, setAuthed] = useState(false); + const [userName, setUserName] = useState(""); + const [menuOpen, setMenuOpen] = useState(false); + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + const check = () => { + const ok = isAuthenticated(); + setAuthed(ok); + if (ok) { + const u = getUser(); + setUserName(u?.name ?? u?.email ?? "User"); + } + }; + check(); + window.addEventListener("storage", check); + return () => window.removeEventListener("storage", check); + }, [pathname]); + + function handleLogout() { + logout(); + setAuthed(false); + setMenuOpen(false); + router.push("/"); + // Dispatch storage event so other tabs update + window.dispatchEvent(new Event("storage")); + } + + const navLinks = [ + { href: "/", label: "Home" }, + { href: "/venues", label: "Venues" }, + ...(authed ? [{ href: "/my-bookings", label: "My Bookings" }] : []), + ]; + + return ( + + ); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000000..aafa92e4c5 --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import { VenueCategory, VENUE_CATEGORIES } from "@/lib/venues"; + +interface SearchBarProps { + onSearch: (query: string, category: VenueCategory | "All") => void; +} + +export default function SearchBar({ onSearch }: SearchBarProps) { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("All"); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + onSearch(query, category); + } + + function handleQueryChange(val: string) { + setQuery(val); + onSearch(val, category); + } + + function handleCategoryChange(val: VenueCategory | "All") { + setCategory(val); + onSearch(query, val); + } + + return ( +
+ {/* Search input */} +
+ + πŸ” + + handleQueryChange(e.target.value)} + style={{ paddingLeft: "2.5rem" }} + /> +
+ + {/* Category filter */} + +
+ ); +} diff --git a/frontend/src/components/VenueCard.tsx b/frontend/src/components/VenueCard.tsx new file mode 100644 index 0000000000..428feb5cb3 --- /dev/null +++ b/frontend/src/components/VenueCard.tsx @@ -0,0 +1,187 @@ +import Link from "next/link"; +import Image from "next/image"; +import { Venue } from "@/lib/venues"; +import { formatPrice } from "@/lib/bookings"; + +interface VenueCardProps { + venue: Venue; +} + +function StarRating({ rating }: { rating: number }) { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + β˜… + + ))} + + {rating.toFixed(1)} + +
+ ); +} + +const CATEGORY_COLORS: Record = { + Conference: "#6366f1", + Wedding: "#ec4899", + Party: "#f59e0b", + Outdoor: "#10b981", + Workshop: "#3b82f6", + Exhibition: "#8b5cf6", + Sports: "#14b8a6", +}; + +export default function VenueCard({ venue }: VenueCardProps) { + const accentColor = CATEGORY_COLORS[venue.category] ?? "#6366f1"; + + return ( + +
+ {/* Hero Image */} +
+ {venue.name} { + (e.currentTarget as HTMLImageElement).style.transform = "scale(1.05)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLImageElement).style.transform = "scale(1)"; + }} + /> + {/* Category badge overlay */} +
+ + {venue.category} + +
+ {/* Price overlay */} +
+ {formatPrice(venue.pricePerHour)}/hr +
+
+ + {/* Content */} +
+ {/* Name & Rating */} +
+

+ {venue.name} +

+ +
+ + {/* Location */} +

+ πŸ“ + {venue.location} +

+ + {/* Capacity */} +

+ πŸ‘₯ + Up to {venue.capacity} guests +

+ + {/* Amenities preview */} +
+ {venue.amenities.slice(0, 3).map((a) => ( + + {a} + + ))} + {venue.amenities.length > 3 && ( + + +{venue.amenities.length - 3} + + )} +
+ + {/* CTA */} +
+
+ View Details β†’ +
+
+
+
+ + ); +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000000..d6d9cd5492 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,97 @@ +// ── Auth API utility ────────────────────────────────────────── +// Connects to the Go auth-service at localhost:8080 + +const AUTH_BASE = process.env.NEXT_PUBLIC_AUTH_URL ?? "http://localhost:8080"; +const TOKEN_KEY = "bmv_token"; + +export interface AuthUser { + name: string; + email: string; + role: string; +} + +export interface TokenClaims { + sub: string; + email: string; + name: string; + role: string; + exp: number; +} + +// ── Token helpers ───────────────────────────────────────────── + +export function getToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function removeToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export function isAuthenticated(): boolean { + const token = getToken(); + if (!token) return false; + try { + // Decode JWT payload (no verification – server handles that) + const payload = JSON.parse(atob(token.split(".")[1])); + return payload.exp * 1000 > Date.now(); + } catch { + return false; + } +} + +export function getUser(): TokenClaims | null { + const token = getToken(); + if (!token) return null; + try { + const payload = JSON.parse(atob(token.split(".")[1])); + return payload as TokenClaims; + } catch { + return null; + } +} + +// ── API calls ───────────────────────────────────────────────── + +export async function login(email: string, password: string): Promise { + const res = await fetch(`${AUTH_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? "Login failed"); + } + + const { token } = await res.json(); + setToken(token); +} + +export async function register( + name: string, + email: string, + password: string, + role: string = "user" +): Promise { + const res = await fetch(`${AUTH_BASE}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password, role, username: email }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? "Registration failed"); + } +} + +export function logout(): void { + removeToken(); +} diff --git a/frontend/src/lib/bookings.ts b/frontend/src/lib/bookings.ts new file mode 100644 index 0000000000..37b8eddd43 --- /dev/null +++ b/frontend/src/lib/bookings.ts @@ -0,0 +1,96 @@ +// ── Booking types & localStorage persistence ────────────────── + +export type BookingStatus = "confirmed" | "pending" | "cancelled"; + +export interface Booking { + id: string; + venueId: string; + venueName: string; + venueLocation: string; + venueImage: string; + date: string; // ISO date string YYYY-MM-DD + startTime: string; // HH:MM + endTime: string; // HH:MM + guestCount: number; + totalPrice: number; + status: BookingStatus; + createdAt: string; +} + +export interface CreateBookingInput { + venueId: string; + venueName: string; + venueLocation: string; + venueImage: string; + date: string; + startTime: string; + endTime: string; + guestCount: number; + pricePerHour: number; +} + +const BOOKINGS_KEY = "bmv_bookings"; + +function loadBookings(): Booking[] { + if (typeof window === "undefined") return []; + try { + return JSON.parse(localStorage.getItem(BOOKINGS_KEY) ?? "[]") as Booking[]; + } catch { + return []; + } +} + +function saveBookings(bookings: Booking[]): void { + localStorage.setItem(BOOKINGS_KEY, JSON.stringify(bookings)); +} + +function calcHours(start: string, end: string): number { + const [sh, sm] = start.split(":").map(Number); + const [eh, em] = end.split(":").map(Number); + const diff = (eh * 60 + em - (sh * 60 + sm)) / 60; + return Math.max(diff, 1); +} + +export function createBooking(input: CreateBookingInput): Booking { + const hours = calcHours(input.startTime, input.endTime); + const totalPrice = Math.round(hours * input.pricePerHour); + + const booking: Booking = { + id: `bk_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + venueId: input.venueId, + venueName: input.venueName, + venueLocation: input.venueLocation, + venueImage: input.venueImage, + date: input.date, + startTime: input.startTime, + endTime: input.endTime, + guestCount: input.guestCount, + totalPrice, + status: "confirmed", + createdAt: new Date().toISOString(), + }; + + const bookings = loadBookings(); + bookings.unshift(booking); + saveBookings(bookings); + return booking; +} + +export function getMyBookings(): Booking[] { + return loadBookings(); +} + +export function cancelBooking(id: string): void { + const bookings = loadBookings().map((b) => + b.id === id ? { ...b, status: "cancelled" as BookingStatus } : b + ); + saveBookings(bookings); +} + +export function formatPrice(amount: number): string { + return new Intl.NumberFormat("en-IN", { + style: "currency", + currency: "INR", + maximumFractionDigits: 0, + }).format(amount); +} diff --git a/frontend/src/lib/venues.ts b/frontend/src/lib/venues.ts new file mode 100644 index 0000000000..80b4ae684d --- /dev/null +++ b/frontend/src/lib/venues.ts @@ -0,0 +1,239 @@ +// ── Venue types & mock data ─────────────────────────────────── + +export type VenueCategory = + | "Conference" + | "Wedding" + | "Party" + | "Outdoor" + | "Workshop" + | "Exhibition" + | "Sports"; + +export interface Venue { + id: string; + name: string; + location: string; + city: string; + category: VenueCategory; + capacity: number; + pricePerHour: number; + rating: number; + reviewCount: number; + amenities: string[]; + images: string[]; + description: string; + highlights: string[]; +} + +// ── Gradient-based hero images (Unsplash) ───────────────────── + +const MOCK_VENUES: Venue[] = [ + { + id: "v1", + name: "The Grand Hall", + location: "Connaught Place, New Delhi", + city: "New Delhi", + category: "Wedding", + capacity: 500, + pricePerHour: 8500, + rating: 4.8, + reviewCount: 142, + amenities: ["Wi-Fi", "Parking", "AV Equipment", "Catering", "AC", "Stage"], + images: ["https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=800&q=80"], + description: + "A magnificent banquet hall with crystal chandeliers and a sprawling dance floor. Perfect for grand weddings and gala events. Our professional team ensures every detail is flawlessly executed.", + highlights: ["Crystal chandeliers", "400 sq m dance floor", "On-site catering"], + }, + { + id: "v2", + name: "TechHub Cowork", + location: "Koramangala, Bengaluru", + city: "Bengaluru", + category: "Conference", + capacity: 80, + pricePerHour: 2200, + rating: 4.6, + reviewCount: 89, + amenities: ["High-Speed Wi-Fi", "4K Projector", "Whiteboard", "Coffee Bar", "AC"], + images: ["https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&q=80"], + description: + "A modern co-working conference space equipped with cutting-edge technology. Ideal for corporate meetings, product demos, and team workshops in the heart of Bangalore's startup district.", + highlights: ["100 Mbps fibre", "Modular seating", "24/7 access"], + }, + { + id: "v3", + name: "Skyline Terrace", + location: "Bandra West, Mumbai", + city: "Mumbai", + category: "Party", + capacity: 150, + pricePerHour: 5500, + rating: 4.9, + reviewCount: 213, + amenities: ["Rooftop View", "Bar Setup", "DJ Booth", "Lighting Rig", "Parking"], + images: ["https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?w=800&q=80"], + description: + "An exclusive rooftop venue with panoramic Mumbai skyline views. Features a professional DJ booth, ambient lighting system, and a fully stocked bar. The go-to spot for premium parties.", + highlights: ["Sea-facing sunset view", "Premium sound system", "Customisable lighting"], + }, + { + id: "v4", + name: "Meadow Gardens", + location: "Whitefield, Bengaluru", + city: "Bengaluru", + category: "Outdoor", + capacity: 300, + pricePerHour: 4000, + rating: 4.7, + reviewCount: 67, + amenities: ["Open Lawn", "Tent Setup", "Parking", "Generator Backup", "Restrooms"], + images: ["https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=800&q=80"], + description: + "A lush 2-acre garden venue perfect for outdoor celebrations. With a natural canopy of trees and beautifully manicured grounds, Meadow Gardens provides a serene backdrop for any event.", + highlights: ["2 acres of lawn", "Tent & shamiyana available", "Ample parking"], + }, + { + id: "v5", + name: "Innovation Lab", + location: "Cyber City, Gurugram", + city: "Gurugram", + category: "Workshop", + capacity: 40, + pricePerHour: 1800, + rating: 4.5, + reviewCount: 54, + amenities: ["Wi-Fi", "3D Printer", "Smart Board", "Breakout Room", "Snack Station"], + images: ["https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&q=80"], + description: + "A purpose-built innovation lab for hackathons, design sprints, and creative workshops. Equipped with maker tools, smart boards, and dedicated breakout zones to keep ideas flowing.", + highlights: ["Maker tools included", "Flexible layout", "Dedicated facilitator"], + }, + { + id: "v6", + name: "Heritage Courtyard", + location: "Old City, Hyderabad", + city: "Hyderabad", + category: "Wedding", + capacity: 250, + pricePerHour: 6000, + rating: 4.8, + reviewCount: 101, + amenities: ["Heritage Architecture", "Catering", "AC Banquet", "Valet Parking", "Stage"], + images: ["https://images.unsplash.com/photo-1583939003579-730e3918a45a?w=800&q=80"], + description: + "Nestled in a 19th-century haveli, Heritage Courtyard blends old-world charm with modern amenities. The open courtyard, intricate stonework, and traditional dΓ©cor create an unforgettable setting.", + highlights: ["Nizam-era architecture", "Open-air courtyard", "Traditional & modern dΓ©cor"], + }, + { + id: "v7", + name: "Expo Arena", + location: "Salt Lake, Kolkata", + city: "Kolkata", + category: "Exhibition", + capacity: 1000, + pricePerHour: 12000, + rating: 4.4, + reviewCount: 38, + amenities: ["Loading Dock", "High Ceilings", "Modular Booths", "Parking", "Broadband"], + images: ["https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=800&q=80"], + description: + "Kolkata's premier exhibition arena with 10,000 sq ft of flexible floor space. Ideal for trade shows, product launches, and art exhibitions. Multiple loading docks for easy setup.", + highlights: ["10,000 sq ft floor", "8m ceiling height", "Modular partition system"], + }, + { + id: "v8", + name: "Arena Sports Club", + location: "Andheri East, Mumbai", + city: "Mumbai", + category: "Sports", + capacity: 200, + pricePerHour: 3500, + rating: 4.6, + reviewCount: 77, + amenities: ["Floodlights", "Changing Rooms", "Scoreboard", "Canteen", "First Aid"], + images: ["https://images.unsplash.com/photo-1577223625816-7546f13df25d?w=800&q=80"], + description: + "A multi-sport facility with a full-sized indoor court, professional floodlighting, and spectator seating for 200. Available for cricket nets, basketball, badminton, and corporate sports events.", + highlights: ["Indoor + outdoor courts", "Professional floodlights", "Coach available"], + }, + { + id: "v9", + name: "Studio LumiΓ¨re", + location: "Vasant Kunj, New Delhi", + city: "New Delhi", + category: "Workshop", + capacity: 30, + pricePerHour: 2500, + rating: 4.9, + reviewCount: 156, + amenities: ["Photo Studio", "Cyclorama Wall", "Lighting Kit", "Greenscreen", "Makeup Room"], + images: ["https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&q=80"], + description: + "A professional photography and video production studio with a seamless cyclorama wall, full lighting kit, and a dedicated makeup room. Perfect for product shoots, reels, and brand campaigns.", + highlights: ["12m cyclorama wall", "Complete lighting setup", "Post-production station"], + }, + { + id: "v10", + name: "Hillview Pavilion", + location: "Lavasa, Pune", + city: "Pune", + category: "Conference", + capacity: 120, + pricePerHour: 3200, + rating: 4.7, + reviewCount: 49, + amenities: ["Hill Views", "Wi-Fi", "Projector", "Outdoor Deck", "Catering", "AC"], + images: ["https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=800&q=80"], + description: + "A scenic hilltop conference pavilion in Lavasa with breathtaking Western Ghats views. An ideal off-site retreat for corporate strategy sessions, leadership workshops, and annual kick-offs.", + highlights: ["Western Ghats panorama", "Outdoor deck lounge", "Team-building packages"], + }, +]; + +// ── Query & retrieval helpers ───────────────────────────────── + +export interface VenueQuery { + search?: string; + category?: VenueCategory | "All"; + city?: string; +} + +export function getVenues(query?: VenueQuery): Venue[] { + let results = [...MOCK_VENUES]; + + if (query?.search) { + const q = query.search.toLowerCase(); + results = results.filter( + (v) => + v.name.toLowerCase().includes(q) || + v.location.toLowerCase().includes(q) || + v.city.toLowerCase().includes(q) + ); + } + + if (query?.category && query.category !== "All") { + results = results.filter((v) => v.category === query.category); + } + + if (query?.city) { + results = results.filter((v) => + v.city.toLowerCase().includes(query.city!.toLowerCase()) + ); + } + + return results; +} + +export function getVenueById(id: string): Venue | undefined { + return MOCK_VENUES.find((v) => v.id === id); +} + +export const VENUE_CATEGORIES: VenueCategory[] = [ + "Conference", + "Wedding", + "Party", + "Outdoor", + "Workshop", + "Exhibition", + "Sports", +]; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000..cf9c65d3e0 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} From 1fcedf59b08eaa9eaae096b672dae50400020e13 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 21:35:01 +0530 Subject: [PATCH 06/13] Migrate from plain golang to gin framework (for future scalability) --- auth-service/cmd/main.go | 27 +++--- auth-service/go.mod | 31 +++++++ auth-service/go.sum | 72 +++++++++++++++ auth-service/internal/auth/service.go | 14 ++- .../internal/delivery/http/handler.go | 88 ++++++++----------- auth-service/internal/domain/user.go | 14 ++- .../internal/pkg/jsonutil/jsonutil.go | 44 ---------- .../002_add_role_constraint.down.sql | 1 + .../migrations/002_add_role_constraint.up.sql | 3 + 9 files changed, 180 insertions(+), 114 deletions(-) delete mode 100644 auth-service/internal/pkg/jsonutil/jsonutil.go create mode 100644 auth-service/migrations/002_add_role_constraint.down.sql create mode 100644 auth-service/migrations/002_add_role_constraint.up.sql diff --git a/auth-service/cmd/main.go b/auth-service/cmd/main.go index 4ad1ba14b6..32f09f876b 100644 --- a/auth-service/cmd/main.go +++ b/auth-service/cmd/main.go @@ -5,37 +5,42 @@ import ( delivery "auth-service/internal/delivery/http" "auth-service/internal/repository" "context" - "github.com/jackc/pgx/v5/pgxpool" "log" - "net/http" "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + "bookmyvenue.com/shared/config" ) func main() { - // Setup Database + // ── Config ──────────────────────────────────────────────────────────────── + connStr := config.MustGetEnv("DATABASE_URL", + "postgres://postgres:secret@localhost:5432/wecode_auth?sslmode=disable") + jwtSecret := config.MustGetEnv("JWT_SECRET", "secret") + port := config.MustGetEnv("PORT", ":8080") + + // ── Database ────────────────────────────────────────────────────────────── ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - connStr := "postgres://postgres:secret@localhost:5432/wecode_auth?sslmode=disable" dbPool, err := pgxpool.New(ctx, connStr) if err != nil { log.Fatalf("DB Connection failed: %v", err) } defer dbPool.Close() - jwtSecret := "secret" + // ── Wire-up ─────────────────────────────────────────────────────────────── jwtManager := auth.NewJWTManager(jwtSecret) - userRepo := repository.NewPostgresUserRepository(dbPool) - authSvc := auth.NewAuthService(userRepo, jwtManager) - mux := http.NewServeMux() - delivery.NewAuthHandler(mux, authSvc) + r := gin.Default() + api := r.Group("/") + delivery.NewAuthHandler(api, authSvc) - port := ":8080" log.Printf("auth-service listening: %s", port) - if err := http.ListenAndServe(port, mux); err != nil { + if err := r.Run(port); err != nil { log.Fatalf("Fatal: Server hit an error: %v", err) } } diff --git a/auth-service/go.mod b/auth-service/go.mod index b19b7deecb..000ba40137 100644 --- a/auth-service/go.mod +++ b/auth-service/go.mod @@ -3,15 +3,46 @@ module auth-service go 1.25.2 require ( + bookmyvenue.com/shared v0.0.0 + github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.2 golang.org/x/crypto v0.52.0 ) +replace bookmyvenue.com/shared => ../shared + require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect 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 + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/auth-service/go.sum b/auth-service/go.sum index 3d78f712b1..a7c67f14b9 100644 --- a/auth-service/go.sum +++ b/auth-service/go.sum @@ -1,8 +1,37 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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= @@ -11,19 +40,62 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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= diff --git a/auth-service/internal/auth/service.go b/auth-service/internal/auth/service.go index dc7e9aaac1..587739b2d4 100644 --- a/auth-service/internal/auth/service.go +++ b/auth-service/internal/auth/service.go @@ -37,7 +37,6 @@ func (s *authService) Login(email, password string) (string, error) { } return "", err } - log.Printf("%v", user) err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) if err != nil { @@ -55,9 +54,16 @@ func (s *authService) Register(name, email, role, password string) (*domain.User return nil, err } - if role == "" { - role = "user" + // Default empty role to 'user'; block self-registration as admin. + switch role { + case "": + role = domain.RoleUser + case domain.RoleUser, domain.RoleOwner: + // allowed + default: + return nil, domain.ErrInvalidRole } + newUser := &domain.User{ Name: name, Email: email, @@ -65,7 +71,7 @@ func (s *authService) Register(name, email, role, password string) (*domain.User Role: role, } if err := s.repo.Create(newUser); err != nil { - return nil, err // Will pass up domain.ErrDuplicateUsername cleanly + return nil, err // Will pass up domain.ErrDuplicateEmail cleanly } return newUser, nil diff --git a/auth-service/internal/delivery/http/handler.go b/auth-service/internal/delivery/http/handler.go index 8baf6827e7..8b03fd48db 100644 --- a/auth-service/internal/delivery/http/handler.go +++ b/auth-service/internal/delivery/http/handler.go @@ -3,11 +3,11 @@ package http import ( "auth-service/internal/auth" "auth-service/internal/domain" - "auth-service/internal/pkg/jsonutil" - "encoding/json" "errors" "net/http" "strings" + + "github.com/gin-gonic/gin" ) type AuthHandler struct { @@ -30,7 +30,7 @@ type RegisterResponse struct { } type LoginRequest struct { - Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } @@ -38,99 +38,81 @@ type LoginResponse struct { Token string `json:"token"` } -func NewAuthHandler(mux *http.ServeMux, svc auth.AuthService) { +// NewAuthHandler registers all auth routes on the provided Gin router group. +func NewAuthHandler(rg *gin.RouterGroup, svc auth.AuthService) { h := &AuthHandler{Service: svc} - mux.HandleFunc("/login", h.Login) - mux.HandleFunc("/register", h.Register) - mux.HandleFunc("/verify", h.Verify) - mux.HandleFunc("/list", h.List) + rg.POST("/login", h.Login) + rg.POST("/register", h.Register) + rg.POST("/verify", h.Verify) + rg.GET("/list", h.List) } -func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - +func (h *AuthHandler) Login(c *gin.Context) { var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonutil.WriteError(w, http.StatusBadRequest, "Malformed request") + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Malformed request"}) return } - token, err := h.Service.Login(req.Username, req.Password) + token, err := h.Service.Login(req.Email, req.Password) if err != nil { - jsonutil.WriteError(w, http.StatusUnauthorized, "Invalid Credentials") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Credentials"}) return } - res := LoginResponse{Token: token} - jsonutil.Write(w, http.StatusOK, res) + c.JSON(http.StatusOK, LoginResponse{Token: token}) } -func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - +func (h *AuthHandler) Register(c *gin.Context) { var req RegisterRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonutil.WriteError(w, http.StatusBadRequest, "Malformed request") + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Malformed request"}) return } if req.Username == "" || req.Password == "" { - jsonutil.WriteError(w, http.StatusBadRequest, "Username and password are required") + c.JSON(http.StatusBadRequest, gin.H{"error": "Username and password are required"}) return } user, err := h.Service.Register(req.Name, req.Email, req.Role, req.Password) if err != nil { - if errors.Is(err, domain.ErrDuplicateEmail) { - jsonutil.WriteError(w, http.StatusConflict, err.Error()) - return + switch { + case errors.Is(err, domain.ErrDuplicateEmail): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, domain.ErrInvalidRole): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) } - jsonutil.WriteError(w, http.StatusInternalServerError, "Internal server error") return } - jsonutil.Write(w, http.StatusCreated, user) + c.JSON(http.StatusCreated, user) } -func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - - authHeader := r.Header.Get("Authorization") +func (h *AuthHandler) Verify(c *gin.Context) { + authHeader := c.GetHeader("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - jsonutil.WriteError(w, http.StatusUnauthorized, "Missing or malformed token") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or malformed token"}) return } tokenStr := strings.TrimPrefix(authHeader, "Bearer ") claims, err := h.Service.Verify(tokenStr) if err != nil { - jsonutil.WriteError(w, http.StatusUnauthorized, err.Error()) + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } - jsonutil.Write(w, http.StatusOK, claims) + c.JSON(http.StatusOK, claims) } -func (h *AuthHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - jsonutil.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - +func (h *AuthHandler) List(c *gin.Context) { users, err := h.Service.List() if err != nil { - jsonutil.WriteError(w, http.StatusInternalServerError, "Internal Server Error") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"}) return } - jsonutil.Write(w, http.StatusOK, users) - + c.JSON(http.StatusOK, users) } diff --git a/auth-service/internal/domain/user.go b/auth-service/internal/domain/user.go index 1db9aef42e..4f73205127 100644 --- a/auth-service/internal/domain/user.go +++ b/auth-service/internal/domain/user.go @@ -2,8 +2,18 @@ package domain import "errors" -var ErrUserNotFound = errors.New("user not found") -var ErrDuplicateEmail = errors.New("user already exists") +// Role constants β€” the single source of truth for role strings across services. +const ( + RoleUser = "user" + RoleOwner = "owner" + RoleAdmin = "admin" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrDuplicateEmail = errors.New("user already exists") + ErrInvalidRole = errors.New("invalid role: must be 'user' or 'owner'") +) type User struct { ID string `json:"id"` diff --git a/auth-service/internal/pkg/jsonutil/jsonutil.go b/auth-service/internal/pkg/jsonutil/jsonutil.go deleted file mode 100644 index 657c382153..0000000000 --- a/auth-service/internal/pkg/jsonutil/jsonutil.go +++ /dev/null @@ -1,44 +0,0 @@ -package jsonutil - -import ( - "encoding/json" - "errors" - "io" - "net/http" -) - -func Write(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - if data != nil { - if err := json.NewEncoder(w).Encode(data); err != nil { - http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError) - } - } -} - -// WriteError is a helper specifically designed to format uniform error responses. -func WriteError(w http.ResponseWriter, status int, message string) { - Write(w, status, map[string]string{"error": message}) -} - -// Read decodes the JSON request body into the target destination struct. -// It automatically closes the body and handles basic validation checks. -func Read(r *http.Request, dst interface{}) error { - if r.Body == nil { - return errors.New("request body is empty") - } - defer r.Body.Close() - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - - if err := decoder.Decode(dst); err != nil { - if errors.Is(err, io.EOF) { - return errors.New("request body is empty") - } - return err - } - return nil -} diff --git a/auth-service/migrations/002_add_role_constraint.down.sql b/auth-service/migrations/002_add_role_constraint.down.sql new file mode 100644 index 0000000000..95eb727ae4 --- /dev/null +++ b/auth-service/migrations/002_add_role_constraint.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; diff --git a/auth-service/migrations/002_add_role_constraint.up.sql b/auth-service/migrations/002_add_role_constraint.up.sql new file mode 100644 index 0000000000..ab713e1f4d --- /dev/null +++ b/auth-service/migrations/002_add_role_constraint.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + ADD CONSTRAINT users_role_check + CHECK (role IN ('user', 'owner', 'admin')); From 0f9407e7292d30b5603c1f2be8e1a40f585e6c7a Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 21:35:16 +0530 Subject: [PATCH 07/13] Add tests for authentication --- .../internal/delivery/http/handler_test.go | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 auth-service/internal/delivery/http/handler_test.go diff --git a/auth-service/internal/delivery/http/handler_test.go b/auth-service/internal/delivery/http/handler_test.go new file mode 100644 index 0000000000..ee26621aa2 --- /dev/null +++ b/auth-service/internal/delivery/http/handler_test.go @@ -0,0 +1,412 @@ +package http + +import ( + "auth-service/internal/domain" + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// --------------------------------------------------------------------------- +// Mock AuthService +// --------------------------------------------------------------------------- + +type mockAuthService struct { + loginFn func(email, password string) (string, error) + verifyFn func(token string) (jwt.MapClaims, error) + registerFn func(name, email, role, password string) (*domain.User, error) + listFn func() ([]domain.User, error) +} + +func (m *mockAuthService) Login(email, password string) (string, error) { + return m.loginFn(email, password) +} + +func (m *mockAuthService) Verify(token string) (jwt.MapClaims, error) { + return m.verifyFn(token) +} + +func (m *mockAuthService) Register(name, email, role, password string) (*domain.User, error) { + return m.registerFn(name, email, role, password) +} + +func (m *mockAuthService) List() ([]domain.User, error) { + return m.listFn() +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newTestRouter builds a Gin engine in test mode wired to the given service. +func newTestRouter(svc *mockAuthService) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() // no middleware noise in test output + api := r.Group("/") + NewAuthHandler(api, svc) + return r +} + +// jsonBody serialises v to a *bytes.Buffer suitable for http.Request bodies. +func jsonBody(t *testing.T, v any) *bytes.Buffer { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("jsonBody: marshal error: %v", err) + } + return bytes.NewBuffer(b) +} + +// decode unmarshals the response body into dst. +func decode(t *testing.T, body []byte, dst any) { + t.Helper() + if err := json.Unmarshal(body, dst); err != nil { + t.Fatalf("decode: %v (body was: %s)", err, body) + } +} + +// --------------------------------------------------------------------------- +// POST /register +// --------------------------------------------------------------------------- + +func TestRegister(t *testing.T) { + tests := []struct { + name string + body any + mockReturn func(name, email, role, password string) (*domain.User, error) + wantStatus int + wantError string // non-empty β†’ assert {"error": ...} in body + wantUser bool // true β†’ assert a domain.User shape in body + }{ + { + name: "success", + body: map[string]string{ + "name": "Alice", + "username": "alice", + "email": "alice@example.com", + "password": "s3cr3t", + "role": "user", + }, + mockReturn: func(name, email, role, password string) (*domain.User, error) { + return &domain.User{ + ID: "uuid-1", + Name: name, + Email: email, + Role: role, + }, nil + }, + wantStatus: http.StatusCreated, + wantUser: true, + }, + { + name: "missing username", + body: map[string]string{ + "email": "alice@example.com", + "password": "s3cr3t", + }, + mockReturn: nil, // service should not be called + wantStatus: http.StatusBadRequest, + wantError: "Username and password are required", + }, + { + name: "missing password", + body: map[string]string{ + "username": "alice", + "email": "alice@example.com", + }, + mockReturn: nil, + wantStatus: http.StatusBadRequest, + wantError: "Username and password are required", + }, + { + name: "malformed json", + body: "not-json", + mockReturn: nil, + wantStatus: http.StatusBadRequest, + wantError: "Malformed request", + }, + { + name: "duplicate email", + body: map[string]string{ + "name": "Bob", + "username": "bob", + "email": "bob@example.com", + "password": "s3cr3t", + }, + mockReturn: func(name, email, role, password string) (*domain.User, error) { + return nil, domain.ErrDuplicateEmail + }, + wantStatus: http.StatusConflict, + wantError: domain.ErrDuplicateEmail.Error(), + }, + { + name: "service internal error", + body: map[string]string{ + "name": "Charlie", + "username": "charlie", + "email": "charlie@example.com", + "password": "s3cr3t", + }, + mockReturn: func(name, email, role, password string) (*domain.User, error) { + return nil, errors.New("db is on fire") + }, + wantStatus: http.StatusInternalServerError, + wantError: "Internal server error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &mockAuthService{ + registerFn: tc.mockReturn, + } + // For cases where the service should never be called, guard against panics. + if svc.registerFn == nil { + svc.registerFn = func(_, _, _, _ string) (*domain.User, error) { + t.Error("Register: service should not have been called") + return nil, nil + } + } + + var buf *bytes.Buffer + if s, ok := tc.body.(string); ok { + buf = bytes.NewBufferString(s) + } else { + buf = jsonBody(t, tc.body) + } + + req := httptest.NewRequest(http.MethodPost, "/register", buf) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + newTestRouter(svc).ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf("status: got %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body) + } + + if tc.wantError != "" { + var resp map[string]string + decode(t, rec.Body.Bytes(), &resp) + if resp["error"] != tc.wantError { + t.Errorf("error message: got %q, want %q", resp["error"], tc.wantError) + } + } + + if tc.wantUser { + var user domain.User + decode(t, rec.Body.Bytes(), &user) + if user.Email == "" { + t.Errorf("expected a user in body, got: %s", rec.Body) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// POST /login +// --------------------------------------------------------------------------- + +func TestLogin(t *testing.T) { + tests := []struct { + name string + body any + mockReturn func(email, password string) (string, error) + wantStatus int + wantError string + wantToken bool + }{ + { + name: "success", + body: map[string]string{ + "email": "alice@example.com", + "password": "s3cr3t", + }, + mockReturn: func(email, password string) (string, error) { + return "signed.jwt.token", nil + }, + wantStatus: http.StatusOK, + wantToken: true, + }, + { + name: "invalid credentials", + body: map[string]string{ + "email": "alice@example.com", + "password": "wrong", + }, + mockReturn: func(email, password string) (string, error) { + return "", errors.New("invalid credentials") + }, + wantStatus: http.StatusUnauthorized, + wantError: "Invalid Credentials", + }, + { + name: "malformed json", + body: "not-json", + mockReturn: nil, + wantStatus: http.StatusBadRequest, + wantError: "Malformed request", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &mockAuthService{ + loginFn: tc.mockReturn, + } + if svc.loginFn == nil { + svc.loginFn = func(_, _ string) (string, error) { + t.Error("Login: service should not have been called") + return "", nil + } + } + + var buf *bytes.Buffer + if s, ok := tc.body.(string); ok { + buf = bytes.NewBufferString(s) + } else { + buf = jsonBody(t, tc.body) + } + + req := httptest.NewRequest(http.MethodPost, "/login", buf) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + newTestRouter(svc).ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf("status: got %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body) + } + + if tc.wantError != "" { + var resp map[string]string + decode(t, rec.Body.Bytes(), &resp) + if resp["error"] != tc.wantError { + t.Errorf("error message: got %q, want %q", resp["error"], tc.wantError) + } + } + + if tc.wantToken { + var resp LoginResponse + decode(t, rec.Body.Bytes(), &resp) + if resp.Token == "" { + t.Errorf("expected a non-empty token in body, got: %s", rec.Body) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// POST /verify +// --------------------------------------------------------------------------- + +func TestVerify(t *testing.T) { + validClaims := jwt.MapClaims{ + "user_id": "uuid-1", + "role": "user", + } + + tests := []struct { + name string + authHeader string // value of the Authorization header; "" means omit + mockReturn func(token string) (jwt.MapClaims, error) + wantStatus int + wantError string + wantClaims bool + }{ + { + name: "success", + authHeader: "Bearer valid.jwt.token", + mockReturn: func(token string) (jwt.MapClaims, error) { + if token != "valid.jwt.token" { + return nil, errors.New("unexpected token") + } + return validClaims, nil + }, + wantStatus: http.StatusOK, + wantClaims: true, + }, + { + name: "missing Authorization header", + authHeader: "", + mockReturn: nil, + wantStatus: http.StatusUnauthorized, + wantError: "Missing or malformed token", + }, + { + name: "malformed Authorization header - no Bearer prefix", + authHeader: "Token somestuff", + mockReturn: nil, + wantStatus: http.StatusUnauthorized, + wantError: "Missing or malformed token", + }, + { + name: "expired token", + authHeader: "Bearer expired.jwt.token", + mockReturn: func(token string) (jwt.MapClaims, error) { + return nil, errors.New("token has expired") + }, + wantStatus: http.StatusUnauthorized, + wantError: "token has expired", + }, + { + name: "invalid token", + authHeader: "Bearer tampered.token", + mockReturn: func(token string) (jwt.MapClaims, error) { + return nil, errors.New("invalid or altered token") + }, + wantStatus: http.StatusUnauthorized, + wantError: "invalid or altered token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &mockAuthService{ + verifyFn: tc.mockReturn, + } + if svc.verifyFn == nil { + svc.verifyFn = func(_ string) (jwt.MapClaims, error) { + t.Error("Verify: service should not have been called") + return nil, nil + } + } + + req := httptest.NewRequest(http.MethodPost, "/verify", http.NoBody) + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + rec := httptest.NewRecorder() + + newTestRouter(svc).ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Errorf("status: got %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body) + } + + if tc.wantError != "" { + var resp map[string]string + decode(t, rec.Body.Bytes(), &resp) + if resp["error"] != tc.wantError { + t.Errorf("error message: got %q, want %q", resp["error"], tc.wantError) + } + } + + if tc.wantClaims { + var claims map[string]any + decode(t, rec.Body.Bytes(), &claims) + if _, ok := claims["user_id"]; !ok { + t.Errorf("expected claims in body, got: %s", rec.Body) + } + } + }) + } +} From ad02ff779f056e846ce0b30f4c12958164c65985 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 21:36:03 +0530 Subject: [PATCH 08/13] Add booking microservice, also refactor code to make it more modularized. --- booking-service/cmd/main.go | 61 ++++++++ booking-service/compose.yaml | 22 +++ booking-service/go.mod | 47 ++++++ booking-service/go.sum | 93 +++++++++++ booking-service/internal/booking/service.go | 101 ++++++++++++ .../internal/delivery/http/booking_handler.go | 117 ++++++++++++++ .../internal/delivery/http/venue_handler.go | 146 ++++++++++++++++++ booking-service/internal/domain/booking.go | 48 ++++++ booking-service/internal/domain/venue.go | 40 +++++ .../repository/postgres_booking_repo.go | 102 ++++++++++++ .../repository/postgres_venue_repo.go | 103 ++++++++++++ .../repository/redis_booking_cache.go | 53 +++++++ booking-service/internal/venue/service.go | 81 ++++++++++ .../001_create_venues_table.down.sql | 1 + .../migrations/001_create_venues_table.up.sql | 14 ++ .../002_create_bookings_table.down.sql | 1 + .../002_create_bookings_table.up.sql | 14 ++ go.work | 7 + go.work.sum | 5 + shared/config/config.go | 13 ++ shared/go.mod | 37 +++++ shared/go.sum | 80 ++++++++++ shared/middleware/auth.go | 52 +++++++ shared/middleware/role.go | 26 ++++ 24 files changed, 1264 insertions(+) create mode 100644 booking-service/cmd/main.go create mode 100644 booking-service/compose.yaml create mode 100644 booking-service/go.mod create mode 100644 booking-service/go.sum create mode 100644 booking-service/internal/booking/service.go create mode 100644 booking-service/internal/delivery/http/booking_handler.go create mode 100644 booking-service/internal/delivery/http/venue_handler.go create mode 100644 booking-service/internal/domain/booking.go create mode 100644 booking-service/internal/domain/venue.go create mode 100644 booking-service/internal/repository/postgres_booking_repo.go create mode 100644 booking-service/internal/repository/postgres_venue_repo.go create mode 100644 booking-service/internal/repository/redis_booking_cache.go create mode 100644 booking-service/internal/venue/service.go create mode 100644 booking-service/migrations/001_create_venues_table.down.sql create mode 100644 booking-service/migrations/001_create_venues_table.up.sql create mode 100644 booking-service/migrations/002_create_bookings_table.down.sql create mode 100644 booking-service/migrations/002_create_bookings_table.up.sql create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 shared/config/config.go create mode 100644 shared/go.mod create mode 100644 shared/go.sum create mode 100644 shared/middleware/auth.go create mode 100644 shared/middleware/role.go diff --git a/booking-service/cmd/main.go b/booking-service/cmd/main.go new file mode 100644 index 0000000000..bae3030fa0 --- /dev/null +++ b/booking-service/cmd/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "booking-service/internal/booking" + delivery "booking-service/internal/delivery/http" + "booking-service/internal/repository" + "booking-service/internal/venue" + "context" + "log" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + "bookmyvenue.com/shared/config" +) + +func main() { + // ── Config ──────────────────────────────────────────────────────────────── + dbURL := config.MustGetEnv("DATABASE_URL", + "postgres://postgres:secret@localhost:5433/bookmyvenue_bookings?sslmode=disable") + redisAddr := config.MustGetEnv("REDIS_ADDR", "localhost:6379") + jwtSecret := config.MustGetEnv("JWT_SECRET", "secret") + port := config.MustGetEnv("PORT", ":8081") + + // ── PostgreSQL ──────────────────────────────────────────────────────────── + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + dbPool, err := pgxpool.New(ctx, dbURL) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + defer dbPool.Close() + + // ── Redis ───────────────────────────────────────────────────────────────── + redisClient := redis.NewClient(&redis.Options{Addr: redisAddr}) + if _, err := redisClient.Ping(ctx).Result(); err != nil { + log.Fatalf("Redis connection failed: %v", err) + } + defer redisClient.Close() + + // ── Repositories & Services ─────────────────────────────────────────────── + venueRepo := repository.NewPostgresVenueRepository(dbPool) + bookingRepo := repository.NewPostgresBookingRepository(dbPool) + bookingCache := repository.NewRedisBookingCache(redisClient) + + venueSvc := venue.NewVenueService(venueRepo) + bookingSvc := booking.NewBookingService(bookingRepo, bookingCache) + + // ── Router ──────────────────────────────────────────────────────────────── + r := gin.Default() + api := r.Group("/") + delivery.RegisterVenueRoutes(api, venueSvc, jwtSecret) + delivery.RegisterBookingRoutes(api, bookingSvc, jwtSecret) + + log.Printf("booking-service listening on %s", port) + if err := r.Run(port); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/booking-service/compose.yaml b/booking-service/compose.yaml new file mode 100644 index 0000000000..229cd6b92e --- /dev/null +++ b/booking-service/compose.yaml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: secret + POSTGRES_DB: bookmyvenue_bookings + ports: + - "5433:5432" + volumes: + - booking_pg_data:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + +volumes: + booking_pg_data: diff --git a/booking-service/go.mod b/booking-service/go.mod new file mode 100644 index 0000000000..84265e4699 --- /dev/null +++ b/booking-service/go.mod @@ -0,0 +1,47 @@ +module booking-service + +go 1.25.2 + +require bookmyvenue.com/shared v0.0.0 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.20.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) + +replace bookmyvenue.com/shared => ../shared diff --git a/booking-service/go.sum b/booking-service/go.sum new file mode 100644 index 0000000000..afbf777e08 --- /dev/null +++ b/booking-service/go.sum @@ -0,0 +1,93 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/booking-service/internal/booking/service.go b/booking-service/internal/booking/service.go new file mode 100644 index 0000000000..de612f4371 --- /dev/null +++ b/booking-service/internal/booking/service.go @@ -0,0 +1,101 @@ +// Package booking implements the core business logic for booking management. +package booking + +import ( + "booking-service/internal/domain" + "errors" + "log" + "time" +) + +// Service is the booking business-logic contract. +type Service interface { + CreateBooking(userID, venueID string, start, end time.Time) (*domain.Booking, error) + GetBooking(callerID, callerRole, bookingID string) (*domain.Booking, error) + ListUserBookings(userID string) ([]domain.Booking, error) + ListAllBookings() ([]domain.Booking, error) + CancelBooking(callerID, callerRole, bookingID string) error +} + +type bookingService struct { + repo domain.BookingRepository + cache domain.BookingCache +} + +func NewBookingService(repo domain.BookingRepository, cache domain.BookingCache) Service { + return &bookingService{repo: repo, cache: cache} +} + +func (s *bookingService) CreateBooking(userID, venueID string, start, end time.Time) (*domain.Booking, error) { + available, err := s.repo.IsVenueAvailable(venueID, start, end) + if err != nil { + return nil, err + } + if !available { + return nil, domain.ErrVenueUnavailable + } + + b := &domain.Booking{ + UserID: userID, + VenueID: venueID, + StartTime: start, + EndTime: end, + Status: domain.StatusConfirmed, + } + if err := s.repo.Create(b); err != nil { + return nil, err + } + if err := s.cache.Set(b); err != nil { + log.Printf("cache.Set booking %s: %v", b.ID, err) + } + return b, nil +} + +// GetBooking checks cache first; falls back to DB and repopulates cache on miss. +// Any caller may read their own bookings; admins may read any booking. +func (s *bookingService) GetBooking(callerID, callerRole, id string) (*domain.Booking, error) { + b, err := s.cache.Get(id) + if err != nil { + if !errors.Is(err, domain.ErrBookingNotFound) { + log.Printf("cache.Get booking %s: %v", id, err) + } + b, err = s.repo.GetByID(id) + if err != nil { + return nil, err + } + if cacheErr := s.cache.Set(b); cacheErr != nil { + log.Printf("cache.Set booking %s: %v", id, cacheErr) + } + } + + if callerRole != domain.RoleAdmin && b.UserID != callerID { + return nil, domain.ErrBookingForbidden + } + return b, nil +} + +func (s *bookingService) ListUserBookings(userID string) ([]domain.Booking, error) { + return s.repo.ListByUserID(userID) +} + +func (s *bookingService) ListAllBookings() ([]domain.Booking, error) { + return s.repo.ListAll() +} + +// CancelBooking allows the booking owner or an admin to cancel. +func (s *bookingService) CancelBooking(callerID, callerRole, id string) error { + b, err := s.repo.GetByID(id) + if err != nil { + return err + } + if callerRole != domain.RoleAdmin && b.UserID != callerID { + return domain.ErrBookingForbidden + } + if err := s.repo.UpdateStatus(id, domain.StatusCancelled); err != nil { + return err + } + if err := s.cache.Delete(id); err != nil { + log.Printf("cache.Delete booking %s: %v", id, err) + } + return nil +} diff --git a/booking-service/internal/delivery/http/booking_handler.go b/booking-service/internal/delivery/http/booking_handler.go new file mode 100644 index 0000000000..53bf614cdd --- /dev/null +++ b/booking-service/internal/delivery/http/booking_handler.go @@ -0,0 +1,117 @@ +package http + +import ( + "booking-service/internal/booking" + "booking-service/internal/domain" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "bookmyvenue.com/shared/middleware" +) + +type BookingHandler struct { + svc booking.Service +} + +type createBookingRequest struct { + VenueID string `json:"venue_id" binding:"required"` + StartTime time.Time `json:"start_time" binding:"required"` + EndTime time.Time `json:"end_time" binding:"required"` +} + +// RegisterBookingRoutes mounts booking endpoints β€” all require authentication. +func RegisterBookingRoutes(rg *gin.RouterGroup, svc booking.Service, jwtSecret string) { + h := &BookingHandler{svc: svc} + + auth := middleware.JWTAuth(jwtSecret) + userOrAdmin := middleware.RequireRole(domain.RoleUser, domain.RoleAdmin) + + rg.POST("/bookings", auth, userOrAdmin, h.CreateBooking) + rg.GET("/bookings", auth, h.ListBookings) // user β†’ own; admin β†’ all + rg.GET("/bookings/:id", auth, h.GetBooking) + rg.DELETE("/bookings/:id", auth, h.CancelBooking) +} + +func (h *BookingHandler) CreateBooking(c *gin.Context) { + var req createBookingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !req.EndTime.After(req.StartTime) { + c.JSON(http.StatusBadRequest, gin.H{"error": "end_time must be after start_time"}) + return + } + + userID := c.GetString(middleware.CtxUserID) + b, err := h.svc.CreateBooking(userID, req.VenueID, req.StartTime, req.EndTime) + if err != nil { + if errors.Is(err, domain.ErrVenueUnavailable) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + c.JSON(http.StatusCreated, b) +} + +// ListBookings returns the caller's own bookings; admins receive all bookings. +func (h *BookingHandler) ListBookings(c *gin.Context) { + callerID := c.GetString(middleware.CtxUserID) + callerRole := c.GetString(middleware.CtxUserRole) + + var ( + bookings []domain.Booking + err error + ) + if callerRole == domain.RoleAdmin { + bookings, err = h.svc.ListAllBookings() + } else { + bookings, err = h.svc.ListUserBookings(callerID) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + c.JSON(http.StatusOK, bookings) +} + +func (h *BookingHandler) GetBooking(c *gin.Context) { + callerID := c.GetString(middleware.CtxUserID) + callerRole := c.GetString(middleware.CtxUserRole) + + b, err := h.svc.GetBooking(callerID, callerRole, c.Param("id")) + if err != nil { + switch { + case errors.Is(err, domain.ErrBookingNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, domain.ErrBookingForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + return + } + c.JSON(http.StatusOK, b) +} + +func (h *BookingHandler) CancelBooking(c *gin.Context) { + callerID := c.GetString(middleware.CtxUserID) + callerRole := c.GetString(middleware.CtxUserRole) + + if err := h.svc.CancelBooking(callerID, callerRole, c.Param("id")); err != nil { + switch { + case errors.Is(err, domain.ErrBookingNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, domain.ErrBookingForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "booking cancelled"}) +} diff --git a/booking-service/internal/delivery/http/venue_handler.go b/booking-service/internal/delivery/http/venue_handler.go new file mode 100644 index 0000000000..6e7d1980ff --- /dev/null +++ b/booking-service/internal/delivery/http/venue_handler.go @@ -0,0 +1,146 @@ +package http + +import ( + "booking-service/internal/domain" + "booking-service/internal/venue" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "bookmyvenue.com/shared/middleware" +) + +type VenueHandler struct { + svc venue.Service +} + +type createVenueRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Location string `json:"location" binding:"required"` + Capacity int `json:"capacity" binding:"required,min=1"` + PricePerHour float64 `json:"price_per_hour" binding:"required,min=0"` +} + +type updateVenueRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Location string `json:"location" binding:"required"` + Capacity int `json:"capacity" binding:"required,min=1"` + PricePerHour float64 `json:"price_per_hour" binding:"required,min=0"` +} + +// RegisterVenueRoutes mounts venue endpoints on the router group. +// Public routes require no auth; protected routes require JWTAuth + RequireRole. +func RegisterVenueRoutes(rg *gin.RouterGroup, svc venue.Service, jwtSecret string) { + h := &VenueHandler{svc: svc} + + auth := middleware.JWTAuth(jwtSecret) + ownerOrAdmin := middleware.RequireRole(domain.RoleOwner, domain.RoleAdmin) + + // Public + rg.GET("/venues", h.ListVenues) + rg.GET("/venues/:id", h.GetVenue) + + // Owner / Admin only + rg.POST("/venues", auth, ownerOrAdmin, h.CreateVenue) + rg.PUT("/venues/:id", auth, ownerOrAdmin, h.UpdateVenue) + rg.DELETE("/venues/:id", auth, ownerOrAdmin, h.DeleteVenue) + + // Owner: list own venues + rg.GET("/venues/mine", auth, middleware.RequireRole(domain.RoleOwner, domain.RoleAdmin), h.ListMyVenues) +} + +func (h *VenueHandler) ListVenues(c *gin.Context) { + venues, err := h.svc.ListVenues() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + c.JSON(http.StatusOK, venues) +} + +func (h *VenueHandler) GetVenue(c *gin.Context) { + v, err := h.svc.GetVenue(c.Param("id")) + if err != nil { + if errors.Is(err, domain.ErrVenueNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + c.JSON(http.StatusOK, v) +} + +func (h *VenueHandler) CreateVenue(c *gin.Context) { + var req createVenueRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ownerID := c.GetString(middleware.CtxUserID) + v, err := h.svc.CreateVenue(ownerID, req.Name, req.Description, req.Location, req.Capacity, req.PricePerHour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + c.JSON(http.StatusCreated, v) +} + +func (h *VenueHandler) UpdateVenue(c *gin.Context) { + var req updateVenueRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + callerID := c.GetString(middleware.CtxUserID) + callerRole := c.GetString(middleware.CtxUserRole) + v := &domain.Venue{ + ID: c.Param("id"), + Name: req.Name, + Description: req.Description, + Location: req.Location, + Capacity: req.Capacity, + PricePerHour: req.PricePerHour, + } + if err := h.svc.UpdateVenue(callerID, callerRole, v); err != nil { + switch { + case errors.Is(err, domain.ErrVenueNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, domain.ErrVenueForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "venue updated"}) +} + +func (h *VenueHandler) DeleteVenue(c *gin.Context) { + callerID := c.GetString(middleware.CtxUserID) + callerRole := c.GetString(middleware.CtxUserRole) + if err := h.svc.DeleteVenue(callerID, callerRole, c.Param("id")); err != nil { + switch { + case errors.Is(err, domain.ErrVenueNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, domain.ErrVenueForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "venue deleted"}) +} + +func (h *VenueHandler) ListMyVenues(c *gin.Context) { + ownerID := c.GetString(middleware.CtxUserID) + venues, err := h.svc.ListMyVenues(ownerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + c.JSON(http.StatusOK, venues) +} diff --git a/booking-service/internal/domain/booking.go b/booking-service/internal/domain/booking.go new file mode 100644 index 0000000000..c09dca4268 --- /dev/null +++ b/booking-service/internal/domain/booking.go @@ -0,0 +1,48 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrBookingNotFound = errors.New("booking not found") + ErrVenueUnavailable = errors.New("venue is not available for the requested time slot") + ErrBookingForbidden = errors.New("not authorized to manage this booking") +) + +type BookingStatus string + +const ( + StatusPending BookingStatus = "pending" + StatusConfirmed BookingStatus = "confirmed" + StatusCancelled BookingStatus = "cancelled" +) + +// Booking represents a reservation of a venue by a user for a time window. +type Booking struct { + ID string `json:"id"` + UserID string `json:"user_id"` + VenueID string `json:"venue_id"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Status BookingStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// BookingRepository is the persistence contract for bookings. +type BookingRepository interface { + Create(b *Booking) error + GetByID(id string) (*Booking, error) + ListByUserID(userID string) ([]Booking, error) + ListAll() ([]Booking, error) + UpdateStatus(id string, status BookingStatus) error + IsVenueAvailable(venueID string, start, end time.Time) (bool, error) +} + +// BookingCache is the caching contract for bookings (backed by Redis). +type BookingCache interface { + Set(b *Booking) error + Get(id string) (*Booking, error) + Delete(id string) error +} diff --git a/booking-service/internal/domain/venue.go b/booking-service/internal/domain/venue.go new file mode 100644 index 0000000000..76db3f4f24 --- /dev/null +++ b/booking-service/internal/domain/venue.go @@ -0,0 +1,40 @@ +package domain + +import ( + "errors" + "time" +) + +// Role constants shared between auth-service tokens and booking-service authorization. +const ( + RoleUser = "user" + RoleOwner = "owner" + RoleAdmin = "admin" +) + +var ( + ErrVenueNotFound = errors.New("venue not found") + ErrVenueForbidden = errors.New("not authorized to manage this venue") +) + +// Venue represents a bookable space owned by a user with the owner role. +type Venue struct { + ID string `json:"id"` + OwnerID string `json:"owner_id"` + Name string `json:"name"` + Description string `json:"description"` + Location string `json:"location"` + Capacity int `json:"capacity"` + PricePerHour float64 `json:"price_per_hour"` + CreatedAt time.Time `json:"created_at"` +} + +// VenueRepository is the persistence contract for venues. +type VenueRepository interface { + Create(v *Venue) error + GetByID(id string) (*Venue, error) + ListAll() ([]Venue, error) + ListByOwner(ownerID string) ([]Venue, error) + Update(v *Venue) error + Delete(id string) error +} diff --git a/booking-service/internal/repository/postgres_booking_repo.go b/booking-service/internal/repository/postgres_booking_repo.go new file mode 100644 index 0000000000..f4a3b27581 --- /dev/null +++ b/booking-service/internal/repository/postgres_booking_repo.go @@ -0,0 +1,102 @@ +package repository + +import ( + "booking-service/internal/domain" + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type postgresBookingRepo struct { + db *pgxpool.Pool +} + +// NewPostgresBookingRepository returns a BookingRepository backed by PostgreSQL. +func NewPostgresBookingRepository(db *pgxpool.Pool) domain.BookingRepository { + return &postgresBookingRepo{db: db} +} + +func (r *postgresBookingRepo) Create(b *domain.Booking) error { + q := `INSERT INTO bookings (user_id, venue_id, start_time, end_time, status) + VALUES ($1,$2,$3,$4,$5) + RETURNING id, created_at` + return r.db.QueryRow(context.Background(), q, + b.UserID, b.VenueID, b.StartTime, b.EndTime, b.Status, + ).Scan(&b.ID, &b.CreatedAt) +} + +func (r *postgresBookingRepo) GetByID(id string) (*domain.Booking, error) { + q := `SELECT id, user_id, venue_id, start_time, end_time, status, created_at + FROM bookings WHERE id = $1` + var b domain.Booking + err := r.db.QueryRow(context.Background(), q, id).Scan( + &b.ID, &b.UserID, &b.VenueID, &b.StartTime, &b.EndTime, &b.Status, &b.CreatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrBookingNotFound + } + return nil, err + } + return &b, nil +} + +func (r *postgresBookingRepo) ListByUserID(userID string) ([]domain.Booking, error) { + q := `SELECT id, user_id, venue_id, start_time, end_time, status, created_at + FROM bookings WHERE user_id = $1 ORDER BY created_at DESC` + return r.scanBookings(q, userID) +} + +func (r *postgresBookingRepo) ListAll() ([]domain.Booking, error) { + q := `SELECT id, user_id, venue_id, start_time, end_time, status, created_at + FROM bookings ORDER BY created_at DESC` + return r.scanBookings(q) +} + +func (r *postgresBookingRepo) UpdateStatus(id string, status domain.BookingStatus) error { + tag, err := r.db.Exec(context.Background(), + `UPDATE bookings SET status=$1 WHERE id=$2`, status, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrBookingNotFound + } + return nil +} + +// IsVenueAvailable returns true when no active (non-cancelled) booking overlaps +// the requested [start, end) window for the given venue. +func (r *postgresBookingRepo) IsVenueAvailable(venueID string, start, end time.Time) (bool, error) { + q := `SELECT COUNT(*) FROM bookings + WHERE venue_id = $1 AND status != 'cancelled' + AND start_time < $3 AND end_time > $2` + var count int + err := r.db.QueryRow(context.Background(), q, venueID, start, end).Scan(&count) + if err != nil { + return false, err + } + return count == 0, nil +} + +func (r *postgresBookingRepo) scanBookings(q string, args ...any) ([]domain.Booking, error) { + rows, err := r.db.Query(context.Background(), q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var bookings []domain.Booking + for rows.Next() { + var b domain.Booking + if err := rows.Scan(&b.ID, &b.UserID, &b.VenueID, &b.StartTime, + &b.EndTime, &b.Status, &b.CreatedAt); err != nil { + return nil, err + } + bookings = append(bookings, b) + } + return bookings, rows.Err() +} diff --git a/booking-service/internal/repository/postgres_venue_repo.go b/booking-service/internal/repository/postgres_venue_repo.go new file mode 100644 index 0000000000..069af206d5 --- /dev/null +++ b/booking-service/internal/repository/postgres_venue_repo.go @@ -0,0 +1,103 @@ +package repository + +import ( + "booking-service/internal/domain" + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type postgresVenueRepo struct { + db *pgxpool.Pool +} + +// NewPostgresVenueRepository returns a VenueRepository backed by PostgreSQL. +func NewPostgresVenueRepository(db *pgxpool.Pool) domain.VenueRepository { + return &postgresVenueRepo{db: db} +} + +func (r *postgresVenueRepo) Create(v *domain.Venue) error { + q := `INSERT INTO venues (owner_id, name, description, location, capacity, price_per_hour) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING id, created_at` + return r.db.QueryRow(context.Background(), q, + v.OwnerID, v.Name, v.Description, v.Location, v.Capacity, v.PricePerHour, + ).Scan(&v.ID, &v.CreatedAt) +} + +func (r *postgresVenueRepo) GetByID(id string) (*domain.Venue, error) { + q := `SELECT id, owner_id, name, description, location, capacity, price_per_hour, created_at + FROM venues WHERE id = $1` + var v domain.Venue + err := r.db.QueryRow(context.Background(), q, id).Scan( + &v.ID, &v.OwnerID, &v.Name, &v.Description, &v.Location, + &v.Capacity, &v.PricePerHour, &v.CreatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrVenueNotFound + } + return nil, err + } + return &v, nil +} + +func (r *postgresVenueRepo) ListAll() ([]domain.Venue, error) { + q := `SELECT id, owner_id, name, description, location, capacity, price_per_hour, created_at + FROM venues ORDER BY created_at DESC` + return r.scanVenues(q) +} + +func (r *postgresVenueRepo) ListByOwner(ownerID string) ([]domain.Venue, error) { + q := `SELECT id, owner_id, name, description, location, capacity, price_per_hour, created_at + FROM venues WHERE owner_id = $1 ORDER BY created_at DESC` + return r.scanVenues(q, ownerID) +} + +func (r *postgresVenueRepo) Update(v *domain.Venue) error { + q := `UPDATE venues SET name=$1, description=$2, location=$3, capacity=$4, price_per_hour=$5 + WHERE id=$6` + tag, err := r.db.Exec(context.Background(), q, + v.Name, v.Description, v.Location, v.Capacity, v.PricePerHour, v.ID, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrVenueNotFound + } + return nil +} + +func (r *postgresVenueRepo) Delete(id string) error { + tag, err := r.db.Exec(context.Background(), `DELETE FROM venues WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrVenueNotFound + } + return nil +} + +// scanVenues is a helper that runs a SELECT query and collects rows into a slice. +func (r *postgresVenueRepo) scanVenues(q string, args ...any) ([]domain.Venue, error) { + rows, err := r.db.Query(context.Background(), q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var venues []domain.Venue + for rows.Next() { + var v domain.Venue + if err := rows.Scan(&v.ID, &v.OwnerID, &v.Name, &v.Description, &v.Location, + &v.Capacity, &v.PricePerHour, &v.CreatedAt); err != nil { + return nil, err + } + venues = append(venues, v) + } + return venues, rows.Err() +} diff --git a/booking-service/internal/repository/redis_booking_cache.go b/booking-service/internal/repository/redis_booking_cache.go new file mode 100644 index 0000000000..767e15fb4f --- /dev/null +++ b/booking-service/internal/repository/redis_booking_cache.go @@ -0,0 +1,53 @@ +package repository + +import ( + "booking-service/internal/domain" + "context" + "encoding/json" + "errors" + "time" + + "github.com/redis/go-redis/v9" +) + +const cacheTTL = time.Hour + +type redisBookingCache struct { + client *redis.Client +} + +// NewRedisBookingCache returns a BookingCache backed by Redis. +func NewRedisBookingCache(client *redis.Client) domain.BookingCache { + return &redisBookingCache{client: client} +} + +func key(id string) string { return "booking:" + id } + +func (r *redisBookingCache) Set(b *domain.Booking) error { + data, err := json.Marshal(b) + if err != nil { + return err + } + return r.client.Set(context.Background(), key(b.ID), data, cacheTTL).Err() +} + +// Get returns ErrBookingNotFound on a cache miss so the service layer can +// fall through to the database transparently. +func (r *redisBookingCache) Get(id string) (*domain.Booking, error) { + data, err := r.client.Get(context.Background(), key(id)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, domain.ErrBookingNotFound + } + return nil, err + } + var b domain.Booking + if err := json.Unmarshal(data, &b); err != nil { + return nil, err + } + return &b, nil +} + +func (r *redisBookingCache) Delete(id string) error { + return r.client.Del(context.Background(), key(id)).Err() +} diff --git a/booking-service/internal/venue/service.go b/booking-service/internal/venue/service.go new file mode 100644 index 0000000000..78f76cf859 --- /dev/null +++ b/booking-service/internal/venue/service.go @@ -0,0 +1,81 @@ +// Package venue implements the core business logic for venue management. +package venue + +import ( + "booking-service/internal/domain" + "errors" +) + +// Service is the venue business-logic contract. +type Service interface { + CreateVenue(ownerID, name, description, location string, capacity int, pricePerHour float64) (*domain.Venue, error) + GetVenue(id string) (*domain.Venue, error) + ListVenues() ([]domain.Venue, error) + ListMyVenues(ownerID string) ([]domain.Venue, error) + UpdateVenue(callerID, callerRole string, v *domain.Venue) error + DeleteVenue(callerID, callerRole, venueID string) error +} + +type venueService struct { + repo domain.VenueRepository +} + +func NewVenueService(repo domain.VenueRepository) Service { + return &venueService{repo: repo} +} + +func (s *venueService) CreateVenue(ownerID, name, description, location string, capacity int, price float64) (*domain.Venue, error) { + v := &domain.Venue{ + OwnerID: ownerID, + Name: name, + Description: description, + Location: location, + Capacity: capacity, + PricePerHour: price, + } + if err := s.repo.Create(v); err != nil { + return nil, err + } + return v, nil +} + +func (s *venueService) GetVenue(id string) (*domain.Venue, error) { + return s.repo.GetByID(id) +} + +func (s *venueService) ListVenues() ([]domain.Venue, error) { + return s.repo.ListAll() +} + +func (s *venueService) ListMyVenues(ownerID string) ([]domain.Venue, error) { + return s.repo.ListByOwner(ownerID) +} + +// UpdateVenue authorizes the caller β€” only the owning user or an admin may update. +func (s *venueService) UpdateVenue(callerID, callerRole string, v *domain.Venue) error { + existing, err := s.repo.GetByID(v.ID) + if err != nil { + return err + } + if callerRole != domain.RoleAdmin && existing.OwnerID != callerID { + return domain.ErrVenueForbidden + } + return s.repo.Update(v) +} + +// DeleteVenue authorizes the caller β€” only the owning user or an admin may delete. +func (s *venueService) DeleteVenue(callerID, callerRole, venueID string) error { + existing, err := s.repo.GetByID(venueID) + if err != nil { + return err + } + if callerRole != domain.RoleAdmin && existing.OwnerID != callerID { + return domain.ErrVenueForbidden + } + return s.repo.Delete(venueID) +} + +// IsForbidden is a helper for handlers to detect authorization errors. +func IsForbidden(err error) bool { + return errors.Is(err, domain.ErrVenueForbidden) +} diff --git a/booking-service/migrations/001_create_venues_table.down.sql b/booking-service/migrations/001_create_venues_table.down.sql new file mode 100644 index 0000000000..355e078014 --- /dev/null +++ b/booking-service/migrations/001_create_venues_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS venues; diff --git a/booking-service/migrations/001_create_venues_table.up.sql b/booking-service/migrations/001_create_venues_table.up.sql new file mode 100644 index 0000000000..25c7cfc33c --- /dev/null +++ b/booking-service/migrations/001_create_venues_table.up.sql @@ -0,0 +1,14 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS venues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL, + name TEXT NOT NULL, + description TEXT, + location TEXT NOT NULL, + capacity INT NOT NULL CHECK (capacity > 0), + price_per_hour NUMERIC(10, 2) NOT NULL CHECK (price_per_hour >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_venues_owner_id ON venues(owner_id); diff --git a/booking-service/migrations/002_create_bookings_table.down.sql b/booking-service/migrations/002_create_bookings_table.down.sql new file mode 100644 index 0000000000..e742c5fea9 --- /dev/null +++ b/booking-service/migrations/002_create_bookings_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS bookings; diff --git a/booking-service/migrations/002_create_bookings_table.up.sql b/booking-service/migrations/002_create_bookings_table.up.sql new file mode 100644 index 0000000000..103a682604 --- /dev/null +++ b/booking-service/migrations/002_create_bookings_table.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + venue_id UUID NOT NULL REFERENCES venues(id), + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed', 'cancelled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT bookings_end_after_start CHECK (end_time > start_time) +); + +CREATE INDEX idx_bookings_user_id ON bookings(user_id); +CREATE INDEX idx_bookings_venue_id ON bookings(venue_id); diff --git a/go.work b/go.work new file mode 100644 index 0000000000..964894dde1 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.25.2 + +use ( + ./auth-service + ./booking-service + ./shared +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000000..585b6b80c2 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,5 @@ +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= diff --git a/shared/config/config.go b/shared/config/config.go new file mode 100644 index 0000000000..5208b417d7 --- /dev/null +++ b/shared/config/config.go @@ -0,0 +1,13 @@ +// Package config provides lightweight environment-based configuration helpers. +package config + +import "os" + +// MustGetEnv returns the value of the environment variable named by key. +// If the variable is unset or empty, fallback is returned instead. +func MustGetEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/shared/go.mod b/shared/go.mod new file mode 100644 index 0000000000..10f6d57e0b --- /dev/null +++ b/shared/go.mod @@ -0,0 +1,37 @@ +module bookmyvenue.com/shared + +go 1.25.2 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/shared/go.sum b/shared/go.sum new file mode 100644 index 0000000000..b133b144cb --- /dev/null +++ b/shared/go.sum @@ -0,0 +1,80 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shared/middleware/auth.go b/shared/middleware/auth.go new file mode 100644 index 0000000000..49dce68ed5 --- /dev/null +++ b/shared/middleware/auth.go @@ -0,0 +1,52 @@ +// Package middleware provides reusable Gin middleware for all BookMyVenue services. +package middleware + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// Context keys written by JWTAuth and read by handlers and RequireRole. +const ( + CtxUserID = "user_id" + CtxUserRole = "user_role" +) + +// JWTAuth returns a Gin middleware that validates a Bearer JWT in the +// Authorization header. On success it writes CtxUserID and CtxUserRole +// into the Gin context for downstream handlers. +func JWTAuth(secretKey string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or malformed token"}) + return + } + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(secretKey), nil + }) + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"}) + return + } + + c.Set(CtxUserID, fmt.Sprint(claims["user_id"])) + c.Set(CtxUserRole, fmt.Sprint(claims["role"])) + c.Next() + } +} diff --git a/shared/middleware/role.go b/shared/middleware/role.go new file mode 100644 index 0000000000..8d5de19190 --- /dev/null +++ b/shared/middleware/role.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// RequireRole returns a Gin middleware that aborts with 403 if the authenticated +// user's role (set by JWTAuth) is not in the allowed list. +// Must be chained after JWTAuth. +func RequireRole(roles ...string) gin.HandlerFunc { + allowed := make(map[string]struct{}, len(roles)) + for _, r := range roles { + allowed[r] = struct{}{} + } + + return func(c *gin.Context) { + role := c.GetString(CtxUserRole) + if _, ok := allowed[role]; !ok { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + c.Next() + } +} From 5c7a0ade36736207f5e667d255f443fdc786bf94 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 21:36:50 +0530 Subject: [PATCH 09/13] Basic landing page + login/register pages. --- frontend/next.config.ts | 9 +- frontend/src/app/login/page.tsx | 199 +++++++++++++++++ frontend/src/app/my-bookings/page.tsx | 239 ++++++++++++++++++++ frontend/src/app/page.tsx | 2 + frontend/src/app/register/page.tsx | 254 ++++++++++++++++++++++ frontend/src/app/venues/[id]/page.tsx | 302 ++++++++++++++++++++++++++ frontend/src/app/venues/page.tsx | 166 ++++++++++++++ 7 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/my-bookings/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/app/venues/[id]/page.tsx create mode 100644 frontend/src/app/venues/page.tsx diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa3083a..a9194aacb2 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "images.unsplash.com", + }, + ], + }, }; export default nextConfig; diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000000..74bc6f5255 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { login, isAuthenticated } from "@/lib/auth"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + useEffect(() => { + if (isAuthenticated()) router.replace("/venues"); + }, [router]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (!email || !password) { + setError("Please fill in all fields."); + return; + } + setLoading(true); + try { + await login(email, password); + window.dispatchEvent(new Event("storage")); + router.push("/venues"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Login failed."); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Background glow */} +
+ +
+ {/* Card */} +
+ {/* Brand */} +
+
+ πŸ“ +
+

+ Welcome back +

+

+ Sign in to your BookMyVenue account +

+
+ +
+
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Don't have an account?{" "} + + Create one β†’ + +

+
+
+
+ ); +} diff --git a/frontend/src/app/my-bookings/page.tsx b/frontend/src/app/my-bookings/page.tsx new file mode 100644 index 0000000000..4e7c6f9f79 --- /dev/null +++ b/frontend/src/app/my-bookings/page.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import AuthGuard from "@/components/AuthGuard"; +import { getMyBookings, cancelBooking, formatPrice } from "@/lib/bookings"; +import type { Booking } from "@/lib/bookings"; + +const STATUS_CONFIG = { + confirmed: { label: "Confirmed", className: "badge-success", icon: "βœ“" }, + pending: { label: "Pending", className: "badge-warning", icon: "⏳" }, + cancelled: { label: "Cancelled", className: "badge-error", icon: "βœ•" }, +}; + +function BookingCard({ booking, onCancel }: { booking: Booking; onCancel: (id: string) => void }) { + const status = STATUS_CONFIG[booking.status]; + const [cancelling, setCancelling] = useState(false); + + function handleCancel() { + setCancelling(true); + setTimeout(() => { + cancelBooking(booking.id); + onCancel(booking.id); + setCancelling(false); + }, 500); + } + + const formattedDate = new Date(booking.date).toLocaleDateString("en-IN", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( +
+
+ {/* Venue thumbnail */} +
+ {booking.venueName} +
+ + {/* Content */} +
+ {/* Header */} +
+
+

+ {booking.venueName} +

+

+ πŸ“ {booking.venueLocation} +

+
+ + {status.icon} {status.label} + +
+ + {/* Details row */} +
+ πŸ“… {formattedDate} + πŸ• {booking.startTime} – {booking.endTime} + πŸ‘₯ {booking.guestCount} guests +
+ + {/* Price & Actions */} +
+ + {formatPrice(booking.totalPrice)} + +
+ + View Venue + + {booking.status === "confirmed" && ( + + )} +
+
+
+
+ + {/* Booking ID footer */} +
+ Booking ID: {booking.id} Β· Booked on {new Date(booking.createdAt).toLocaleDateString("en-IN")} +
+
+ ); +} + +function MyBookingsContent() { + const [bookings, setBookings] = useState([]); + + useEffect(() => { + setBookings(getMyBookings()); + }, []); + + function handleCancel(id: string) { + setBookings(getMyBookings()); + } + + const confirmed = bookings.filter((b) => b.status === "confirmed"); + const past = bookings.filter((b) => b.status !== "confirmed"); + + return ( +
+ {/* Header */} +
+

+ My Bookings +

+

+ Track and manage your venue reservations +

+
+ + {bookings.length === 0 ? ( +
+
πŸ“…
+

+ No bookings yet +

+

+ Discover and book amazing venues for your next event. +

+ + Browse Venues β†’ + +
+ ) : ( + <> + {/* Upcoming */} + {confirmed.length > 0 && ( +
+

+ βœ“ {confirmed.length} + Upcoming Bookings +

+
+ {confirmed.map((b) => ( + + ))} +
+
+ )} + + {/* Past / Cancelled */} + {past.length > 0 && ( +
+

+ Past & Cancelled +

+
+ {past.map((b) => ( + + ))} +
+
+ )} + + )} +
+ ); +} + +export default function MyBookingsPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0727c448c3..6c054e3658 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; export default function Home() { diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000000..6b37edb9fc --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { register, login, isAuthenticated } from "@/lib/auth"; + +const ROLES = [ + { value: "user", label: "πŸ‘€ User β€” I want to book venues" }, + { value: "venue_owner", label: "🏒 Venue Owner β€” I want to list spaces" }, +]; + +export default function RegisterPage() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [role, setRole] = useState("user"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + useEffect(() => { + if (isAuthenticated()) router.replace("/venues"); + }, [router]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!name || !email || !password || !confirmPassword) { + setError("Please fill in all fields."); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + if (password.length < 6) { + setError("Password must be at least 6 characters."); + return; + } + + setLoading(true); + try { + await register(name, email, password, role); + // Auto-login after registration + await login(email, password); + window.dispatchEvent(new Event("storage")); + router.push("/venues"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Registration failed."); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Background glow */} +
+ +
+
+ {/* Brand */} +
+
+ πŸ“ +
+

+ Create an account +

+

+ Join the BookMyVenue community +

+
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + autoComplete="name" + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+ + {/* Password */} +
+
+ + setPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ + {/* Role */} +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Already have an account?{" "} + + Sign in β†’ + +

+
+
+
+ ); +} diff --git a/frontend/src/app/venues/[id]/page.tsx b/frontend/src/app/venues/[id]/page.tsx new file mode 100644 index 0000000000..7cd0b64c9a --- /dev/null +++ b/frontend/src/app/venues/[id]/page.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { use, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import AuthGuard from "@/components/AuthGuard"; +import BookingModal from "@/components/BookingModal"; +import { getVenueById } from "@/lib/venues"; +import { formatPrice } from "@/lib/bookings"; + +const CATEGORY_COLORS: Record = { + Conference: "#6366f1", + Wedding: "#ec4899", + Party: "#f59e0b", + Outdoor: "#10b981", + Workshop: "#3b82f6", + Exhibition: "#8b5cf6", + Sports: "#14b8a6", +}; + +interface PageProps { + params: Promise<{ id: string }>; +} + +function StarRating({ rating, count }: { rating: number; count: number }) { + return ( +
+ {[1, 2, 3, 4, 5].map((s) => ( + + β˜… + + ))} + {rating.toFixed(1)} + ({count} reviews) +
+ ); +} + +function VenueDetailContent({ id }: { id: string }) { + const venue = getVenueById(id); + const [showModal, setShowModal] = useState(false); + const [bookingSuccess, setBookingSuccess] = useState(false); + + if (!venue) notFound(); + + const accentColor = CATEGORY_COLORS[venue.category] ?? "#6366f1"; + + function handleSuccess() { + setShowModal(false); + setBookingSuccess(true); + } + + return ( +
+ {/* Breadcrumb */} + + + {/* Booking success banner */} + {bookingSuccess && ( +
+ πŸŽ‰ +
+

Booking Confirmed!

+

+ Your booking at {venue.name} has been confirmed.{" "} + View My Bookings β†’ +

+
+
+ )} + +
+ {/* Left β€” main content */} +
+ {/* Hero image */} +
+ {venue.name} + {/* Category overlay */} +
+ + {venue.category} + +
+
+ + {/* Title & rating */} +

+ {venue.name} +

+
+ + + πŸ“ {venue.location} + + + πŸ‘₯ Up to {venue.capacity} guests + +
+ + {/* Description */} +
+

+ About this venue +

+

+ {venue.description} +

+
+ + {/* Highlights */} +
+

+ Highlights +

+
+ {venue.highlights.map((h) => ( +
+ βœ“ + {h} +
+ ))} +
+
+ + {/* Amenities */} +
+

+ Amenities +

+
+ {venue.amenities.map((a) => ( + + {a} + + ))} +
+
+
+ + {/* Right β€” sticky booking sidebar */} +
+
+
+

+ Starting from +

+
+ + {formatPrice(venue.pricePerHour)} + + /hour +
+
+ + {/* Key info */} +
+ {[ + { icon: "πŸ“", label: "Location", value: venue.city }, + { icon: "πŸ‘₯", label: "Capacity", value: `${venue.capacity} guests` }, + { icon: "⭐", label: "Rating", value: `${venue.rating}/5 (${venue.reviewCount} reviews)` }, + { icon: "🏷️", label: "Category", value: venue.category }, + ].map((item) => ( +
+ + {item.icon}{item.label} + + {item.value} +
+ ))} +
+ + + +

+ Free cancellation Β· No booking fees +

+
+
+
+ + {/* Booking modal */} + {showModal && ( + setShowModal(false)} + onSuccess={handleSuccess} + /> + )} +
+ ); +} + +export default function VenueDetailPage({ params }: PageProps) { + const { id } = use(params); + return ( + + + + ); +} diff --git a/frontend/src/app/venues/page.tsx b/frontend/src/app/venues/page.tsx new file mode 100644 index 0000000000..8a1d74abdf --- /dev/null +++ b/frontend/src/app/venues/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import AuthGuard from "@/components/AuthGuard"; +import SearchBar from "@/components/SearchBar"; +import VenueCard from "@/components/VenueCard"; +import { getVenues } from "@/lib/venues"; +import type { VenueCategory } from "@/lib/venues"; + +function VenueSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +function VenuesContent() { + const [searchQuery, setSearchQuery] = useState(""); + const [category, setCategory] = useState("All"); + const [loading] = useState(false); + + const venues = getVenues({ search: searchQuery, category }); + + function handleSearch(q: string, cat: VenueCategory | "All") { + setSearchQuery(q); + setCategory(cat); + } + + return ( +
+ {/* Page header */} +
+

+ Discover Venues +

+

+ Find the perfect space for your next event +

+
+ + {/* Search */} +
+ +
+ + {/* Results info */} +
+

+ {venues.length === 0 + ? "No venues found" + : `${venues.length} venue${venues.length === 1 ? "" : "s"} found`} + {category !== "All" && ( + + in {category} + + )} +

+ {(searchQuery || category !== "All") && ( + + )} +
+ + {/* Grid */} + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : venues.length === 0 ? ( +
+
πŸ”
+

+ No venues found +

+

Try adjusting your search or category filter.

+
+ ) : ( +
+ {venues.map((venue, i) => ( +
+ +
+ ))} +
+ )} +
+ ); +} + +export default function VenuesPage() { + return ( + +
+ +
+
+ ); +} From a2b9a5762f9fa1ae965cff15a5fabd130eba4e1b Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Thu, 28 May 2026 21:45:49 +0530 Subject: [PATCH 10/13] Add some dev scripts to speedup workflow --- .gitignore | 18 ++++++++++++++++++ Makefile | 38 ++++++++++++++++++++++++++++++++++++++ go.work.sum | 17 +++++++++++++++++ scripts/dev.sh | 43 +++++++++++++++++++++++++++++++++++++++++++ scripts/migrate.sh | 22 ++++++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100755 scripts/dev.sh create mode 100755 scripts/migrate.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..d8edf0a4f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Environment secrets +.env + +# Build artefacts +/bin/ + +# Go +*.test +*.out + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..4e57bdf81c --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +GONOSUMDB := bookmyvenue.com +export GONOSUMDB + +.PHONY: dev build test lint tidy infra-up infra-down migrate clean + +dev: + @scripts/dev.sh + +build: + cd auth-service && go build -o ../bin/auth-service ./cmd/main.go + cd booking-service && go build -o ../bin/booking-service ./cmd/main.go + +test: + cd shared && go test ./... + cd auth-service && go test ./... + cd booking-service && go test ./... + +lint: + cd shared && go vet ./... + cd auth-service && go vet ./... + cd booking-service && go vet ./... + +tidy: + cd shared && go mod tidy + cd auth-service && go mod tidy + cd booking-service && go mod tidy + +infra-up: + docker compose -f booking-service/compose.yaml up -d + +infra-down: + docker compose -f booking-service/compose.yaml down + +migrate: + @scripts/migrate.sh + +clean: + rm -rf bin/ diff --git a/go.work.sum b/go.work.sum index 585b6b80c2..6cde90e7ae 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,5 +1,22 @@ +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000000..d7ec2d75b3 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +[ -f "$REPO_ROOT/.env" ] && source "$REPO_ROOT/.env" + +export GONOSUMDB="${GONOSUMDB:-bookmyvenue.com}" + +docker compose -f "$REPO_ROOT/booking-service/compose.yaml" up -d + +echo "Waiting for Postgres..." +until nc -z localhost 5433 2>/dev/null; do sleep 1; done + +echo "Waiting for Redis..." +until nc -z localhost 6379 2>/dev/null; do sleep 1; done + +cleanup() { + kill "$AUTH_PID" "$BOOKING_PID" 2>/dev/null + wait +} +trap cleanup SIGINT SIGTERM + +cd "$REPO_ROOT/auth-service" +DATABASE_URL="${DATABASE_URL_AUTH:-postgres://postgres:secret@localhost:5432/wecode_auth?sslmode=disable}" \ +JWT_SECRET="${JWT_SECRET:-secret}" \ +PORT="${PORT_AUTH:-:8080}" \ +go run ./cmd/main.go & +AUTH_PID=$! + +cd "$REPO_ROOT/booking-service" +DATABASE_URL="${DATABASE_URL:-postgres://postgres:secret@localhost:5433/bookmyvenue_bookings?sslmode=disable}" \ +REDIS_ADDR="${REDIS_ADDR:-localhost:6379}" \ +JWT_SECRET="${JWT_SECRET:-secret}" \ +PORT="${PORT_BOOKING:-:8081}" \ +go run ./cmd/main.go & +BOOKING_PID=$! + +echo "auth-service β†’ http://localhost:${PORT_AUTH:-8080}" +echo "booking-service β†’ http://localhost:${PORT_BOOKING:-8081}" +echo "Press Ctrl+C to stop." + +wait diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000000..28f637a984 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +[ -f "$REPO_ROOT/.env" ] && source "$REPO_ROOT/.env" + +AUTH_DB="${DATABASE_URL_AUTH:-postgres://postgres:secret@localhost:5432/wecode_auth?sslmode=disable}" +BOOKING_DB="${DATABASE_URL:-postgres://postgres:secret@localhost:5433/bookmyvenue_bookings?sslmode=disable}" + +run() { + local label="$1" db="$2" dir="$3" + echo "Migrating $label..." + for f in "$REPO_ROOT/$dir/migrations"/*.up.sql; do + echo " $(basename "$f")" + psql "$db" -f "$f" -q + done +} + +run "auth-service" "$AUTH_DB" "auth-service" +run "booking-service" "$BOOKING_DB" "booking-service" +echo "Done." From 024a8151235a64c1333f48b9d49df9f8a618eb47 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Tue, 9 Jun 2026 22:12:37 +0530 Subject: [PATCH 11/13] Check for already existing emails --- auth-service/internal/auth/service.go | 6 ++++++ auth-service/internal/delivery/http/handler.go | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/auth-service/internal/auth/service.go b/auth-service/internal/auth/service.go index 587739b2d4..22d07e59e8 100644 --- a/auth-service/internal/auth/service.go +++ b/auth-service/internal/auth/service.go @@ -48,6 +48,12 @@ func (s *authService) Login(email, password string) (string, error) { } func (s *authService) Register(name, email, role, password string) (*domain.User, error) { + user, err := s.repo.GetByEmail(email) + if user != nil { + log.Printf("Email already exists") + return nil, err + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { log.Printf("Error hashing") diff --git a/auth-service/internal/delivery/http/handler.go b/auth-service/internal/delivery/http/handler.go index 8b03fd48db..25c2fbeaac 100644 --- a/auth-service/internal/delivery/http/handler.go +++ b/auth-service/internal/delivery/http/handler.go @@ -70,8 +70,8 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - if req.Username == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username and password are required"}) + if req.Email == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Email and password are required"}) return } From 7332c97cd7017d8d79e029eda13b81c60fcb1eff Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Tue, 9 Jun 2026 22:12:52 +0530 Subject: [PATCH 12/13] Add RabbitMQ + update scripts --- auth-service/compose.yaml | 6 ++++++ docker-compose.rabbitmq.yml | 23 +++++++++++++++++++++++ scripts/dev.sh | 12 ++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 docker-compose.rabbitmq.yml diff --git a/auth-service/compose.yaml b/auth-service/compose.yaml index 57f7733bbb..40a2ff3501 100644 --- a/auth-service/compose.yaml +++ b/auth-service/compose.yaml @@ -11,6 +11,12 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql + networks: + - wecode_network volumes: postgres_data: + +networks: + wecode_network: + external: true diff --git a/docker-compose.rabbitmq.yml b/docker-compose.rabbitmq.yml new file mode 100644 index 0000000000..0d9e10484e --- /dev/null +++ b/docker-compose.rabbitmq.yml @@ -0,0 +1,23 @@ +services: + rabbitmq: + image: rabbitmq:3.11-management + container_name: rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + RABBITMQ_DEFAULT_VHOST: / + ports: + - "5672:5672" # AMQP + - "15672:15672" # Management UI + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - wecode_network + +volumes: + rabbitmq_data: + +networks: + wecode_network: + external: true diff --git a/scripts/dev.sh b/scripts/dev.sh index d7ec2d75b3..1dac9b7260 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -7,6 +7,15 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" export GONOSUMDB="${GONOSUMDB:-bookmyvenue.com}" +# ensure shared docker network exists +if ! docker network inspect wecode_network >/dev/null 2>&1; then + docker network create wecode_network +fi + +# start RabbitMQ (message broker) +docker compose -f "$REPO_ROOT/docker-compose.rabbitmq.yml" up -d + +# start booking service infra (postgres for booking) docker compose -f "$REPO_ROOT/booking-service/compose.yaml" up -d echo "Waiting for Postgres..." @@ -15,6 +24,9 @@ until nc -z localhost 5433 2>/dev/null; do sleep 1; done echo "Waiting for Redis..." until nc -z localhost 6379 2>/dev/null; do sleep 1; done +echo "Waiting for RabbitMQ..." +until nc -z localhost 5672 2>/dev/null; do sleep 1; done + cleanup() { kill "$AUTH_PID" "$BOOKING_PID" 2>/dev/null wait From 2cc73f6bcd72f4b4aaec9b7ba5fc36cd53230134 Mon Sep 17 00:00:00 2001 From: IamShaDoW666 Date: Wed, 22 Jul 2026 21:52:11 +0530 Subject: [PATCH 13/13] feat: connect frontend to backend API and add venue management CRUD --- PROJECT_PLAN.md | 125 +++++ auth-service/cmd/main.go | 64 ++- auth-service/go.mod | 4 +- auth-service/go.sum | 6 + auth-service/internal/auth/service.go | 26 +- .../internal/delivery/http/handler_test.go | 8 +- booking-service/cmd/main.go | 7 + booking-service/go.mod | 5 +- booking-service/go.sum | 6 + .../internal/delivery/http/venue_handler.go | 41 +- booking-service/internal/domain/venue.go | 5 + .../repository/postgres_venue_repo.go | 65 ++- booking-service/internal/venue/service.go | 9 +- .../003_add_venue_profile_fields.down.sql | 6 + .../003_add_venue_profile_fields.up.sql | 6 + frontend/src/app/my-venues/page.tsx | 158 ++++++ frontend/src/app/register/page.tsx | 462 +++++++++--------- frontend/src/app/venues/[id]/edit/page.tsx | 88 ++++ frontend/src/app/venues/[id]/page.tsx | 184 ++++--- frontend/src/app/venues/new/page.tsx | 46 ++ frontend/src/app/venues/page.tsx | 16 +- frontend/src/components/BookingModal.tsx | 8 +- frontend/src/components/DeleteVenueDialog.tsx | 71 +++ frontend/src/components/Navbar.tsx | 7 + frontend/src/components/VenueCard.tsx | 17 +- frontend/src/components/VenueForm.tsx | 183 +++++++ frontend/src/lib/api.ts | 42 ++ frontend/src/lib/venues.ts | 243 ++------- 28 files changed, 1337 insertions(+), 571 deletions(-) create mode 100644 PROJECT_PLAN.md create mode 100644 booking-service/migrations/003_add_venue_profile_fields.down.sql create mode 100644 booking-service/migrations/003_add_venue_profile_fields.up.sql create mode 100644 frontend/src/app/my-venues/page.tsx create mode 100644 frontend/src/app/venues/[id]/edit/page.tsx create mode 100644 frontend/src/app/venues/new/page.tsx create mode 100644 frontend/src/components/DeleteVenueDialog.tsx create mode 100644 frontend/src/components/VenueForm.tsx create mode 100644 frontend/src/lib/api.ts diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000000..b0b460633c --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,125 @@ +# Venues CRUD β€” Project Plan + +## Current State + +The **backend** (booking-service) already has a **complete CRUD API** for venues: + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/venues` | β€” | List all venues | +| GET | `/venues/:id` | β€” | Get venue by ID | +| POST | `/venues` | JWT (owner/admin) | Create venue | +| PUT | `/venues/:id` | JWT (owner/admin) | Update venue | +| DELETE | `/venues/:id` | JWT (owner/admin) | Delete venue | +| GET | `/venues/mine` | JWT (owner/admin) | List my venues | + +The **frontend** uses **hardcoded mock data** (`src/lib/venues.ts`) β€” no API calls. There is no UI for creating, editing, or deleting venues. + +--- + +## Phase 1 β€” Connect Frontend to Real API + +### 1.1 Environment variable +Add `NEXT_PUBLIC_BOOKING_URL=http://localhost:8081` to the frontend. + +### 1.2 API client (`src/lib/api.ts`) +Create a shared fetch wrapper that: +- Reads `NEXT_PUBLIC_BOOKING_URL` +- Injects `Authorization: Bearer ` from localStorage for protected calls +- Handles JSON serialization and error responses uniformly + +### 1.3 Real venue API functions (`src/lib/venues.ts`) +Replace mock functions with real HTTP calls: + +```ts +fetchVenues(query?) β†’ GET /venues?search=&category=&city= +fetchVenueById(id) β†’ GET /venues/:id +createVenue(data) β†’ POST /venues +updateVenue(id, data) β†’ PUT /venues/:id +deleteVenue(id) β†’ DELETE /venues/:id +fetchMyVenues() β†’ GET /venues/mine +``` + +### 1.4 Align `Venue` type +The backend model has: `id`, `owner_id`, `name`, `description`, `location`, `capacity`, `price_per_hour`, `created_at`. + +The frontend has extra fields: `city`, `category`, `rating`, `reviewCount`, `amenities`, `images`, `highlights`. + +**Option A** (recommended short-term): Add `city`, `category`, `images` as nullable columns to the venues table via a new migration. Store `amenities` / `highlights` as JSONB. The `rating` / `reviewCount` fields belong in a separate reviews system (future) β€” for now default to `0` / `0` or seed data. + +**Option B** (minimal): Keep the frontend type superset and simply don't send extra fields to the API. When reading, merge DB data with defaults. Simpler but leaves a gap. + +### 1.5 Update existing pages +- `venues/page.tsx` β€” replace `getVenues()` with `fetchVenues()` +- `venues/[id]/page.tsx` β€” replace `getVenueById()` with `fetchVenueById()` +- Keep search + category filter working (can be query params sent to the API) + +--- + +## Phase 2 β€” Owner Venue Management + +### 2.1 My Venues page (`/my-venues`) +- Protected route for `owner` / `admin` roles +- Calls `fetchMyVenues()` +- Lists the user's venues with **Edit** and **Delete** actions +- Shows a **"Add Venue"** CTA when empty + +### 2.2 Create Venue page (`/venues/new`) +- Form: name, description, location, capacity, price_per_hour, city, category, images +- Calls `createVenue()` on submit +- Redirects to `/my-venues` or the new venue's detail page on success + +### 2.3 Edit Venue page (`/venues/[id]/edit`) +- Pre-populated form from `fetchVenueById()` +- Calls `updateVenue()` on submit +- Only accessible by the venue owner or an admin + +### 2.4 Delete Venue +- Confirmation dialog (modal or inline) +- Calls `deleteVenue()` on confirm +- Removes the venue from the list without page reload (optimistic UI) + +--- + +## Phase 3 (Optional) β€” Polish + +### 3.1 Owner badge on venue detail +Show "Edit" / "Delete" buttons on the venue detail page if the logged-in user owns it. + +### 3.2 Venue images upload +Add an image upload endpoint to the backend (multipart β†’ S3/local storage) and a file picker in the create/edit forms. + +### 3.3 Pagination +Add `offset` / `limit` query params to `GET /venues` and pagination UI on the listing page. + +--- + +## File Changes Summary + +| File | Action | +|------|--------| +| `frontend/.env.local` | Add `NEXT_PUBLIC_BOOKING_URL` | +| `frontend/src/lib/api.ts` | **Create** β€” shared fetch helper | +| `frontend/src/lib/venues.ts` | **Rewrite** β€” replace mocks with API calls | +| `frontend/src/app/venues/page.tsx` | Update to use `fetchVenues()` | +| `frontend/src/app/venues/[id]/page.tsx` | Update to use `fetchVenueById()`, add owner actions | +| `frontend/src/app/my-venues/page.tsx` | **Create** β€” owner dashboard | +| `frontend/src/app/venues/new/page.tsx` | **Create** β€” add venue form | +| `frontend/src/app/venues/[id]/edit/page.tsx` | **Create** β€” edit venue form | +| `frontend/src/components/DeleteVenueDialog.tsx` | **Create** β€” delete confirmation | +| `booking-service/migrations/003_add_venue_profile_fields.up.sql` | **Create** β€” city, category, images, amenities, highlights columns | + +--- + +## Sequence for Delivery + +``` +Phase 1 ──► Phase 2 ──► Phase 3 (optional) + 1.1 2.1 3.1 + 1.2 2.2 3.2 + 1.3 2.3 3.3 + 1.4 2.4 + 1.5 +``` + +Start with Phase 1 to unblock the listing + detail pages, then Phase 2 for the owner management flows. diff --git a/auth-service/cmd/main.go b/auth-service/cmd/main.go index 32f09f876b..53b8f9e939 100644 --- a/auth-service/cmd/main.go +++ b/auth-service/cmd/main.go @@ -3,24 +3,75 @@ package main import ( "auth-service/internal/auth" delivery "auth-service/internal/delivery/http" + amqp "github.com/rabbitmq/amqp091-go" + "auth-service/internal/repository" "context" "log" "time" + "bookmyvenue.com/shared/config" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" - "bookmyvenue.com/shared/config" ) +type amqpPublisher struct { + ch *amqp.Channel + queue string +} + +func (p *amqpPublisher) PublishLogin(userID string) error { + body := []byte(`{"user_id":"` + userID + `"}`) + return p.ch.Publish( + "", // exchange + p.queue, + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "application/json", + Body: body, + Timestamp: time.Now(), + }, + ) +} + func main() { // ── Config ──────────────────────────────────────────────────────────────── connStr := config.MustGetEnv("DATABASE_URL", "postgres://postgres:secret@localhost:5432/wecode_auth?sslmode=disable") jwtSecret := config.MustGetEnv("JWT_SECRET", "secret") + broker, err := amqp.Dial("amqp://guest:guest@localhost:5672/") + if err != nil { + log.Fatal(err) + } + defer broker.Close() + + // Create a channel for publishing. We keep a single publisher instance + // and pass it to the auth service. + ch, err := broker.Channel() + if err != nil { + log.Fatalf("failed to open amqp channel: %v", err) + } + defer ch.Close() + + q, err := ch.QueueDeclare( + "logins", + true, + false, + false, + false, + nil, + ) + + if err != nil { + log.Fatalf("failed to declare exchange: %v", err) + } + + pub := &amqpPublisher{ch: ch, queue: q.Name} + port := config.MustGetEnv("PORT", ":8080") - // ── Database ────────────────────────────────────────────────────────────── ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -30,12 +81,17 @@ func main() { } defer dbPool.Close() - // ── Wire-up ─────────────────────────────────────────────────────────────── jwtManager := auth.NewJWTManager(jwtSecret) userRepo := repository.NewPostgresUserRepository(dbPool) - authSvc := auth.NewAuthService(userRepo, jwtManager) + authSvc := auth.NewAuthService(userRepo, jwtManager, pub) r := gin.Default() + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:3000"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Content-Type", "Authorization"}, + AllowCredentials: true, + })) api := r.Group("/") delivery.NewAuthHandler(api, authSvc) diff --git a/auth-service/go.mod b/auth-service/go.mod index 000ba40137..9c174f30a8 100644 --- a/auth-service/go.mod +++ b/auth-service/go.mod @@ -18,6 +18,7 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/cors v1.7.7 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -36,10 +37,11 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rabbitmq/amqp091-go v1.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - golang.org/x/arch v0.22.0 // indirect + golang.org/x/arch v0.23.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/auth-service/go.sum b/auth-service/go.sum index a7c67f14b9..2b516a7389 100644 --- a/auth-service/go.sum +++ b/auth-service/go.sum @@ -11,6 +11,8 @@ 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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= +github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= @@ -61,6 +63,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= +github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -83,6 +87,8 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= diff --git a/auth-service/internal/auth/service.go b/auth-service/internal/auth/service.go index 22d07e59e8..c79b423484 100644 --- a/auth-service/internal/auth/service.go +++ b/auth-service/internal/auth/service.go @@ -22,10 +22,15 @@ type AuthService interface { type authService struct { repo domain.UserRepository jwtManager *JWTManager + pub Publisher } -func NewAuthService(repo domain.UserRepository, jwt *JWTManager) AuthService { - return &authService{jwtManager: jwt, repo: repo} +type Publisher interface { + PublishLogin(userID string) error +} + +func NewAuthService(repo domain.UserRepository, jwt *JWTManager, pub Publisher) AuthService { + return &authService{jwtManager: jwt, repo: repo, pub: pub} } func (s *authService) Login(email, password string) (string, error) { @@ -44,7 +49,22 @@ func (s *authService) Login(email, password string) (string, error) { return "", errors.New("invalid credentials") } - return s.jwtManager.GenerateToken(user.ID, user.Role, time.Hour) + token, err := s.jwtManager.GenerateToken(user.ID, user.Role, time.Hour) + if err != nil { + return "", err + } + + // Publish login event asynchronously; failures should not block the + // authentication flow. + if s.pub != nil { + go func(id string) { + if err := s.pub.PublishLogin(id); err != nil { + log.Printf("failed to publish login event: %v", err) + } + }(user.ID) + } + + return token, nil } func (s *authService) Register(name, email, role, password string) (*domain.User, error) { diff --git a/auth-service/internal/delivery/http/handler_test.go b/auth-service/internal/delivery/http/handler_test.go index ee26621aa2..7d9a842c5f 100644 --- a/auth-service/internal/delivery/http/handler_test.go +++ b/auth-service/internal/delivery/http/handler_test.go @@ -105,14 +105,14 @@ func TestRegister(t *testing.T) { wantUser: true, }, { - name: "missing username", + name: "missing email", body: map[string]string{ - "email": "alice@example.com", + "username": "alice", "password": "s3cr3t", }, mockReturn: nil, // service should not be called wantStatus: http.StatusBadRequest, - wantError: "Username and password are required", + wantError: "Email and password are required", }, { name: "missing password", @@ -122,7 +122,7 @@ func TestRegister(t *testing.T) { }, mockReturn: nil, wantStatus: http.StatusBadRequest, - wantError: "Username and password are required", + wantError: "Email and password are required", }, { name: "malformed json", diff --git a/booking-service/cmd/main.go b/booking-service/cmd/main.go index bae3030fa0..ce8a6e0876 100644 --- a/booking-service/cmd/main.go +++ b/booking-service/cmd/main.go @@ -9,6 +9,7 @@ import ( "log" "time" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" @@ -50,6 +51,12 @@ func main() { // ── Router ──────────────────────────────────────────────────────────────── r := gin.Default() + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:3000"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Content-Type", "Authorization"}, + AllowCredentials: true, + })) api := r.Group("/") delivery.RegisterVenueRoutes(api, venueSvc, jwtSecret) delivery.RegisterBookingRoutes(api, bookingSvc, jwtSecret) diff --git a/booking-service/go.mod b/booking-service/go.mod index 84265e4699..fdf4242889 100644 --- a/booking-service/go.mod +++ b/booking-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/cors v1.7.7 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-gonic/gin v1.12.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -36,11 +37,11 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/arch v0.22.0 // indirect + golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/booking-service/go.sum b/booking-service/go.sum index afbf777e08..345c2e91be 100644 --- a/booking-service/go.sum +++ b/booking-service/go.sum @@ -12,6 +12,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= +github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= @@ -77,6 +79,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= @@ -86,6 +90,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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= diff --git a/booking-service/internal/delivery/http/venue_handler.go b/booking-service/internal/delivery/http/venue_handler.go index 6e7d1980ff..98db0ab382 100644 --- a/booking-service/internal/delivery/http/venue_handler.go +++ b/booking-service/internal/delivery/http/venue_handler.go @@ -4,10 +4,11 @@ import ( "booking-service/internal/domain" "booking-service/internal/venue" "errors" + "log" "net/http" - "github.com/gin-gonic/gin" "bookmyvenue.com/shared/middleware" + "github.com/gin-gonic/gin" ) type VenueHandler struct { @@ -15,19 +16,29 @@ type VenueHandler struct { } type createVenueRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Location string `json:"location" binding:"required"` - Capacity int `json:"capacity" binding:"required,min=1"` - PricePerHour float64 `json:"price_per_hour" binding:"required,min=0"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Location string `json:"location" binding:"required"` + City string `json:"city"` + Category string `json:"category"` + Capacity int `json:"capacity" binding:"required,min=1"` + PricePerHour float64 `json:"price_per_hour" binding:"required,min=0"` + Images []string `json:"images"` + Amenities []string `json:"amenities"` + Highlights []string `json:"highlights"` } type updateVenueRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Location string `json:"location" binding:"required"` - Capacity int `json:"capacity" binding:"required,min=1"` - PricePerHour float64 `json:"price_per_hour" binding:"required,min=0"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Location string `json:"location" binding:"required"` + City string `json:"city"` + Category string `json:"category"` + Capacity int `json:"capacity" binding:"required,min=1"` + PricePerHour float64 `json:"price_per_hour" binding:"required,min=0"` + Images []string `json:"images"` + Amenities []string `json:"amenities"` + Highlights []string `json:"highlights"` } // RegisterVenueRoutes mounts venue endpoints on the router group. @@ -80,7 +91,7 @@ func (h *VenueHandler) CreateVenue(c *gin.Context) { return } ownerID := c.GetString(middleware.CtxUserID) - v, err := h.svc.CreateVenue(ownerID, req.Name, req.Description, req.Location, req.Capacity, req.PricePerHour) + v, err := h.svc.CreateVenue(ownerID, req.Name, req.Description, req.Location, req.City, req.Category, req.Capacity, req.PricePerHour, req.Images, req.Amenities, req.Highlights) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return @@ -101,8 +112,13 @@ func (h *VenueHandler) UpdateVenue(c *gin.Context) { Name: req.Name, Description: req.Description, Location: req.Location, + City: req.City, + Category: req.Category, Capacity: req.Capacity, PricePerHour: req.PricePerHour, + Images: req.Images, + Amenities: req.Amenities, + Highlights: req.Highlights, } if err := h.svc.UpdateVenue(callerID, callerRole, v); err != nil { switch { @@ -139,6 +155,7 @@ func (h *VenueHandler) ListMyVenues(c *gin.Context) { ownerID := c.GetString(middleware.CtxUserID) venues, err := h.svc.ListMyVenues(ownerID) if err != nil { + log.Printf("ERROR: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return } diff --git a/booking-service/internal/domain/venue.go b/booking-service/internal/domain/venue.go index 76db3f4f24..a94fe4e8b0 100644 --- a/booking-service/internal/domain/venue.go +++ b/booking-service/internal/domain/venue.go @@ -24,8 +24,13 @@ type Venue struct { Name string `json:"name"` Description string `json:"description"` Location string `json:"location"` + City string `json:"city"` + Category string `json:"category"` Capacity int `json:"capacity"` PricePerHour float64 `json:"price_per_hour"` + Images []string `json:"images"` + Amenities []string `json:"amenities"` + Highlights []string `json:"highlights"` CreatedAt time.Time `json:"created_at"` } diff --git a/booking-service/internal/repository/postgres_venue_repo.go b/booking-service/internal/repository/postgres_venue_repo.go index 069af206d5..dac340dda0 100644 --- a/booking-service/internal/repository/postgres_venue_repo.go +++ b/booking-service/internal/repository/postgres_venue_repo.go @@ -3,6 +3,7 @@ package repository import ( "booking-service/internal/domain" "context" + "encoding/json" "errors" "github.com/jackc/pgx/v5" @@ -19,21 +20,25 @@ func NewPostgresVenueRepository(db *pgxpool.Pool) domain.VenueRepository { } func (r *postgresVenueRepo) Create(v *domain.Venue) error { - q := `INSERT INTO venues (owner_id, name, description, location, capacity, price_per_hour) - VALUES ($1,$2,$3,$4,$5,$6) + images, _ := json.Marshal(v.Images) + amenities, _ := json.Marshal(v.Amenities) + highlights, _ := json.Marshal(v.Highlights) + q := `INSERT INTO venues (owner_id, name, description, location, city, category, capacity, price_per_hour, images, amenities, highlights) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id, created_at` return r.db.QueryRow(context.Background(), q, - v.OwnerID, v.Name, v.Description, v.Location, v.Capacity, v.PricePerHour, + v.OwnerID, v.Name, v.Description, v.Location, v.City, v.Category, + v.Capacity, v.PricePerHour, images, amenities, highlights, ).Scan(&v.ID, &v.CreatedAt) } -func (r *postgresVenueRepo) GetByID(id string) (*domain.Venue, error) { - q := `SELECT id, owner_id, name, description, location, capacity, price_per_hour, created_at - FROM venues WHERE id = $1` +func scanVenue(row pgx.Row) (*domain.Venue, error) { var v domain.Venue - err := r.db.QueryRow(context.Background(), q, id).Scan( + var imagesRaw, amenitiesRaw, highlightsRaw []byte + err := row.Scan( &v.ID, &v.OwnerID, &v.Name, &v.Description, &v.Location, - &v.Capacity, &v.PricePerHour, &v.CreatedAt, + &v.City, &v.Category, &v.Capacity, &v.PricePerHour, + &imagesRaw, &amenitiesRaw, &highlightsRaw, &v.CreatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -41,26 +46,45 @@ func (r *postgresVenueRepo) GetByID(id string) (*domain.Venue, error) { } return nil, err } + if len(imagesRaw) > 0 { + json.Unmarshal(imagesRaw, &v.Images) + } + if len(amenitiesRaw) > 0 { + json.Unmarshal(amenitiesRaw, &v.Amenities) + } + if len(highlightsRaw) > 0 { + json.Unmarshal(highlightsRaw, &v.Highlights) + } return &v, nil } +func (r *postgresVenueRepo) GetByID(id string) (*domain.Venue, error) { + q := `SELECT id, owner_id, name, description, location, city, category, capacity, price_per_hour, images, amenities, highlights, created_at + FROM venues WHERE id = $1` + return scanVenue(r.db.QueryRow(context.Background(), q, id)) +} + func (r *postgresVenueRepo) ListAll() ([]domain.Venue, error) { - q := `SELECT id, owner_id, name, description, location, capacity, price_per_hour, created_at + q := `SELECT id, owner_id, name, description, location, city, category, capacity, price_per_hour, images, amenities, highlights, created_at FROM venues ORDER BY created_at DESC` - return r.scanVenues(q) + return r.scanVenuesMulti(q) } func (r *postgresVenueRepo) ListByOwner(ownerID string) ([]domain.Venue, error) { - q := `SELECT id, owner_id, name, description, location, capacity, price_per_hour, created_at + q := `SELECT id, owner_id, name, description, location, city, category, capacity, price_per_hour, images, amenities, highlights, created_at FROM venues WHERE owner_id = $1 ORDER BY created_at DESC` - return r.scanVenues(q, ownerID) + return r.scanVenuesMulti(q, ownerID) } func (r *postgresVenueRepo) Update(v *domain.Venue) error { - q := `UPDATE venues SET name=$1, description=$2, location=$3, capacity=$4, price_per_hour=$5 - WHERE id=$6` + images, _ := json.Marshal(v.Images) + amenities, _ := json.Marshal(v.Amenities) + highlights, _ := json.Marshal(v.Highlights) + q := `UPDATE venues SET name=$1, description=$2, location=$3, city=$4, category=$5, capacity=$6, price_per_hour=$7, images=$8, amenities=$9, highlights=$10 + WHERE id=$11` tag, err := r.db.Exec(context.Background(), q, - v.Name, v.Description, v.Location, v.Capacity, v.PricePerHour, v.ID, + v.Name, v.Description, v.Location, v.City, v.Category, + v.Capacity, v.PricePerHour, images, amenities, highlights, v.ID, ) if err != nil { return err @@ -82,8 +106,8 @@ func (r *postgresVenueRepo) Delete(id string) error { return nil } -// scanVenues is a helper that runs a SELECT query and collects rows into a slice. -func (r *postgresVenueRepo) scanVenues(q string, args ...any) ([]domain.Venue, error) { +// scanVenuesMulti runs a SELECT query and collects rows into a slice. +func (r *postgresVenueRepo) scanVenuesMulti(q string, args ...any) ([]domain.Venue, error) { rows, err := r.db.Query(context.Background(), q, args...) if err != nil { return nil, err @@ -92,12 +116,11 @@ func (r *postgresVenueRepo) scanVenues(q string, args ...any) ([]domain.Venue, e var venues []domain.Venue for rows.Next() { - var v domain.Venue - if err := rows.Scan(&v.ID, &v.OwnerID, &v.Name, &v.Description, &v.Location, - &v.Capacity, &v.PricePerHour, &v.CreatedAt); err != nil { + v, err := scanVenue(rows) + if err != nil { return nil, err } - venues = append(venues, v) + venues = append(venues, *v) } return venues, rows.Err() } diff --git a/booking-service/internal/venue/service.go b/booking-service/internal/venue/service.go index 78f76cf859..8d23b7cf93 100644 --- a/booking-service/internal/venue/service.go +++ b/booking-service/internal/venue/service.go @@ -8,7 +8,7 @@ import ( // Service is the venue business-logic contract. type Service interface { - CreateVenue(ownerID, name, description, location string, capacity int, pricePerHour float64) (*domain.Venue, error) + CreateVenue(ownerID, name, description, location, city, category string, capacity int, pricePerHour float64, images, amenities, highlights []string) (*domain.Venue, error) GetVenue(id string) (*domain.Venue, error) ListVenues() ([]domain.Venue, error) ListMyVenues(ownerID string) ([]domain.Venue, error) @@ -24,14 +24,19 @@ func NewVenueService(repo domain.VenueRepository) Service { return &venueService{repo: repo} } -func (s *venueService) CreateVenue(ownerID, name, description, location string, capacity int, price float64) (*domain.Venue, error) { +func (s *venueService) CreateVenue(ownerID, name, description, location, city, category string, capacity int, price float64, images, amenities, highlights []string) (*domain.Venue, error) { v := &domain.Venue{ OwnerID: ownerID, Name: name, Description: description, Location: location, + City: city, + Category: category, Capacity: capacity, PricePerHour: price, + Images: images, + Amenities: amenities, + Highlights: highlights, } if err := s.repo.Create(v); err != nil { return nil, err diff --git a/booking-service/migrations/003_add_venue_profile_fields.down.sql b/booking-service/migrations/003_add_venue_profile_fields.down.sql new file mode 100644 index 0000000000..06a1e4de39 --- /dev/null +++ b/booking-service/migrations/003_add_venue_profile_fields.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE venues + DROP COLUMN city, + DROP COLUMN category, + DROP COLUMN images, + DROP COLUMN amenities, + DROP COLUMN highlights; diff --git a/booking-service/migrations/003_add_venue_profile_fields.up.sql b/booking-service/migrations/003_add_venue_profile_fields.up.sql new file mode 100644 index 0000000000..fe2c341d08 --- /dev/null +++ b/booking-service/migrations/003_add_venue_profile_fields.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE venues + ADD COLUMN city TEXT, + ADD COLUMN category TEXT, + ADD COLUMN images JSONB DEFAULT '[]'::jsonb, + ADD COLUMN amenities JSONB DEFAULT '[]'::jsonb, + ADD COLUMN highlights JSONB DEFAULT '[]'::jsonb; diff --git a/frontend/src/app/my-venues/page.tsx b/frontend/src/app/my-venues/page.tsx new file mode 100644 index 0000000000..d219269c5d --- /dev/null +++ b/frontend/src/app/my-venues/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import AuthGuard from "@/components/AuthGuard"; +import DeleteVenueDialog from "@/components/DeleteVenueDialog"; +import { fetchMyVenues, deleteVenue } from "@/lib/venues"; +import { formatPrice } from "@/lib/bookings"; +import type { Venue } from "@/lib/venues"; + +function MyVenuesContent() { + const [venues, setVenues] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteId, setDeleteId] = useState(null); + const [error, setError] = useState(""); + + function load() { + setLoading(true); + fetchMyVenues() + .then((res) => { + if (res) { + setVenues(res) + } else { + setVenues([]) + } + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + } + + useEffect(load, []); + + async function handleDelete(id: string) { + try { + await deleteVenue(id); + setVenues((prev) => prev.filter((v) => v.id !== id)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to delete venue"); + } + setDeleteId(null); + } + + if (loading) { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + return ( +
+
+
+

+ My Venues +

+

+ Manage your venue listings +

+
+ + + Add Venue + +
+ + {error && ( +
+ {error} +
+ )} + + {!loading && venues?.length === 0 ? ( +
+
🏟️
+

+ No venues yet +

+

Create your first venue listing to start accepting bookings.

+ + + Add Your First Venue + +
+ ) : ( +
+ {venues.map((venue) => ( +
+
+ {venue.name} +
+
+

{venue.name}

+

+ πŸ“ {venue.location}{venue.city ? `, ${venue.city}` : ""} +

+

+ πŸ‘₯ Up to {venue.capacity} guests Β· {formatPrice(venue.price_per_hour)}/hr +

+
+ + Edit + + + + View β†’ + +
+
+
+ ))} +
+ )} + + {deleteId && ( + handleDelete(deleteId)} + onCancel={() => setDeleteId(null)} + /> + )} +
+ ); +} + +export default function MyVenuesPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index 6b37edb9fc..632ae0ca62 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -6,249 +6,249 @@ import { useRouter } from "next/navigation"; import { register, login, isAuthenticated } from "@/lib/auth"; const ROLES = [ - { value: "user", label: "πŸ‘€ User β€” I want to book venues" }, - { value: "venue_owner", label: "🏒 Venue Owner β€” I want to list spaces" }, + { value: "user", label: "πŸ‘€ User β€” I want to book venues" }, + { value: "owner", label: "🏒 Venue Owner β€” I want to list spaces" }, ]; export default function RegisterPage() { - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [role, setRole] = useState("user"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const router = useRouter(); - - useEffect(() => { - if (isAuthenticated()) router.replace("/venues"); - }, [router]); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - - if (!name || !email || !password || !confirmPassword) { - setError("Please fill in all fields."); - return; - } - if (password !== confirmPassword) { - setError("Passwords do not match."); - return; - } - if (password.length < 6) { - setError("Password must be at least 6 characters."); - return; + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [role, setRole] = useState("user"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + useEffect(() => { + if (isAuthenticated()) router.replace("/venues"); + }, [router]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!name || !email || !password || !confirmPassword) { + setError("Please fill in all fields."); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + if (password.length < 6) { + setError("Password must be at least 6 characters."); + return; + } + + setLoading(true); + try { + await register(name, email, password, role); + // Auto-login after registration + await login(email, password); + window.dispatchEvent(new Event("storage")); + router.push("/venues"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Registration failed."); + } finally { + setLoading(false); + } } - setLoading(true); - try { - await register(name, email, password, role); - // Auto-login after registration - await login(email, password); - window.dispatchEvent(new Event("storage")); - router.push("/venues"); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Registration failed."); - } finally { - setLoading(false); - } - } - - return ( -
- {/* Background glow */} -
- -
+ return (
- {/* Brand */} -
-
- πŸ“ -
-

- Create an account -

-

- Join the BookMyVenue community -

-
- -
- {/* Name */} -
- - setName(e.target.value)} - autoComplete="name" - /> -
- - {/* Email */} -
- - setEmail(e.target.value)} - autoComplete="email" - /> -
- - {/* Password */} -
-
- - setPassword(e.target.value)} - autoComplete="new-password" - /> -
-
- - setConfirmPassword(e.target.value)} - autoComplete="new-password" - /> -
-
- - {/* Role */} -
- - -
+ padding: "2rem 1rem", + position: "relative", + overflow: "hidden", + minHeight: "calc(100vh - 64px)", + }} + > + {/* Background glow */} +
- {error && ( -
- {error} -
- )} - - - - -

- Already have an account?{" "} - - Sign in β†’ - -

+
+ {/* Brand */} +
+
+ πŸ“ +
+

+ Create an account +

+

+ Join the BookMyVenue community +

+
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + autoComplete="name" + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+ + {/* Password */} +
+
+ + setPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ + {/* Role */} +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Already have an account?{" "} + + Sign in β†’ + +

+
+
-
-
- ); + ); } diff --git a/frontend/src/app/venues/[id]/edit/page.tsx b/frontend/src/app/venues/[id]/edit/page.tsx new file mode 100644 index 0000000000..f619c3de9e --- /dev/null +++ b/frontend/src/app/venues/[id]/edit/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { use, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import AuthGuard from "@/components/AuthGuard"; +import VenueForm from "@/components/VenueForm"; +import { fetchVenueById, updateVenue } from "@/lib/venues"; +import type { CreateVenueInput } from "@/lib/venues"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +function EditVenueContent({ id }: { id: string }) { + const router = useRouter(); + const [initial, setInitial] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchVenueById(id) + .then((v) => + setInitial({ + name: v.name, + description: v.description, + location: v.location, + city: v.city, + category: v.category, + capacity: v.capacity, + price_per_hour: v.price_per_hour, + images: v.images, + amenities: v.amenities, + highlights: v.highlights, + }) + ) + .catch(() => notFound()) + .finally(() => setLoading(false)); + }, [id]); + + async function handleSubmit(data: CreateVenueInput) { + await updateVenue(id, data); + router.push(`/venues/${id}`); + } + + if (loading) { + return ( +
+
+
+
+ ); + } + + if (!initial) return null; + + return ( +
+ + +

+ Edit Venue +

+

+ Update your venue details +

+ +
+ +
+
+ ); +} + +export default function EditVenuePage({ params }: PageProps) { + const { id } = use(params); + return ( + + + + ); +} diff --git a/frontend/src/app/venues/[id]/page.tsx b/frontend/src/app/venues/[id]/page.tsx index 7cd0b64c9a..847303fb16 100644 --- a/frontend/src/app/venues/[id]/page.tsx +++ b/frontend/src/app/venues/[id]/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { use, useState } from "react"; +import { use, useState, useEffect } from "react"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; import AuthGuard from "@/components/AuthGuard"; import BookingModal from "@/components/BookingModal"; -import { getVenueById } from "@/lib/venues"; +import { fetchVenueById } from "@/lib/venues"; +import type { Venue } from "@/lib/venues"; import { formatPrice } from "@/lib/bookings"; const CATEGORY_COLORS: Record = { @@ -23,28 +24,47 @@ interface PageProps { params: Promise<{ id: string }>; } -function StarRating({ rating, count }: { rating: number; count: number }) { +function StarRating({ rating }: { rating?: number }) { + const r = rating ?? 0; return (
{[1, 2, 3, 4, 5].map((s) => ( - + β˜… ))} - {rating.toFixed(1)} - ({count} reviews) + {r.toFixed(1)}
); } function VenueDetailContent({ id }: { id: string }) { - const venue = getVenueById(id); + const [venue, setVenue] = useState(null); + const [loading, setLoading] = useState(true); const [showModal, setShowModal] = useState(false); const [bookingSuccess, setBookingSuccess] = useState(false); + useEffect(() => { + fetchVenueById(id) + .then(setVenue) + .catch(() => notFound()) + .finally(() => setLoading(false)); + }, [id]); + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + if (!venue) notFound(); - const accentColor = CATEGORY_COLORS[venue.category] ?? "#6366f1"; + const accentColor = venue.category ? (CATEGORY_COLORS[venue.category] ?? "#6366f1") : "#6366f1"; function handleSuccess() { setShowModal(false); @@ -100,29 +120,30 @@ function VenueDetailContent({ id }: { id: string }) { }} > {venue.name} - {/* Category overlay */} -
- - {venue.category} - -
+ {venue.category && ( +
+ + {venue.category} + +
+ )}
{/* Title & rating */} @@ -138,7 +159,7 @@ function VenueDetailContent({ id }: { id: string }) { {venue.name}
- + πŸ“ {venue.location} @@ -148,61 +169,67 @@ function VenueDetailContent({ id }: { id: string }) {
{/* Description */} -
-

- About this venue -

-

- {venue.description} -

-
+ {venue.description && ( +
+

+ About this venue +

+

+ {venue.description} +

+
+ )} {/* Highlights */} -
-

- Highlights -

-
- {venue.highlights.map((h) => ( -
- βœ“ - {h} -
- ))} + {venue.highlights && venue.highlights.length > 0 && ( +
+

+ Highlights +

+
+ {venue.highlights.map((h) => ( +
+ βœ“ + {h} +
+ ))} +
-
+ )} {/* Amenities */} -
-

- Amenities -

-
- {venue.amenities.map((a) => ( - - {a} - - ))} + {venue.amenities && venue.amenities.length > 0 && ( +
+

+ Amenities +

+
+ {venue.amenities.map((a) => ( + + {a} + + ))} +
-
+ )}
{/* Right β€” sticky booking sidebar */} @@ -231,7 +258,7 @@ function VenueDetailContent({ id }: { id: string }) { letterSpacing: "-0.04em", }} > - {formatPrice(venue.pricePerHour)} + {formatPrice(venue.price_per_hour)} /hour
@@ -250,10 +277,9 @@ function VenueDetailContent({ id }: { id: string }) { }} > {[ - { icon: "πŸ“", label: "Location", value: venue.city }, + { icon: "πŸ“", label: "Location", value: venue.city || venue.location }, { icon: "πŸ‘₯", label: "Capacity", value: `${venue.capacity} guests` }, - { icon: "⭐", label: "Rating", value: `${venue.rating}/5 (${venue.reviewCount} reviews)` }, - { icon: "🏷️", label: "Category", value: venue.category }, + { icon: "🏷️", label: "Category", value: venue.category || "β€”" }, ].map((item) => (
diff --git a/frontend/src/app/venues/new/page.tsx b/frontend/src/app/venues/new/page.tsx new file mode 100644 index 0000000000..eff1f70cf4 --- /dev/null +++ b/frontend/src/app/venues/new/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import AuthGuard from "@/components/AuthGuard"; +import VenueForm from "@/components/VenueForm"; +import { createVenue } from "@/lib/venues"; +import type { CreateVenueInput } from "@/lib/venues"; + +function NewVenueContent() { + const router = useRouter(); + + async function handleSubmit(data: CreateVenueInput) { + const venue = await createVenue(data); + router.push(`/venues/${venue.id}`); + } + + return ( +
+ + +

+ Add Venue +

+

+ List a new space for events and bookings +

+ +
+ +
+
+ ); +} + +export default function NewVenuePage() { + return ( + + + + ); +} diff --git a/frontend/src/app/venues/page.tsx b/frontend/src/app/venues/page.tsx index 8a1d74abdf..fb20f354eb 100644 --- a/frontend/src/app/venues/page.tsx +++ b/frontend/src/app/venues/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import AuthGuard from "@/components/AuthGuard"; import SearchBar from "@/components/SearchBar"; import VenueCard from "@/components/VenueCard"; -import { getVenues } from "@/lib/venues"; -import type { VenueCategory } from "@/lib/venues"; +import { fetchVenues } from "@/lib/venues"; +import type { VenueCategory, Venue } from "@/lib/venues"; function VenueSkeleton() { return ( @@ -31,9 +31,15 @@ function VenueSkeleton() { function VenuesContent() { const [searchQuery, setSearchQuery] = useState(""); const [category, setCategory] = useState("All"); - const [loading] = useState(false); + const [venues, setVenues] = useState([]); + const [loading, setLoading] = useState(true); - const venues = getVenues({ search: searchQuery, category }); + useEffect(() => { + setLoading(true); + fetchVenues({ search: searchQuery, category }) + .then(setVenues) + .finally(() => setLoading(false)); + }, [searchQuery, category]); function handleSearch(q: string, cat: VenueCategory | "All") { setSearchQuery(q); diff --git a/frontend/src/components/BookingModal.tsx b/frontend/src/components/BookingModal.tsx index e78406a5d3..31a4e97c62 100644 --- a/frontend/src/components/BookingModal.tsx +++ b/frontend/src/components/BookingModal.tsx @@ -33,7 +33,7 @@ export default function BookingModal({ venue, onClose, onSuccess }: BookingModal const [error, setError] = useState(""); const hours = calcHours(startTime, endTime); - const totalPrice = Math.round(hours * venue.pricePerHour); + const totalPrice = Math.round(hours * venue.price_per_hour); const isValid = hours > 0 && guestCount > 0 && guestCount <= venue.capacity; async function handleBook() { @@ -46,12 +46,12 @@ export default function BookingModal({ venue, onClose, onSuccess }: BookingModal venueId: venue.id, venueName: venue.name, venueLocation: venue.location, - venueImage: venue.images[0], + venueImage: venue.images?.[0] ?? "", date, startTime, endTime, guestCount, - pricePerHour: venue.pricePerHour, + pricePerHour: venue.price_per_hour, }); onSuccess(); } catch (e: unknown) { @@ -222,7 +222,7 @@ export default function BookingModal({ venue, onClose, onSuccess }: BookingModal }} >
- {formatPrice(venue.pricePerHour)} Γ— {hours > 0 ? `${hours}h` : "β€”"} + {formatPrice(venue.price_per_hour)} Γ— {hours > 0 ? `${hours}h` : "β€”"} {hours > 0 ? formatPrice(totalPrice) : "β€”"}
void; + onCancel: () => void; +} + +export default function DeleteVenueDialog({ onConfirm, onCancel }: DeleteVenueDialogProps) { + return ( +
+
e.stopPropagation()} + style={{ + background: "var(--bg-card)", + border: "1px solid var(--border-card)", + borderRadius: "var(--radius-xl)", + width: "100%", + maxWidth: "400px", + boxShadow: "var(--shadow-card), var(--shadow-glow)", + padding: "1.75rem", + animation: "fadeInUp 0.3s ease", + textAlign: "center", + }} + > +
πŸ—‘οΈ
+

+ Delete Venue +

+

+ Are you sure you want to delete this venue? This action cannot be undone. All associated bookings will remain. +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 34c62417ba..723182e39d 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -8,6 +8,7 @@ import { isAuthenticated, getUser, logout } from "@/lib/auth"; export default function Navbar() { const [authed, setAuthed] = useState(false); const [userName, setUserName] = useState(""); + const [userRole, setUserRole] = useState(""); const [menuOpen, setMenuOpen] = useState(false); const pathname = usePathname(); const router = useRouter(); @@ -19,6 +20,9 @@ export default function Navbar() { if (ok) { const u = getUser(); setUserName(u?.name ?? u?.email ?? "User"); + setUserRole(u?.role ?? ""); + } else { + setUserRole(""); } }; check(); @@ -26,6 +30,8 @@ export default function Navbar() { return () => window.removeEventListener("storage", check); }, [pathname]); + const isOwnerOrAdmin = userRole === "owner" || userRole === "admin"; + function handleLogout() { logout(); setAuthed(false); @@ -39,6 +45,7 @@ export default function Navbar() { { href: "/", label: "Home" }, { href: "/venues", label: "Venues" }, ...(authed ? [{ href: "/my-bookings", label: "My Bookings" }] : []), + ...(isOwnerOrAdmin ? [{ href: "/my-venues", label: "My Venues" }] : []), ]; return ( diff --git a/frontend/src/components/VenueCard.tsx b/frontend/src/components/VenueCard.tsx index 428feb5cb3..fcb9e08638 100644 --- a/frontend/src/components/VenueCard.tsx +++ b/frontend/src/components/VenueCard.tsx @@ -7,7 +7,8 @@ interface VenueCardProps { venue: Venue; } -function StarRating({ rating }: { rating: number }) { +function StarRating({ rating }: { rating?: number }) { + const r = rating ?? 0; return (
{[1, 2, 3, 4, 5].map((star) => ( @@ -15,7 +16,7 @@ function StarRating({ rating }: { rating: number }) { key={star} style={{ fontSize: "0.75rem", - color: star <= Math.round(rating) ? "#fbbf24" : "var(--text-muted)", + color: star <= Math.round(r) ? "#fbbf24" : "var(--text-muted)", }} > β˜… @@ -28,7 +29,7 @@ function StarRating({ rating }: { rating: number }) { marginLeft: "0.15rem", }} > - {rating.toFixed(1)} + {r.toFixed(1)}
); @@ -56,7 +57,7 @@ export default function VenueCard({ venue }: VenueCardProps) { {/* Hero Image */}
{venue.name} - {formatPrice(venue.pricePerHour)}/hr + {formatPrice(venue.price_per_hour)}/hr
@@ -123,7 +124,7 @@ export default function VenueCard({ venue }: VenueCardProps) { > {venue.name} - +
{/* Location */} @@ -140,7 +141,7 @@ export default function VenueCard({ venue }: VenueCardProps) { {/* Amenities preview */}
- {venue.amenities.slice(0, 3).map((a) => ( + {(venue.amenities ?? []).slice(0, 3).map((a) => ( ))} - {venue.amenities.length > 3 && ( + {(venue.amenities ?? []).length > 3 && ( Promise; + submitLabel: string; +} + +export default function VenueForm({ initial, onSubmit, submitLabel }: VenueFormProps) { + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [location, setLocation] = useState(initial?.location ?? ""); + const [city, setCity] = useState(initial?.city ?? ""); + const [category, setCategory] = useState(initial?.category ?? ""); + const [capacity, setCapacity] = useState(initial?.capacity ?? 1); + const [pricePerHour, setPricePerHour] = useState(initial?.price_per_hour ?? 0); + const [images, setImages] = useState(initial?.images?.join("\n") ?? ""); + const [amenities, setAmenities] = useState(initial?.amenities?.join("\n") ?? ""); + const [highlights, setHighlights] = useState(initial?.highlights?.join("\n") ?? ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!name.trim()) { setError("Name is required"); return; } + if (!location.trim()) { setError("Location is required"); return; } + if (capacity < 1) { setError("Capacity must be at least 1"); return; } + if (pricePerHour < 0) { setError("Price must be 0 or greater"); return; } + + setLoading(true); + try { + await onSubmit({ + name: name.trim(), + description: description.trim(), + location: location.trim(), + city: city.trim(), + category, + capacity, + price_per_hour: pricePerHour, + images: images.split("\n").map((s) => s.trim()).filter(Boolean), + amenities: amenities.split("\n").map((s) => s.trim()).filter(Boolean), + highlights: highlights.split("\n").map((s) => s.trim()).filter(Boolean), + }); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to save venue"); + } finally { + setLoading(false); + } + } + + const inputStyle: React.CSSProperties = { + width: "100%", + padding: "0.625rem 0.875rem", + background: "var(--bg-elevated)", + border: "1px solid var(--border-card)", + borderRadius: "var(--radius-md)", + color: "var(--text-primary)", + fontSize: "0.9rem", + outline: "none", + boxSizing: "border-box", + }; + + const labelStyle: React.CSSProperties = { + display: "block", + fontSize: "0.8rem", + fontWeight: 600, + color: "var(--text-secondary)", + marginBottom: "0.375rem", + textTransform: "uppercase", + letterSpacing: "0.05em", + }; + + const fieldGroup: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "0.375rem", + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} placeholder="The Grand Hall" /> +
+
+ + +
+
+ +
+ +