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: 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 ''; } function createButtonWidget(config, url) { return ''; } function createModalWidget(config, url) { const modalId = config.widgetId + '-modal'; return '' + ''; } function createFloatingWidget(config, url) { const modalId = config.widgetId + '-modal'; return '' + ''; } function createCalendarWidget(config, url, apiUrl) { // Placeholder for inline calendar - would fetch availability and render mini-calendar return '
' + '

Select a time

' + '

Loading availability...

' + 'View all times' + '
'; } 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)'); })();` }