Files
Bookra/apps/backend/internal/api/server.go
T
Tomas Dvorak 48c3e15a38 cleanup
2026-05-05 09:48:07 +02:00

776 lines
30 KiB
Go

package api
import (
"errors"
"io"
"net/http"
"strconv"
"time"
"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/tenancy"
"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
}
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)
catalogService := catalog.NewService(repository)
billingService := billing.NewService(cfg, repository)
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
server := &Server{
router: gin.New(),
cfg: cfg,
pools: pools,
verifier: verifier,
}
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(),
})
})
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,
})
})
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"`
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)
})
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
}
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)
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)
})
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("/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()
}
}
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)');
})();`
}