mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 12:33:00 +00:00
7d3e3448cf
Implement a complete SMS messaging system including: - Integration with SMS Manager.cz API for sending messages. - Metered billing via Stripe using monthly aggregate invoice items. - Backend services for managing SMS settings, usage logging, and monthly reporting. - Database migrations for tenant settings, usage logs, and monthly reports. - Frontend dashboard components for SMS configuration, usage tracking, and history. - Support for customer phone numbers in the booking flow. Includes new migrations, backend services, and frontend UI components.
1217 lines
44 KiB
Go
1217 lines
44 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)
|
|
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
|
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"},
|
|
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
|
AllowCredentials: true,
|
|
}), httpx.SecurityHeaders())
|
|
|
|
server.router.GET("/healthz", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "ok",
|
|
"environment": cfg.Environment,
|
|
"databaseConfigured": pools.DatabaseConfigured(),
|
|
})
|
|
})
|
|
|
|
// Test endpoint for Sentry
|
|
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"})
|
|
})
|
|
|
|
// Test endpoint for billing
|
|
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.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, authService))
|
|
{
|
|
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
|
|
}
|
|
adminUserID, _ := c.Get("userID")
|
|
err := adminService.UpdateUserRole(
|
|
c.Request.Context(),
|
|
adminUserID.(string),
|
|
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)
|
|
})
|
|
|
|
// ============================================
|
|
// 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 - 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
|
|
if errors.Is(err, bookings.ErrBookingNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
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.ErrBookingCancelled):
|
|
status = http.StatusConflict
|
|
case errors.Is(err, bookings.ErrInvalidReschedule):
|
|
status = http.StatusBadRequest
|
|
}
|
|
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.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.ErrPaddleNotConfigured) {
|
|
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.ErrPaddleNotConfigured) {
|
|
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.ErrPaddleNotConfigured):
|
|
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/paddle", func(c *gin.Context) {
|
|
if err := billingService.HandleWebhook(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/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("/api/paddle_webhook", func(c *gin.Context) {
|
|
if err := billingService.HandleWebhook(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)');
|
|
})();`
|
|
}
|