Files
Bookra/apps/backend/internal/api/server.go
T
Tomas DvorakandDevin 09f780e7a6 feat(catalog): add service and staff management end-to-end
Services and staff existed as schema + booking FK targets but had no
management surface. Adds full CRUD: repository (pgx + in-memory with
matching FK semantics), catalog service validation with tenant isolation,
MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas,
and dashboard screens for both entities.

Dashboard summary now resolves staff names on bookings and computes
setup completion from real configured state. Booking modal selects from
the tenant's actual services; blocked days can be scoped per staff member.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 08:52:25 +02:00

1413 lines
51 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
"bookra/apps/backend/internal/admin"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/catalog"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/httpx"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/sms"
"bookra/apps/backend/internal/tenancy"
"github.com/getsentry/sentry-go"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
type Server struct {
router *gin.Engine
cfg config.Config
pools *db.Pools
verifier *auth.Verifier
authService *auth.Service
adminService *admin.Service
billingService *billing.Service
notificationService *notifications.Service
smsService *sms.Service
}
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
verifier, err := auth.NewVerifier(cfg.NeonAuthURL, cfg.AuthJWTSecret)
if err != nil {
return nil, err
}
repository := db.NewRepository(pools, cfg.DemoMode)
notificationService := notifications.NewService(cfg, repository)
bookingService := bookings.NewService(repository, notificationService, cfg.FrontendURL)
customerBookingService := bookings.NewCustomerService(repository, notificationService, cfg.FrontendURL)
tenantService := tenancy.NewService(repository)
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
authService: authService,
adminService: adminService,
billingService: billingService,
notificationService: notificationService,
smsService: smsService,
}
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
AllowOrigins: allowedOrigins(cfg),
AllowHeaders: []string{"Authorization", "Content-Type", "X-Admin-Email", "X-Admin-Key"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}), httpx.SecurityHeaders())
server.router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"environment": cfg.Environment,
"databaseConfigured": pools.DatabaseConfigured(),
})
})
if cfg.Environment == "development" || cfg.Environment == "test" {
// Test endpoints, only exposed outside production
server.router.GET("/debug/sentry", func(c *gin.Context) {
sentry.CaptureMessage("Test message from Bookra API")
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"})
})
server.router.GET("/debug/billing-test", func(c *gin.Context) {
if billingService != nil {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"billingService": "initialized",
"message": "Billing service is working",
})
} else {
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
}
})
}
server.router.GET("/v1/meta/config", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"environment": cfg.Environment,
"neonAuthEnabled": verifier.Enabled(),
"apiUrl": cfg.APIURL,
"demoMode": cfg.DemoMode,
"adminLoginEnabled": adminService.IsConfigured(),
})
})
// ============================================
// AUTH API
// ============================================
authGroup := server.router.Group("/v1/auth")
authGroup.Use(publicRateLimiter.Middleware())
{
authGroup.POST("/register", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.RegisterWithPassword(c.Request.Context(), request.Email, request.Password, request.Name)
if err != nil {
if errors.Is(err, auth.ErrEmailAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, auth.ErrPasswordTooShort) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration_failed"})
return
}
c.JSON(http.StatusCreated, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/login", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.LoginWithPassword(c.Request.Context(), request.Email, request.Password)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "login_failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/magic-link", func(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
_, err := authService.CreateMagicLink(c.Request.Context(), request.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "magic_link_failed"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "magic_link_sent"})
})
authGroup.POST("/verify", func(c *gin.Context) {
var request struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
user, tokens, err := authService.VerifyMagicLink(c.Request.Context(), request.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
authGroup.POST("/refresh", func(c *gin.Context) {
var request struct {
RefreshToken string `json:"refreshToken" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
tokens, err := authService.RefreshToken(c.Request.Context(), request.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"tokenType": tokens.TokenType,
"expiresIn": tokens.ExpiresIn,
})
})
}
// ============================================
// ADMIN API
// ============================================
adminGroup := server.router.Group("/v1/admin")
adminGroup.Use(admin.RequireAdmin(adminService, verifier, repository, cfg.DemoMode))
{
adminGroup.GET("/stats", func(c *gin.Context) {
stats, err := adminService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
})
adminGroup.GET("/tenants", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
result, err := adminService.ListTenants(c.Request.Context(), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
adminGroup.GET("/users", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
result, err := adminService.ListUsers(c.Request.Context(), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
adminGroup.PUT("/users/:userID/role", func(c *gin.Context) {
var request domain.UpdateUserRoleRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
err := adminService.UpdateUserRole(
c.Request.Context(),
admin.ActorFromContext(c),
c.Param("userID"),
request.Role,
c.ClientIP(),
c.GetHeader("User-Agent"),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
})
// Trigger trial ending email check
adminGroup.POST("/trigger-trial-emails", func(c *gin.Context) {
err := billingService.CheckAndSendTrialEndingEmails(c.Request.Context(), notificationService)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "completed", "message": "Trial ending emails sent"})
})
}
server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) {
response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrTenantNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) {
var request struct {
TenantSlug string `json:"tenantSlug" binding:"required"`
BookingMode string `json:"bookingMode" binding:"required"`
ServiceID *string `json:"serviceId"`
ClassSessionID *string `json:"classSessionId"`
StaffID *string `json:"staffId"`
LocationID *string `json:"locationId"`
CustomerName string `json:"customerName" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required,email"`
CustomerPhone string `json:"customerPhone"`
Notes string `json:"notes"`
StartsAt string `json:"startsAt" binding:"required"`
EndsAt string `json:"endsAt" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := bookingService.Create(c.Request.Context(), domain.CreateBookingRequest(request))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrInvalidBooking):
status = http.StatusBadRequest
case errors.Is(err, bookings.ErrTenantNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
server.router.POST("/v1/public/contact", publicRateLimiter.Middleware(), func(c *gin.Context) {
var request struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Message string `json:"message" binding:"required,min=10"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
if err := notificationService.SendContactEmail(c.Request.Context(), request.Name, request.Email, request.Message); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
})
protected := server.router.Group("/v1")
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
protected.GET("/dashboard/summary", func(c *gin.Context) {
response, err := bookingService.DashboardSummary(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/tenants/bootstrap", func(c *gin.Context) {
response, err := tenantService.Bootstrap(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/tenants/onboard", func(c *gin.Context) {
var request domain.OnboardTenantRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.Onboard(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, tenancy.ErrInvalidOnboarding):
status = http.StatusBadRequest
case errors.Is(err, tenancy.ErrTenantAlreadyProvisioned), errors.Is(err, tenancy.ErrTenantSlugTaken):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/tenants/brand", func(c *gin.Context) {
var request domain.UpdateBrandRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.UpdateBrand(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/tenants/email-templates", func(c *gin.Context) {
response, err := tenantService.ListEmailTemplates(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"templates": response})
})
protected.PUT("/tenants/email-template", func(c *gin.Context) {
var request domain.UpdateEmailTemplateRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.UpdateEmailTemplate(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, tenancy.ErrInvalidOnboarding) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// ============================================
// CATALOG API - Locations / Zones
// ============================================
protected.GET("/catalog/locations", func(c *gin.Context) {
response, err := catalogService.ListLocations(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/locations", func(c *gin.Context) {
var request domain.CreateLocationRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateLocation(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/locations/:locationID", func(c *gin.Context) {
var request domain.UpdateLocationRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrLocationNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/locations/:locationID", func(c *gin.Context) {
err := catalogService.DeleteLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrLocationNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Services
// ============================================
protected.GET("/catalog/services", func(c *gin.Context) {
response, err := catalogService.ListServices(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/services", func(c *gin.Context) {
var request domain.CreateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateService(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/services/:serviceID", func(c *gin.Context) {
var request domain.UpdateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrServiceNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/services/:serviceID", func(c *gin.Context) {
err := catalogService.DeleteService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrServiceNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Staff
// ============================================
protected.GET("/catalog/staff", func(c *gin.Context) {
response, err := catalogService.ListStaff(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/staff", func(c *gin.Context) {
var request domain.CreateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateStaff(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrPlanLimitReached):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/staff/:staffID", func(c *gin.Context) {
var request domain.UpdateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/staff/:staffID", func(c *gin.Context) {
err := catalogService.DeleteStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrStaffNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.POST("/catalog/bookings", func(c *gin.Context) {
var request domain.DashboardBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateBooking(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidBooking):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
// ============================================
// CATALOG API - Blocked Days
// ============================================
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
from := time.Now()
to := from.AddDate(0, 3, 0) // 3 months ahead
response, err := catalogService.ListBlockedDays(c.Request.Context(), auth.PrincipalFromContext(c), from, to)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/blocked-days", func(c *gin.Context) {
var request domain.CreateBlockedDayRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrInvalidBooking) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
var request domain.UpdateBlockedDayRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
err := catalogService.DeleteBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Customers
// ============================================
protected.GET("/catalog/customers", func(c *gin.Context) {
limit := 50
offset := 0
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
limit = l
}
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
offset = o
}
response, err := catalogService.ListCustomers(c.Request.Context(), auth.PrincipalFromContext(c), limit, offset)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/customers", func(c *gin.Context) {
var request domain.CreateCustomerRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/customers/:customerID", func(c *gin.Context) {
var request domain.UpdateCustomerRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/customers/:customerID", func(c *gin.Context) {
err := catalogService.DeleteCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Working Hours
// ============================================
protected.GET("/catalog/working-hours", func(c *gin.Context) {
response, err := catalogService.ListWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.PUT("/catalog/working-hours/:dayOfWeek", func(c *gin.Context) {
dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek"))
if err != nil || dayOfWeek < 0 || dayOfWeek > 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"})
return
}
var request domain.UpdateWorkingHoursRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
err = catalogService.UpdateWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), dayOfWeek, request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
})
// ============================================
// CUSTOMER BOOKING MANAGEMENT API (Public)
// ============================================
server.router.GET("/v1/public/bookings/:reference", func(c *gin.Context) {
token := c.Query("token")
response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/public/bookings/:reference/reschedule", publicRateLimiter.Middleware(), func(c *gin.Context) {
token := c.Query("token")
var request domain.RescheduleBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
err := customerBookingService.RescheduleBooking(c.Request.Context(), c.Param("reference"), request, token)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
case errors.Is(err, bookings.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "rescheduled"})
})
server.router.POST("/v1/public/bookings/:reference/cancel", publicRateLimiter.Middleware(), func(c *gin.Context) {
token := c.Query("token")
err := customerBookingService.CancelBooking(c.Request.Context(), c.Param("reference"), token)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
})
protected.GET("/billing/subscription", func(c *gin.Context) {
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/billing/checkout", func(c *gin.Context) {
var request domain.CheckoutSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency, request.BillingInterval)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
status = http.StatusBadRequest
}
if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/billing/refresh", func(c *gin.Context) {
response, err := billingService.Refresh(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/billing/portal", func(c *gin.Context) {
response, err := billingService.CreatePortalSession(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, billing.ErrBillingMembership):
status = http.StatusNotFound
case errors.Is(err, billing.ErrBillingCustomerMissing):
status = http.StatusBadRequest
case errors.Is(err, billing.ErrStripeNotConfigured):
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// ============================================
// SMS API
// ============================================
protected.GET("/sms/settings", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
settings, err := smsService.GetSettings(c.Request.Context(), membership.Tenant.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
})
protected.POST("/sms/settings", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var request domain.UpdateSMSSettingsRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
settings, err := smsService.UpdateSettings(c.Request.Context(), membership.Tenant.ID, request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, sms.ErrSMSPlanNotAllowed) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
})
protected.POST("/sms/send", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var request domain.SendSMSRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := smsService.SendMessage(c.Request.Context(), membership.Tenant.ID, request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, sms.ErrSMSNotConfigured), errors.Is(err, sms.ErrSMSNotEnabled):
status = http.StatusServiceUnavailable
case errors.Is(err, sms.ErrSMSPlanNotAllowed):
status = http.StatusForbidden
case errors.Is(err, sms.ErrSMSLimitReached):
status = http.StatusTooManyRequests
case errors.Is(err, sms.ErrSMSInvalidPhone):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/sms/usage", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
yearMonth := c.Query("month")
if yearMonth == "" {
now := time.Now().UTC()
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
}
report, err := smsService.GetUsage(c.Request.Context(), membership.Tenant.ID, yearMonth)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
})
protected.GET("/sms/history", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
limit := 50
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
limit = l
}
logs, err := smsService.GetUsageHistory(c.Request.Context(), membership.Tenant.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
})
protected.GET("/sms/invoices", func(c *gin.Context) {
membership, err := tenantService.GetTenantMembership(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
limit := 12
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 24 {
limit = l
}
reports, err := smsService.GetMonthlyReports(c.Request.Context(), membership.Tenant.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"reports": reports})
})
// Internal job endpoint for SMS monthly invoicing
server.router.POST("/v1/internal/jobs/sms/invoices", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
yearMonth := c.Query("month")
response, err := smsService.GenerateMonthlyInvoices(c.Request.Context(), yearMonth, notificationService)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
if err := billingService.HandleStripeWebhook(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
})
server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) {
if !authorizeJobRunner(c, cfg) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var request domain.DispatchReminderJobsRequest
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := notificationService.DispatchDue(c.Request.Context(), request.Limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Widget embeddable script endpoint - serves JavaScript for external sites
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
c.Header("Content-Type", "application/javascript; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600") // Cache for 1 hour
c.String(http.StatusOK, widgetJavaScript(cfg.APIURL))
})
return server, nil
}
func (s *Server) Handler() http.Handler {
return s.router
}
func (s *Server) Close() {
if s.verifier != nil {
s.verifier.Close()
}
}
// StartBackgroundJobs runs periodic background tasks
func (s *Server) StartBackgroundJobs() {
// Run trial ending check every 6 hours
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
// Run immediately on startup after a brief delay
time.Sleep(30 * time.Second)
s.runTrialEndingCheck()
for {
select {
case <-ticker.C:
s.runTrialEndingCheck()
}
}
}
func (s *Server) runTrialEndingCheck() {
if s.billingService == nil || s.notificationService == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := s.billingService.CheckAndSendTrialEndingEmails(ctx, s.notificationService)
if err != nil {
log.Printf("Background job: trial ending check failed: %v", err)
} else {
log.Printf("Background job: trial ending check completed")
}
}
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
if cfg.JobRunnerKey == "" {
return false
}
return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey
}
func allowedOrigins(cfg config.Config) []string {
origins := []string{cfg.FrontendURL}
if cfg.Environment == "development" {
origins = append(origins,
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:4173",
"http://127.0.0.1:4173",
)
}
seen := make(map[string]struct{}, len(origins))
unique := origins[:0]
for _, origin := range origins {
if origin == "" {
continue
}
if _, ok := seen[origin]; ok {
continue
}
seen[origin] = struct{}{}
unique = append(unique, origin)
}
return unique
}
// widgetJavaScript returns the embeddable widget script that can be included on external sites
func widgetJavaScript(apiURL string) string {
return `(function() {
'use strict';
// Bookra Widget v1.0 - Embeddable Booking Widget
// Usage: <script src="` + apiURL + `/v1/public/widget.js" data-tenant="your-slug" async defer></script>
const WIDGET_VERSION = '1.0.0';
const WIDGET_ORIGIN = '` + apiURL + `';
// Configuration from script tag attributes
const scripts = document.querySelectorAll('script[src*="widget.js"]');
scripts.forEach(function(script) {
const config = {
tenant: script.getAttribute('data-tenant') || script.getAttribute('data-tenant-slug'),
theme: script.getAttribute('data-theme') || 'auto',
size: script.getAttribute('data-size') || 'default',
color: script.getAttribute('data-color') || '#a65c3e',
position: script.getAttribute('data-position') || 'bottom-right',
widgetId: script.getAttribute('data-widget-id') || 'bookra-widget-' + Math.random().toString(36).substr(2, 9)
};
if (!config.tenant) {
console.error('[Bookra Widget] Missing data-tenant attribute');
return;
}
// Find or create widget container
let container = document.getElementById(config.widgetId);
if (!container) {
container = document.createElement('div');
container.id = config.widgetId;
container.className = 'bookra-widget-container';
// For floating widgets, append to body
if (config.size === 'floating' || script.getAttribute('data-floating') === 'true') {
container.style.cssText = 'position:fixed;' + getFloatingPosition(config.position) + ';z-index:9999;';
document.body.appendChild(container);
}
}
// Apply theme
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = config.theme === 'dark' || (config.theme === 'auto' && prefersDark);
container.setAttribute('data-theme', isDark ? 'dark' : 'light');
// Build booking URL
const bookingUrl = WIDGET_ORIGIN.replace('/api', '') + '/book/' + encodeURIComponent(config.tenant);
// Create widget content based on type
const widgetType = script.getAttribute('data-type') || 'iframe';
switch (widgetType) {
case 'button':
container.innerHTML = createButtonWidget(config, bookingUrl);
break;
case 'modal':
container.innerHTML = createModalWidget(config, bookingUrl);
break;
case 'floating':
container.innerHTML = createFloatingWidget(config, bookingUrl);
break;
case 'inline-calendar':
container.innerHTML = createCalendarWidget(config, bookingUrl, WIDGET_ORIGIN);
break;
default:
container.innerHTML = createIframeWidget(config, bookingUrl);
}
// Add styles
addWidgetStyles(config.color, isDark);
});
function getFloatingPosition(position) {
switch (position) {
case 'top-left': return 'top:20px;left:20px';
case 'top-right': return 'top:20px;right:20px';
case 'bottom-left': return 'bottom:20px;left:20px';
default: return 'bottom:20px;right:20px';
}
}
function createIframeWidget(config, url) {
const height = config.size === 'compact' ? '400px' : config.size === 'full' ? '100vh' : '760px';
return '<iframe src="' + url + '" style="width:100%;height:' + height + ';border:none;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.1);" loading="lazy" title="Book appointment"></iframe>';
}
function createButtonWidget(config, url) {
return '<button class="bookra-widget-btn" onclick="window.open(\'' + url + '\', \'_blank\')" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:opacity 0.2s;">' +
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
'Book appointment</button>';
}
function createModalWidget(config, url) {
const modalId = config.widgetId + '-modal';
return '<button class="bookra-widget-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;">Book now</button>' +
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
'<div style="background:white;border-radius:16px;width:100%;max-width:900px;height:90vh;position:relative;overflow:hidden;">' +
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
'<iframe src="' + url + '" style="width:100%;height:100%;border:none;"></iframe>' +
'</div></div>';
}
function createFloatingWidget(config, url) {
const modalId = config.widgetId + '-modal';
return '<button class="bookra-floating-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;width:60px;height:60px;border:none;border-radius:50%;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:center;justify-content:center;transition:transform 0.2s;" onmouseover="this.style.transform=\'scale(1.1)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
'</button>' +
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
'<div style="background:white;border-radius:16px;width:100%;max-width:500px;max-height:90vh;position:relative;overflow:hidden;">' +
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
'<iframe src="' + url + '" style="width:100%;height:80vh;border:none;"></iframe>' +
'</div></div>';
}
function createCalendarWidget(config, url, apiUrl) {
// Placeholder for inline calendar - would fetch availability and render mini-calendar
return '<div class="bookra-calendar-widget" style="background:white;border-radius:12px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1);">' +
'<h3 style="margin:0 0 16px 0;font-size:18px;">Select a time</h3>' +
'<p style="color:#666;margin:0 0 16px 0;">Loading availability...</p>' +
'<a href="' + url + '" target="_blank" style="display:inline-block;background:' + config.color + ';color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;">View all times</a>' +
'</div>';
}
function addWidgetStyles(primaryColor, isDark) {
if (document.getElementById('bookra-widget-styles')) return;
const styles = document.createElement('style');
styles.id = 'bookra-widget-styles';
styles.textContent = '.bookra-widget-container { font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; }' +
'.bookra-widget-container[data-theme="dark"] iframe { filter: invert(0.95) hue-rotate(180deg); }' +
'.bookra-floating-btn:hover { transform: scale(1.1); }' +
'@keyframes bookra-pulse { 0%, 100% { box-shadow: 0 4px 12px rgba(0,0,0,0.15); } 50% { box-shadow: 0 4px 20px rgba(0,0,0,0.25); } }' +
'.bookra-floating-btn { animation: bookra-pulse 2s infinite; }';
document.head.appendChild(styles);
}
// Log initialization
console.log('[Bookra Widget] v' + WIDGET_VERSION + ' initialized for ' + scripts.length + ' widget(s)');
})();`
}