mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(sms): implement SMS messaging and metered billing
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.
This commit is contained in:
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"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"
|
||||
@@ -36,6 +38,7 @@ type Server struct {
|
||||
adminService *admin.Service
|
||||
billingService *billing.Service
|
||||
notificationService *notifications.Service
|
||||
smsService *sms.Service
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
@@ -53,6 +56,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
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{
|
||||
@@ -64,6 +68,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
adminService: adminService,
|
||||
billingService: billingService,
|
||||
notificationService: notificationService,
|
||||
smsService: smsService,
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
@@ -339,6 +344,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
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"`
|
||||
@@ -801,6 +807,141 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
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()})
|
||||
|
||||
@@ -780,6 +780,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
WidgetEmbedding: true,
|
||||
UmamiTracking: false,
|
||||
APIAccess: false,
|
||||
SMSAvailable: false,
|
||||
}
|
||||
case "business":
|
||||
// Business: Unlimited everything, API access, dedicated manager
|
||||
@@ -793,6 +794,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
UmamiTracking: true,
|
||||
APIAccess: true,
|
||||
DedicatedManager: true,
|
||||
SMSAvailable: true,
|
||||
}
|
||||
default:
|
||||
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
|
||||
@@ -805,6 +807,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
||||
WidgetEmbedding: true,
|
||||
UmamiTracking: true,
|
||||
APIAccess: false,
|
||||
SMSAvailable: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "pro", "czk")
|
||||
}, "pro", "czk", "monthly")
|
||||
if err != ErrPaddleNotConfigured {
|
||||
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestCreateCheckoutReturnsLaunchPayload(t *testing.T) {
|
||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||
Subject: "demo-owner",
|
||||
Email: "owner@bookra.dev",
|
||||
}, "pro", "czk")
|
||||
}, "pro", "czk", "monthly")
|
||||
if err != nil {
|
||||
t.Fatalf("create checkout: %v", err)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
|
||||
customerName := strings.TrimSpace(request.CustomerName)
|
||||
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
||||
customerPhone := strings.TrimSpace(request.CustomerPhone)
|
||||
notes := strings.TrimSpace(request.Notes)
|
||||
if len(customerName) < 2 || len(customerName) > 120 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
||||
@@ -129,6 +130,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
||||
}
|
||||
if customerPhone != "" && len(customerPhone) > 30 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerPhone must be at most 30 characters", ErrInvalidBooking)
|
||||
}
|
||||
if len(notes) > 1000 {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
|
||||
}
|
||||
@@ -194,6 +198,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
BookingMode: request.BookingMode,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
CustomerPhone: customerPhone,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: endsAt.UTC(),
|
||||
Status: status,
|
||||
|
||||
@@ -10,64 +10,70 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
Port string
|
||||
APIURL string
|
||||
FrontendURL string
|
||||
DatabaseURL string
|
||||
DatabaseDirectURL string
|
||||
NeonAuthURL string
|
||||
AuthJWTSecret string
|
||||
JobRunnerKey string
|
||||
EmailFrom string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
PaddleEnvironment string
|
||||
PaddleAPIKey string
|
||||
PaddleWebhookKey string
|
||||
PaddlePriceMatrix map[string]map[string]string
|
||||
StripeAPIKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceMatrix map[string]map[string]string
|
||||
AdminEmail string
|
||||
AdminKey string
|
||||
UmamiAPIURL string
|
||||
UmamiAPIKey string
|
||||
SentryDSN string
|
||||
DemoMode bool
|
||||
Environment string
|
||||
Port string
|
||||
APIURL string
|
||||
FrontendURL string
|
||||
DatabaseURL string
|
||||
DatabaseDirectURL string
|
||||
NeonAuthURL string
|
||||
AuthJWTSecret string
|
||||
JobRunnerKey string
|
||||
EmailFrom string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
PaddleEnvironment string
|
||||
PaddleAPIKey string
|
||||
PaddleWebhookKey string
|
||||
PaddlePriceMatrix map[string]map[string]string
|
||||
StripeAPIKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceMatrix map[string]map[string]string
|
||||
AdminEmail string
|
||||
AdminKey string
|
||||
UmamiAPIURL string
|
||||
UmamiAPIKey string
|
||||
SentryDSN string
|
||||
DemoMode bool
|
||||
SMSManagerAPIKey string
|
||||
SMSManagerBaseURL string
|
||||
StripeSMSPriceMatrix map[string]string // currency -> price ID (czk, usd, eur, gbp, ...)
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
|
||||
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
|
||||
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
|
||||
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
||||
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
||||
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
||||
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
|
||||
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
||||
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
||||
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
|
||||
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
|
||||
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
|
||||
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
|
||||
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
|
||||
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
||||
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
||||
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
||||
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
|
||||
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||
StripePriceMatrix: stripePriceMatrixFromEnv(),
|
||||
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
|
||||
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
|
||||
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
|
||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
||||
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
|
||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
||||
Environment: valueOrDefault("BOOKRA_APP_ENV", "development"),
|
||||
Port: valueOrDefault("BOOKRA_API_PORT", "8080"),
|
||||
APIURL: valueOrDefault("BOOKRA_API_URL", "http://localhost:8080"),
|
||||
FrontendURL: valueOrDefault("BOOKRA_FRONTEND_URL", "http://localhost:3000"),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_URL")),
|
||||
DatabaseDirectURL: strings.TrimSpace(os.Getenv("BOOKRA_DATABASE_DIRECT_URL")),
|
||||
NeonAuthURL: strings.TrimSpace(os.Getenv("BOOKRA_NEON_AUTH_URL")),
|
||||
AuthJWTSecret: strings.TrimSpace(os.Getenv("BOOKRA_AUTH_JWT_SECRET")),
|
||||
JobRunnerKey: strings.TrimSpace(os.Getenv("BOOKRA_JOB_RUNNER_KEY")),
|
||||
EmailFrom: valueOrDefault("BOOKRA_EMAIL_FROM", "noreply@bookra.dev"),
|
||||
SMTPHost: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_HOST")),
|
||||
SMTPPort: valueOrDefault("BOOKRA_SMTP_PORT", "587"),
|
||||
SMTPUsername: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_USERNAME")),
|
||||
SMTPPassword: strings.TrimSpace(os.Getenv("BOOKRA_SMTP_PASSWORD")),
|
||||
PaddleEnvironment: normalizePaddleEnvironment(os.Getenv("BOOKRA_PADDLE_ENV")),
|
||||
PaddleAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_API_KEY")),
|
||||
PaddleWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_PADDLE_WEBHOOK_SECRET")),
|
||||
PaddlePriceMatrix: paddlePriceMatrixFromEnv(),
|
||||
StripeAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_API_KEY")),
|
||||
StripeWebhookKey: strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_WEBHOOK_SECRET")),
|
||||
StripePriceMatrix: stripePriceMatrixFromEnv(),
|
||||
AdminEmail: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_EMAIL")),
|
||||
AdminKey: strings.TrimSpace(os.Getenv("BOOKRA_ADMIN_KEY")),
|
||||
UmamiAPIURL: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_URL")),
|
||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
||||
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
|
||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
||||
SMSManagerAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_SMSMANAGER_API_KEY")),
|
||||
SMSManagerBaseURL: valueOrDefault("BOOKRA_SMSMANAGER_BASE_URL", "https://api.smsmngr.com/v2"),
|
||||
StripeSMSPriceMatrix: smsPriceMatrixFromEnv(),
|
||||
}
|
||||
|
||||
if cfg.FrontendURL == "" {
|
||||
@@ -158,6 +164,25 @@ func (cfg Config) BillingWebhookConfigured() bool {
|
||||
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
|
||||
}
|
||||
|
||||
func (cfg Config) SMSConfigured() bool {
|
||||
return strings.TrimSpace(cfg.SMSManagerAPIKey) != ""
|
||||
}
|
||||
|
||||
func (cfg Config) StripeSMSConfigured() bool {
|
||||
return cfg.StripeConfigured() && cfg.StripeSMSPriceMatrix["czk"] != ""
|
||||
}
|
||||
|
||||
func (cfg Config) StripeSMSPriceID(currency string) string {
|
||||
c := strings.ToLower(strings.TrimSpace(currency))
|
||||
if c == "" {
|
||||
c = "czk"
|
||||
}
|
||||
if id := cfg.StripeSMSPriceMatrix[c]; id != "" {
|
||||
return id
|
||||
}
|
||||
return cfg.StripeSMSPriceMatrix["czk"]
|
||||
}
|
||||
|
||||
func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||
matrix := map[string]map[string]string{
|
||||
"starter": {},
|
||||
@@ -223,6 +248,15 @@ func boolFromEnv(key string, fallback bool) bool {
|
||||
return value == "true" || value == "1" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func smsPriceMatrixFromEnv() map[string]string {
|
||||
matrix := map[string]string{}
|
||||
for _, currency := range []string{"czk", "usd", "eur", "gbp", "pln"} {
|
||||
upper := strings.ToUpper(currency)
|
||||
matrix[currency] = strings.TrimSpace(os.Getenv("BOOKRA_STRIPE_SMS_" + upper + "_PRICE_ID"))
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, starts_at, ends_at, status, reference
|
||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
|
||||
FROM bookings
|
||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||
ORDER BY starts_at ASC
|
||||
@@ -30,6 +30,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
||||
&record.LocationID,
|
||||
&record.CustomerName,
|
||||
&record.CustomerEmail,
|
||||
&record.CustomerPhone,
|
||||
&record.StartsAt,
|
||||
&record.EndsAt,
|
||||
&record.Status,
|
||||
@@ -47,10 +48,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO bookings (
|
||||
tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
booking_mode, customer_name, customer_email, starts_at, ends_at,
|
||||
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
|
||||
status, reference, notes
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING id, reference, status
|
||||
`,
|
||||
params.TenantID,
|
||||
@@ -61,6 +62,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
||||
params.BookingMode,
|
||||
params.CustomerName,
|
||||
params.CustomerEmail,
|
||||
params.CustomerPhone,
|
||||
params.StartsAt,
|
||||
params.EndsAt,
|
||||
params.Status,
|
||||
|
||||
@@ -86,6 +86,18 @@ type Repository interface {
|
||||
// Working Hours
|
||||
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
||||
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
||||
|
||||
// SMS
|
||||
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
|
||||
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
|
||||
CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error)
|
||||
GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error)
|
||||
GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error)
|
||||
ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error)
|
||||
ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error)
|
||||
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
|
||||
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
|
||||
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
|
||||
}
|
||||
|
||||
type TenantRecord struct {
|
||||
@@ -124,12 +136,12 @@ type MagicLinkRecord struct {
|
||||
}
|
||||
|
||||
type PlatformStats struct {
|
||||
TotalTenants int64 `json:"totalTenants"`
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
TotalTenants int64 `json:"totalTenants"`
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
|
||||
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
|
||||
}
|
||||
|
||||
type AdminAuditLogParams struct {
|
||||
@@ -229,6 +241,7 @@ type BookingRecord struct {
|
||||
LocationID *string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
CustomerPhone string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Status string
|
||||
@@ -244,6 +257,7 @@ type CreateBookingParams struct {
|
||||
BookingMode string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
CustomerPhone string
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Status string
|
||||
@@ -414,6 +428,44 @@ type UpdateWorkingHoursParams struct {
|
||||
IsOpen *bool
|
||||
}
|
||||
|
||||
// SMS Records
|
||||
type TenantSMSSettingsRecord struct {
|
||||
TenantID string
|
||||
Enabled bool
|
||||
SenderName string
|
||||
MonthlyLimit int
|
||||
StripeSubscriptionItemID string
|
||||
}
|
||||
|
||||
type SMSUsageLogRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
RecipientPhone string
|
||||
MessageBody string
|
||||
ExternalMessageID string
|
||||
ExternalRequestID string
|
||||
Status string
|
||||
CostCents int
|
||||
SentAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SMSUsageSummary struct {
|
||||
MessageCount int
|
||||
TotalCostCents int
|
||||
}
|
||||
|
||||
type SMSMonthlyReportRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
YearMonth string
|
||||
MessageCount int
|
||||
TotalCostCents int
|
||||
StripeInvoiceID string
|
||||
InvoiceSentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type PGRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -428,46 +480,14 @@ func NewRepository(pools *Pools, demoMode bool) Repository {
|
||||
return NewMemoryRepository()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================
|
||||
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
|
||||
// ============================================
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================
|
||||
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
|
||||
// ============================================
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
|
||||
// ============================================
|
||||
@@ -572,11 +592,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
|
||||
var rec BookingRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, starts_at, ends_at, status, reference
|
||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference
|
||||
FROM bookings
|
||||
WHERE reference = $1
|
||||
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
||||
&rec.CustomerName, &rec.CustomerEmail, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
|
||||
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
@@ -598,8 +618,6 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
|
||||
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
|
||||
// ============================================
|
||||
|
||||
|
||||
|
||||
type MemoryRepository struct {
|
||||
tenant TenantRecord
|
||||
membership TenantMembershipRecord
|
||||
@@ -617,6 +635,9 @@ type MemoryRepository struct {
|
||||
blockedDays []BlockedDayRecord
|
||||
customers []CustomerRecord
|
||||
workingHours []WorkingHoursRecord
|
||||
smsSettings TenantSMSSettingsRecord
|
||||
smsLogs []SMSUsageLogRecord
|
||||
smsReports []SMSMonthlyReportRecord
|
||||
}
|
||||
|
||||
func NewMemoryRepository() *MemoryRepository {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// SMS SETTINGS - PG REPOSITORY
|
||||
// ============================================
|
||||
|
||||
func (r *PGRepository) GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
|
||||
var rec TenantSMSSettingsRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT tenant_id, enabled, COALESCE(sender_name, ''), COALESCE(monthly_limit, 0), COALESCE(stripe_subscription_item_id, '')
|
||||
FROM tenant_sms_settings
|
||||
WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&rec.TenantID, &rec.Enabled, &rec.SenderName, &rec.MonthlyLimit, &rec.StripeSubscriptionItemID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return TenantSMSSettingsRecord{TenantID: tenantID}, nil
|
||||
}
|
||||
return TenantSMSSettingsRecord{}, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO tenant_sms_settings (tenant_id, enabled, sender_name, monthly_limit, stripe_subscription_item_id, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
sender_name = EXCLUDED.sender_name,
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
|
||||
updated_at = now()
|
||||
`, params.TenantID, params.Enabled, params.SenderName, params.MonthlyLimit, params.StripeSubscriptionItemID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SMS USAGE LOGS - PG REPOSITORY
|
||||
// ============================================
|
||||
|
||||
func (r *PGRepository) CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error) {
|
||||
var id string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO sms_usage_logs (tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, sent_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, params.TenantID, params.RecipientPhone, params.MessageBody, params.ExternalMessageID, params.ExternalRequestID, params.Status, params.CostCents, params.SentAt).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error) {
|
||||
var summary SMSUsageSummary
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
|
||||
FROM sms_usage_logs
|
||||
WHERE tenant_id = $1 AND date_trunc('month', created_at) = date_trunc('month', now())
|
||||
`, tenantID).Scan(&summary.MessageCount, &summary.TotalCostCents)
|
||||
return summary, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
|
||||
var rec SMSMonthlyReportRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
|
||||
FROM sms_usage_logs
|
||||
WHERE tenant_id = $1 AND to_char(created_at, 'YYYY-MM') = $2
|
||||
`, tenantID, yearMonth).Scan(&rec.MessageCount, &rec.TotalCostCents)
|
||||
if err != nil {
|
||||
return SMSMonthlyReportRecord{}, err
|
||||
}
|
||||
rec.TenantID = tenantID
|
||||
rec.YearMonth = yearMonth
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, created_at
|
||||
FROM sms_usage_logs
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`, tenantID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []SMSUsageLogRecord
|
||||
for rows.Next() {
|
||||
var rec SMSUsageLogRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.RecipientPhone, &rec.MessageBody, &rec.ExternalMessageID, &rec.ExternalRequestID, &rec.Status, &rec.CostCents, &rec.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id, invoice_sent_at, created_at
|
||||
FROM sms_monthly_reports
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY year_month DESC
|
||||
LIMIT $2
|
||||
`, tenantID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []SMSMonthlyReportRecord
|
||||
for rows.Next() {
|
||||
var rec SMSMonthlyReportRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.YearMonth, &rec.MessageCount, &rec.TotalCostCents, &rec.StripeInvoiceID, &rec.InvoiceSentAt, &rec.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO sms_monthly_reports (tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (tenant_id, year_month) DO UPDATE SET
|
||||
message_count = EXCLUDED.message_count,
|
||||
total_cost_cents = EXCLUDED.total_cost_cents,
|
||||
stripe_invoice_id = EXCLUDED.stripe_invoice_id,
|
||||
created_at = now()
|
||||
`, params.TenantID, params.YearMonth, params.MessageCount, params.TotalCostCents, params.StripeInvoiceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE sms_monthly_reports
|
||||
SET invoice_sent_at = now()
|
||||
WHERE tenant_id = $1 AND year_month = $2
|
||||
`, tenantID, yearMonth)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT DISTINCT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
|
||||
t.billing_provider, t.billing_customer_id, t.billing_subscription_id
|
||||
FROM tenants t
|
||||
JOIN tenant_sms_settings s ON s.tenant_id = t.id AND s.enabled = true
|
||||
JOIN sms_usage_logs l ON l.tenant_id = t.id AND to_char(l.created_at, 'YYYY-MM') = $1
|
||||
`, yearMonth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []TenantRecord
|
||||
for rows.Next() {
|
||||
var rec TenantRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.Slug, &rec.Name, &rec.Preset, &rec.Locale, &rec.Timezone, &rec.PlanCode, &rec.SubscriptionStatus,
|
||||
&rec.BillingProvider, &rec.BillingCustomerID, &rec.BillingSubscription); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SMS SETTINGS - MEMORY REPOSITORY
|
||||
// ============================================
|
||||
|
||||
func (r *MemoryRepository) GetTenantSMSSettings(_ context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return TenantSMSSettingsRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
return r.smsSettings, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpsertTenantSMSSettings(_ context.Context, params TenantSMSSettingsRecord) error {
|
||||
r.smsSettings = params
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateSMSUsageLog(_ context.Context, params SMSUsageLogRecord) (string, error) {
|
||||
params.ID = fmt.Sprintf("sms-%d", len(r.smsLogs))
|
||||
params.CreatedAt = time.Now().UTC()
|
||||
r.smsLogs = append([]SMSUsageLogRecord{params}, r.smsLogs...)
|
||||
return params.ID, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetSMSUsageThisMonth(_ context.Context, tenantID string) (SMSUsageSummary, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return SMSUsageSummary{}, nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var count, cost int
|
||||
for _, log := range r.smsLogs {
|
||||
if log.TenantID == tenantID && log.CreatedAt.Year() == now.Year() && log.CreatedAt.Month() == now.Month() {
|
||||
count++
|
||||
cost += log.CostCents
|
||||
}
|
||||
}
|
||||
return SMSUsageSummary{MessageCount: count, TotalCostCents: cost}, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetSMSUsageForMonth(_ context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return SMSMonthlyReportRecord{}, nil
|
||||
}
|
||||
var count, cost int
|
||||
for _, log := range r.smsLogs {
|
||||
if log.TenantID == tenantID && log.CreatedAt.Format("2006-01") == yearMonth {
|
||||
count++
|
||||
cost += log.CostCents
|
||||
}
|
||||
}
|
||||
return SMSMonthlyReportRecord{TenantID: tenantID, YearMonth: yearMonth, MessageCount: count, TotalCostCents: cost}, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListSMSUsageLogs(_ context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > len(r.smsLogs) {
|
||||
limit = len(r.smsLogs)
|
||||
}
|
||||
return r.smsLogs[:limit], nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListSMSMonthlyReports(_ context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > len(r.smsReports) {
|
||||
limit = len(r.smsReports)
|
||||
}
|
||||
return r.smsReports[:limit], nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpsertSMSMonthlyReport(_ context.Context, params SMSMonthlyReportRecord) error {
|
||||
for i, rep := range r.smsReports {
|
||||
if rep.TenantID == params.TenantID && rep.YearMonth == params.YearMonth {
|
||||
r.smsReports[i] = params
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r.smsReports = append([]SMSMonthlyReportRecord{params}, r.smsReports...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) MarkSMSReportInvoiceSent(_ context.Context, tenantID string, yearMonth string) error {
|
||||
now := time.Now().UTC()
|
||||
for i, rep := range r.smsReports {
|
||||
if rep.TenantID == tenantID && rep.YearMonth == yearMonth {
|
||||
r.smsReports[i].InvoiceSentAt = &now
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListTenantsWithSMSUsage(_ context.Context, yearMonth string) ([]TenantRecord, error) {
|
||||
for _, log := range r.smsLogs {
|
||||
if log.TenantID == r.tenant.ID && log.CreatedAt.Format("2006-01") == yearMonth && r.smsSettings.Enabled {
|
||||
return []TenantRecord{r.tenant}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -132,6 +132,7 @@ type CreateBookingRequest struct {
|
||||
LocationID *string `json:"locationId,omitempty"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
@@ -146,31 +147,32 @@ type CreateBookingResponse struct {
|
||||
type PlanEntitlements struct {
|
||||
MaxLocations int `json:"maxLocations"`
|
||||
MaxStaff int `json:"maxStaff"`
|
||||
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
|
||||
MaxBookingsMonth int `json:"maxBookingsMonth"` // -1 = unlimited
|
||||
EmailReminders bool `json:"emailReminders"`
|
||||
AdvancedReporting bool `json:"advancedReporting"`
|
||||
WidgetEmbedding bool `json:"widgetEmbedding"`
|
||||
UmamiTracking bool `json:"umamiTracking"`
|
||||
APIAccess bool `json:"apiAccess"`
|
||||
DedicatedManager bool `json:"dedicatedManager"`
|
||||
SMSAvailable bool `json:"smsAvailable"`
|
||||
}
|
||||
|
||||
type PlanPricing struct {
|
||||
MonthlyAmountCents int `json:"monthlyAmountCents"`
|
||||
YearlyAmountCents int `json:"yearlyAmountCents"`
|
||||
MonthlyFormatted string `json:"monthlyFormatted"`
|
||||
YearlyFormatted string `json:"yearlyFormatted"`
|
||||
YearlySavings string `json:"yearlySavings"`
|
||||
YearlySavingsPercent int `json:"yearlySavingsPercent"`
|
||||
MonthlyAmountCents int `json:"monthlyAmountCents"`
|
||||
YearlyAmountCents int `json:"yearlyAmountCents"`
|
||||
MonthlyFormatted string `json:"monthlyFormatted"`
|
||||
YearlyFormatted string `json:"yearlyFormatted"`
|
||||
YearlySavings string `json:"yearlySavings"`
|
||||
YearlySavingsPercent int `json:"yearlySavingsPercent"`
|
||||
}
|
||||
|
||||
type PlanDisplayPrice struct {
|
||||
Currency string `json:"currency"`
|
||||
AmountCents int `json:"amountCents"`
|
||||
Formatted string `json:"formatted"`
|
||||
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
|
||||
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
|
||||
YearlySavings string `json:"yearlySavings,omitempty"`
|
||||
Currency string `json:"currency"`
|
||||
AmountCents int `json:"amountCents"`
|
||||
Formatted string `json:"formatted"`
|
||||
YearlyAmountCents int `json:"yearlyAmountCents,omitempty"`
|
||||
YearlyFormatted string `json:"yearlyFormatted,omitempty"`
|
||||
YearlySavings string `json:"yearlySavings,omitempty"`
|
||||
YearlySavingsPercent int `json:"yearlySavingsPercent,omitempty"`
|
||||
}
|
||||
|
||||
@@ -205,11 +207,11 @@ type CheckoutSessionRequest struct {
|
||||
|
||||
type CheckoutLaunchResponse struct {
|
||||
// Stripe checkout
|
||||
CheckoutURL string `json:"checkoutUrl,omitempty"`
|
||||
CheckoutURL string `json:"checkoutUrl,omitempty"`
|
||||
// Paddle checkout
|
||||
PriceID string `json:"priceId,omitempty"`
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||
PriceID string `json:"priceId,omitempty"`
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||
// Common
|
||||
SuccessRedirectURL string `json:"successRedirectUrl,omitempty"`
|
||||
CancelRedirectURL string `json:"cancelRedirectUrl,omitempty"`
|
||||
@@ -261,19 +263,19 @@ type UpdateLocationRequest struct {
|
||||
// ============================================
|
||||
|
||||
type BlockedDay struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Date time.Time `json:"date"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Date time.Time `json:"date"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreateBlockedDayRequest struct {
|
||||
Date string `json:"date" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
Type string `json:"type" binding:"required"` // full, partial
|
||||
Date string `json:"date" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
Type string `json:"type" binding:"required"` // full, partial
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
}
|
||||
|
||||
@@ -287,32 +289,32 @@ type UpdateBlockedDayRequest struct {
|
||||
// ============================================
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status"` // active, inactive, vip
|
||||
BookingsCount int `json:"bookingsCount"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status"` // active, inactive, vip
|
||||
BookingsCount int `json:"bookingsCount"`
|
||||
LastBookingAt *time.Time `json:"lastBookingAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type CreateCustomerRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"` // defaults to active
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"` // defaults to active
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCustomerRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -333,9 +335,9 @@ type CustomerBookingView struct {
|
||||
}
|
||||
|
||||
type RescheduleBookingRequest struct {
|
||||
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
|
||||
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason,omitempty"`
|
||||
NewStartsAt string `json:"newStartsAt" binding:"required"` // RFC3339
|
||||
NewEndsAt string `json:"newEndsAt" binding:"required"` // RFC3339
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type CancelBookingRequest struct {
|
||||
@@ -347,35 +349,35 @@ type CancelBookingRequest struct {
|
||||
// ============================================
|
||||
|
||||
type AdminDashboardStats struct {
|
||||
TotalTenants int64 `json:"totalTenants"`
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||
TotalTenants int64 `json:"totalTenants"`
|
||||
TotalUsers int64 `json:"totalUsers"`
|
||||
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
||||
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
||||
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
||||
RevenueThisMonthCents int64 `json:"revenueThisMonthCents"`
|
||||
}
|
||||
|
||||
type AdminTenantList struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Tenants []AdminTenant `json:"tenants"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Tenants []AdminTenant `json:"tenants"`
|
||||
}
|
||||
|
||||
type AdminTenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
PlanCode string `json:"planCode"`
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
PlanCode string `json:"planCode"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
BillingProvider string `json:"billingProvider"`
|
||||
BillingProvider string `json:"billingProvider"`
|
||||
}
|
||||
|
||||
type AdminUserList struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Users []AdminUser `json:"users"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Users []AdminUser `json:"users"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
@@ -421,7 +423,7 @@ type UpdateWorkingHoursRequest struct {
|
||||
type EmailTemplate struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
|
||||
Type string `json:"type"` // booking_confirmation, reminder, cancellation, etc.
|
||||
Subject string `json:"subject"`
|
||||
BodyHTML string `json:"bodyHtml"`
|
||||
BodyText string `json:"bodyText"`
|
||||
@@ -436,14 +438,69 @@ type SendEmailRequest struct {
|
||||
}
|
||||
|
||||
type EmailNotification struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
BookingID string `json:"bookingId,omitempty"`
|
||||
Channel string `json:"channel"` // email, sms
|
||||
Type string `json:"type"` // confirmation, reminder, cancellation
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"` // pending, sent, failed
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
BookingID string `json:"bookingId,omitempty"`
|
||||
Channel string `json:"channel"` // email, sms
|
||||
Type string `json:"type"` // confirmation, reminder, cancellation
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"` // pending, sent, failed
|
||||
SentAt *time.Time `json:"sentAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SMS MODELS
|
||||
// ============================================
|
||||
|
||||
type SMSSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
MonthlyLimit int `json:"monthlyLimit,omitempty"`
|
||||
MessagesSent int `json:"messagesSent"`
|
||||
TotalCostCents int `json:"totalCostCents"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
type UpdateSMSSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
MonthlyLimit int `json:"monthlyLimit,omitempty"`
|
||||
}
|
||||
|
||||
type SendSMSRequest struct {
|
||||
To string `json:"to" binding:"required"`
|
||||
Body string `json:"body" binding:"required,max=1000"`
|
||||
}
|
||||
|
||||
type SendSMSResponse struct {
|
||||
LogID string `json:"logId"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CostCents int `json:"costCents"`
|
||||
}
|
||||
|
||||
type SMSUsageLog struct {
|
||||
ID string `json:"id"`
|
||||
RecipientPhone string `json:"recipientPhone"`
|
||||
MessageBody string `json:"messageBody,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CostCents int `json:"costCents"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SMSUsageReport struct {
|
||||
YearMonth string `json:"yearMonth"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
TotalCostCents int `json:"totalCostCents"`
|
||||
StripeInvoiceID string `json:"stripeInvoiceId,omitempty"`
|
||||
InvoiceSentAt *time.Time `json:"invoiceSentAt,omitempty"`
|
||||
}
|
||||
|
||||
type SMSInvoiceBatchResponse struct {
|
||||
YearMonth string `json:"yearMonth"`
|
||||
ProcessedCount int `json:"processedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
|
||||
@@ -458,3 +458,93 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
}
|
||||
return RenderEmailMessage(data)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SMS USAGE EMAIL TEMPLATE
|
||||
// ============================================
|
||||
|
||||
type SMSUsageEmailData struct {
|
||||
TenantName string
|
||||
TenantSlug string
|
||||
BusinessEmail string
|
||||
YearMonth string
|
||||
MessageCount int
|
||||
TotalCostCents int
|
||||
Locale string
|
||||
}
|
||||
|
||||
func RenderSMSUsageEmail(data SMSUsageEmailData) EmailMessage {
|
||||
cs := data.Locale == "cs"
|
||||
year := data.YearMonth[:4]
|
||||
month := data.YearMonth[5:]
|
||||
monthLabel := month + "/" + year
|
||||
if cs {
|
||||
monthLabel = month + "." + year
|
||||
}
|
||||
|
||||
totalFormatted := fmt.Sprintf("%.2f Kč", float64(data.TotalCostCents)/100.0)
|
||||
|
||||
subject := fmt.Sprintf("Bookra SMS Usage - %s (%s)", monthLabel, totalFormatted)
|
||||
if cs {
|
||||
subject = fmt.Sprintf("Bookra SMS Přehled - %s (%s)", monthLabel, totalFormatted)
|
||||
}
|
||||
|
||||
textBody := fmt.Sprintf(
|
||||
"SMS Usage Summary for %s\n\nPeriod: %s\nMessages sent: %d\nTotal cost: %s\n\nThis amount will be added to your next invoice.",
|
||||
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
|
||||
)
|
||||
if cs {
|
||||
textBody = fmt.Sprintf(
|
||||
"Přehled SMS pro %s\n\nObdobí: %s\nOdeslaných zpráv: %d\nCelková cena: %s\n\nTato částka bude přidána k vaší další faktuře.",
|
||||
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
|
||||
)
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
|
||||
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📱</span></div>
|
||||
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">%s</h2>
|
||||
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">%s</p>
|
||||
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="border-bottom:1px solid #e5e7eb;">
|
||||
<td style="padding:12px 0;color:#6b7280;">%s</td>
|
||||
<td style="padding:12px 0;text-align:right;font-weight:600;">%s</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #e5e7eb;">
|
||||
<td style="padding:12px 0;color:#6b7280;">%s</td>
|
||||
<td style="padding:12px 0;text-align:right;font-weight:600;">%d</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 0;color:#1f2937;font-weight:600;">%s</td>
|
||||
<td style="padding:12px 0;text-align:right;font-weight:700;font-size:18px;">%s</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color:#9ca3af;font-size:14px;text-align:center;">%s</p>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
|
||||
</body></html>`,
|
||||
ifCS(cs, "SMS Usage Summary", "Přehled SMS využití"),
|
||||
fmt.Sprintf(ifCS(cs, "Your SMS usage for %s", "Vaše SMS využití za %s"), data.TenantName),
|
||||
ifCS(cs, "Period", "Období"), monthLabel,
|
||||
ifCS(cs, "Messages sent", "Odeslaných zpráv"), data.MessageCount,
|
||||
ifCS(cs, "Total cost (excl. VAT)", "Celková cena (bez DPH)"), totalFormatted,
|
||||
ifCS(cs, "This amount will be added to your next Stripe invoice.", "Tato částka bude přidána k vaší další faktuře Stripe."),
|
||||
)
|
||||
|
||||
return EmailMessage{
|
||||
From: "",
|
||||
To: data.BusinessEmail,
|
||||
Subject: subject,
|
||||
Text: textBody,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
func ifCS(cs bool, en, csText string) string {
|
||||
if cs {
|
||||
return csText
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
@@ -334,6 +334,14 @@ func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locatio
|
||||
return err
|
||||
}
|
||||
|
||||
// SendRawEmail sends a pre-built email message
|
||||
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
|
||||
if msg.From == "" {
|
||||
msg.From = s.cfg.EmailFrom
|
||||
}
|
||||
return s.emailProvider.Send(ctx, msg)
|
||||
}
|
||||
|
||||
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/invoiceitem"
|
||||
)
|
||||
|
||||
type BillingService struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
smsPriceIDs map[string]string // currency -> price ID
|
||||
}
|
||||
|
||||
func NewBillingService(cfg config.Config, repo db.Repository) *BillingService {
|
||||
return &BillingService{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
smsPriceIDs: cfg.StripeSMSPriceMatrix,
|
||||
}
|
||||
}
|
||||
|
||||
// PriceIDForCurrency returns the Stripe price ID for SMS in the given currency
|
||||
func (b *BillingService) PriceIDForCurrency(currency string) string {
|
||||
c := strings.ToLower(strings.TrimSpace(currency))
|
||||
if c == "" {
|
||||
c = "czk"
|
||||
}
|
||||
if id := b.smsPriceIDs[c]; id != "" {
|
||||
return id
|
||||
}
|
||||
// Fallback to CZK
|
||||
return b.smsPriceIDs["czk"]
|
||||
}
|
||||
|
||||
// CreateMonthlyInvoiceItem creates a Stripe InvoiceItem for the total SMS usage of a month.
|
||||
// This adds a line item to the customer's next invoice — charging all messages together.
|
||||
func (b *BillingService) CreateMonthlyInvoiceItem(ctx context.Context, customerID string, currency string, yearMonth string, messageCount int, totalCents int) (string, error) {
|
||||
if customerID == "" {
|
||||
return "", fmt.Errorf("customer id is empty")
|
||||
}
|
||||
|
||||
priceID := b.PriceIDForCurrency(currency)
|
||||
c := strings.ToLower(strings.TrimSpace(currency))
|
||||
if c == "" {
|
||||
c = "czk"
|
||||
}
|
||||
|
||||
// If a Stripe price is configured, use Price + Quantity for a clean invoice line
|
||||
if priceID != "" {
|
||||
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
|
||||
Customer: stripe.String(customerID),
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(int64(messageCount)),
|
||||
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create invoice item: %w", err)
|
||||
}
|
||||
return item.ID, nil
|
||||
}
|
||||
|
||||
// Fallback: explicit amount (for dev/testing when no price configured yet)
|
||||
item, err := invoiceitem.New(&stripe.InvoiceItemParams{
|
||||
Customer: stripe.String(customerID),
|
||||
Amount: stripe.Int64(int64(totalCents)),
|
||||
Currency: stripe.String(c),
|
||||
Description: stripe.String(fmt.Sprintf("SMS Messages (%s) — %d messages", yearMonth, messageCount)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create invoice item: %w", err)
|
||||
}
|
||||
return item.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/shared"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSMSNotConfigured = errors.New("sms is not configured")
|
||||
ErrSMSNotEnabled = errors.New("sms is not enabled for this tenant")
|
||||
ErrSMSPlanNotAllowed = errors.New("sms is only available on pro and business plans")
|
||||
ErrSMSLimitReached = errors.New("monthly sms limit reached")
|
||||
ErrSMSInvalidPhone = errors.New("invalid phone number")
|
||||
ErrSMSMissingAPIKey = errors.New("sms manager api key is missing")
|
||||
ErrSMSSendFailed = errors.New("sms send failed")
|
||||
ErrStripeNotConfigured = errors.New("stripe is not configured for sms billing")
|
||||
ErrNoActiveSubscription = errors.New("no active subscription for sms billing")
|
||||
)
|
||||
|
||||
const smsCostCents = 150 // 1.50 CZK
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
billing *BillingService
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
s := &Service{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
baseURL: strings.TrimRight(cfg.SMSManagerBaseURL, "/"),
|
||||
apiKey: cfg.SMSManagerAPIKey,
|
||||
}
|
||||
if cfg.StripeConfigured() && cfg.StripeSMSConfigured() {
|
||||
s.billing = NewBillingService(cfg, repo)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) Enabled() bool {
|
||||
return s.cfg.SMSConfigured()
|
||||
}
|
||||
|
||||
func (s *Service) IsAvailable(ctx context.Context, tenantID string) (bool, error) {
|
||||
if !s.Enabled() {
|
||||
return false, nil
|
||||
}
|
||||
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return settings.Enabled, nil
|
||||
}
|
||||
|
||||
func (s *Service) canUseSMS(ctx context.Context, tenantID string) error {
|
||||
if !s.Enabled() {
|
||||
return ErrSMSNotConfigured
|
||||
}
|
||||
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plan := shared.NormalizePlanCode(tenant.PlanCode)
|
||||
if plan != "pro" && plan != "business" {
|
||||
return ErrSMSPlanNotAllowed
|
||||
}
|
||||
|
||||
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
|
||||
return ErrNoActiveSubscription
|
||||
}
|
||||
|
||||
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return ErrSMSNotEnabled
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetSettings(ctx context.Context, tenantID string) (domain.SMSSettings, error) {
|
||||
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||
if err != nil {
|
||||
return domain.SMSSettings{}, err
|
||||
}
|
||||
|
||||
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
|
||||
if err != nil {
|
||||
return domain.SMSSettings{}, err
|
||||
}
|
||||
|
||||
return domain.SMSSettings{
|
||||
Enabled: settings.Enabled,
|
||||
SenderName: settings.SenderName,
|
||||
MonthlyLimit: settings.MonthlyLimit,
|
||||
MessagesSent: usage.MessageCount,
|
||||
TotalCostCents: usage.TotalCostCents,
|
||||
Available: s.Enabled(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateSettings(ctx context.Context, tenantID string, req domain.UpdateSMSSettingsRequest) (domain.SMSSettings, error) {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return domain.SMSSettings{}, err
|
||||
}
|
||||
|
||||
plan := shared.NormalizePlanCode(tenant.PlanCode)
|
||||
if plan != "pro" && plan != "business" {
|
||||
return domain.SMSSettings{}, ErrSMSPlanNotAllowed
|
||||
}
|
||||
|
||||
if tenant.SubscriptionStatus != "active" && tenant.SubscriptionStatus != "trialing" {
|
||||
return domain.SMSSettings{}, ErrNoActiveSubscription
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{
|
||||
TenantID: tenantID,
|
||||
Enabled: req.Enabled,
|
||||
SenderName: strings.TrimSpace(req.SenderName),
|
||||
MonthlyLimit: req.MonthlyLimit,
|
||||
}); err != nil {
|
||||
return domain.SMSSettings{}, err
|
||||
}
|
||||
|
||||
return s.GetSettings(ctx, tenantID)
|
||||
}
|
||||
|
||||
func (s *Service) SendMessage(ctx context.Context, tenantID string, req domain.SendSMSRequest) (domain.SendSMSResponse, error) {
|
||||
if err := s.canUseSMS(ctx, tenantID); err != nil {
|
||||
return domain.SendSMSResponse{}, err
|
||||
}
|
||||
|
||||
phone := normalizePhone(req.To)
|
||||
if phone == "" {
|
||||
return domain.SendSMSResponse{}, ErrSMSInvalidPhone
|
||||
}
|
||||
|
||||
// Check monthly limit
|
||||
settings, err := s.repo.GetTenantSMSSettings(ctx, tenantID)
|
||||
if err != nil {
|
||||
return domain.SendSMSResponse{}, err
|
||||
}
|
||||
if settings.MonthlyLimit > 0 {
|
||||
usage, err := s.repo.GetSMSUsageThisMonth(ctx, tenantID)
|
||||
if err != nil {
|
||||
return domain.SendSMSResponse{}, err
|
||||
}
|
||||
if usage.MessageCount >= settings.MonthlyLimit {
|
||||
return domain.SendSMSResponse{}, ErrSMSLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
// Send via SMS Manager
|
||||
resp, err := s.sendToSMSManager(ctx, phone, req.Body, settings.SenderName)
|
||||
if err != nil {
|
||||
return domain.SendSMSResponse{}, err
|
||||
}
|
||||
|
||||
// Extract message ID from accepted recipients
|
||||
messageID := ""
|
||||
if len(resp.Accepted) > 0 {
|
||||
messageID = resp.Accepted[0].MessageID
|
||||
}
|
||||
|
||||
// Log usage locally (Stripe billing happens once at month-end)
|
||||
logID, err := s.repo.CreateSMSUsageLog(ctx, db.SMSUsageLogRecord{
|
||||
TenantID: tenantID,
|
||||
RecipientPhone: phone,
|
||||
MessageBody: req.Body,
|
||||
ExternalMessageID: messageID,
|
||||
ExternalRequestID: resp.RequestID,
|
||||
Status: "sent",
|
||||
CostCents: smsCostCents,
|
||||
SentAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.SendSMSResponse{}, fmt.Errorf("failed to log sms usage: %w", err)
|
||||
}
|
||||
|
||||
return domain.SendSMSResponse{
|
||||
LogID: logID,
|
||||
MessageID: messageID,
|
||||
RequestID: resp.RequestID,
|
||||
Status: "sent",
|
||||
CostCents: smsCostCents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUsage(ctx context.Context, tenantID string, yearMonth string) (domain.SMSUsageReport, error) {
|
||||
report, err := s.repo.GetSMSUsageForMonth(ctx, tenantID, yearMonth)
|
||||
if err != nil {
|
||||
return domain.SMSUsageReport{}, err
|
||||
}
|
||||
return domain.SMSUsageReport{
|
||||
YearMonth: report.YearMonth,
|
||||
MessageCount: report.MessageCount,
|
||||
TotalCostCents: report.TotalCostCents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUsageHistory(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
records, err := s.repo.ListSMSUsageLogs(ctx, tenantID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logs := make([]domain.SMSUsageLog, len(records))
|
||||
for i, r := range records {
|
||||
logs[i] = domain.SMSUsageLog{
|
||||
ID: r.ID,
|
||||
RecipientPhone: r.RecipientPhone,
|
||||
MessageBody: r.MessageBody,
|
||||
Status: r.Status,
|
||||
CostCents: r.CostCents,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetMonthlyReports(ctx context.Context, tenantID string, limit int) ([]domain.SMSUsageReport, error) {
|
||||
if limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
records, err := s.repo.ListSMSMonthlyReports(ctx, tenantID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reports := make([]domain.SMSUsageReport, len(records))
|
||||
for i, r := range records {
|
||||
reports[i] = domain.SMSUsageReport{
|
||||
YearMonth: r.YearMonth,
|
||||
MessageCount: r.MessageCount,
|
||||
TotalCostCents: r.TotalCostCents,
|
||||
StripeInvoiceID: r.StripeInvoiceID,
|
||||
InvoiceSentAt: r.InvoiceSentAt,
|
||||
}
|
||||
}
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
// GenerateMonthlyInvoices creates/finalizes monthly invoices for SMS usage
|
||||
func (s *Service) GenerateMonthlyInvoices(ctx context.Context, yearMonth string, notificationSvc *notifications.Service) (domain.SMSInvoiceBatchResponse, error) {
|
||||
if yearMonth == "" {
|
||||
now := time.Now().UTC()
|
||||
yearMonth = fmt.Sprintf("%04d-%02d", now.Year(), now.Month())
|
||||
}
|
||||
|
||||
response := domain.SMSInvoiceBatchResponse{YearMonth: yearMonth}
|
||||
|
||||
// Get all tenants with SMS enabled that have usage this month
|
||||
tenants, err := s.repo.ListTenantsWithSMSUsage(ctx, yearMonth)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
for _, t := range tenants {
|
||||
report, err := s.repo.GetSMSUsageForMonth(ctx, t.ID, yearMonth)
|
||||
if err != nil {
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if report.MessageCount == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create Stripe InvoiceItem for total monthly SMS usage
|
||||
// This adds one line to the customer's next invoice: all messages together
|
||||
stripeInvoiceItemID := ""
|
||||
if s.billing != nil {
|
||||
customerID := ""
|
||||
if t.BillingCustomerID != nil {
|
||||
customerID = *t.BillingCustomerID
|
||||
}
|
||||
if customerID != "" {
|
||||
currency := "czk"
|
||||
if t.BillingProvider != "" {
|
||||
// Try to infer currency from billing snapshot if available
|
||||
snap, _ := s.repo.GetSubscriptionSnapshot(ctx, t.ID)
|
||||
if snap.Currency != "" {
|
||||
currency = snap.Currency
|
||||
}
|
||||
}
|
||||
itemID, err := s.billing.CreateMonthlyInvoiceItem(ctx, customerID, currency, yearMonth, report.MessageCount, report.TotalCostCents)
|
||||
if err == nil {
|
||||
stripeInvoiceItemID = itemID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertSMSMonthlyReport(ctx, db.SMSMonthlyReportRecord{
|
||||
TenantID: t.ID,
|
||||
YearMonth: yearMonth,
|
||||
MessageCount: report.MessageCount,
|
||||
TotalCostCents: report.TotalCostCents,
|
||||
StripeInvoiceID: stripeInvoiceItemID,
|
||||
}); err != nil {
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Send usage summary email
|
||||
if notificationSvc != nil {
|
||||
_ = s.sendUsageSummaryEmail(ctx, t, report, notificationSvc)
|
||||
}
|
||||
|
||||
response.ProcessedCount++
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) sendUsageSummaryEmail(ctx context.Context, tenant db.TenantRecord, report db.SMSMonthlyReportRecord, svc *notifications.Service) error {
|
||||
// Get brand profile for email styling
|
||||
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||
|
||||
data := notifications.SMSUsageEmailData{
|
||||
TenantName: brand.Name,
|
||||
TenantSlug: tenant.Slug,
|
||||
BusinessEmail: "",
|
||||
YearMonth: report.YearMonth,
|
||||
MessageCount: report.MessageCount,
|
||||
TotalCostCents: report.TotalCostCents,
|
||||
Locale: tenant.Locale,
|
||||
}
|
||||
|
||||
// Find owner email
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, tenant.ID)
|
||||
if err == nil {
|
||||
user, err := s.repo.GetUserByID(ctx, membership.UserID)
|
||||
if err == nil {
|
||||
data.BusinessEmail = user.Email
|
||||
}
|
||||
}
|
||||
|
||||
if data.BusinessEmail == "" {
|
||||
return errors.New("no business email found")
|
||||
}
|
||||
|
||||
msg := notifications.RenderSMSUsageEmail(data)
|
||||
_, err = svc.SendRawEmail(ctx, msg)
|
||||
if err == nil {
|
||||
_ = s.repo.MarkSMSReportInvoiceSent(ctx, report.TenantID, report.YearMonth)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// --- SMS Manager API client ---
|
||||
|
||||
type smsManagerMessage struct {
|
||||
Body string `json:"body"`
|
||||
To []struct {
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
} `json:"to"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
type smsManagerResponse struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Accepted []struct {
|
||||
Key string `json:"key"`
|
||||
MessageID string `json:"message_id"`
|
||||
} `json:"accepted"`
|
||||
Rejected []struct {
|
||||
Key string `json:"key"`
|
||||
Message string `json:"message,omitempty"`
|
||||
} `json:"rejected"`
|
||||
}
|
||||
|
||||
func (s *Service) sendToSMSManager(ctx context.Context, phone, body, senderName string) (smsManagerResponse, error) {
|
||||
if s.apiKey == "" {
|
||||
return smsManagerResponse{}, ErrSMSMissingAPIKey
|
||||
}
|
||||
|
||||
payload := smsManagerMessage{
|
||||
Body: body,
|
||||
To: []struct {
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}{{PhoneNumber: phone}},
|
||||
Tag: "transactional",
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return smsManagerResponse{}, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/message", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return smsManagerResponse{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", s.apiKey)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return smsManagerResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return smsManagerResponse{}, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return smsManagerResponse{}, fmt.Errorf("%w: status=%d body=%s", ErrSMSSendFailed, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result smsManagerResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return smsManagerResponse{}, err
|
||||
}
|
||||
|
||||
if len(result.Rejected) > 0 && len(result.Accepted) == 0 {
|
||||
return result, fmt.Errorf("%w: %v", ErrSMSSendFailed, result.Rejected)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
p := strings.TrimSpace(phone)
|
||||
p = strings.ReplaceAll(p, " ", "")
|
||||
p = strings.ReplaceAll(p, "-", "")
|
||||
p = strings.TrimPrefix(p, "+")
|
||||
p = strings.TrimPrefix(p, "00")
|
||||
// Czech default if no country code and 9 digits
|
||||
if len(p) == 9 {
|
||||
p = "420" + p
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestSMSServiceEnabled(t *testing.T) {
|
||||
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||
svc := NewService(cfg, db.NewMemoryRepository())
|
||||
if !svc.Enabled() {
|
||||
t.Fatal("expected SMS service to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSServiceDisabledWithoutKey(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
svc := NewService(cfg, db.NewMemoryRepository())
|
||||
if svc.Enabled() {
|
||||
t.Fatal("expected SMS service to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettingsForNewTenant(t *testing.T) {
|
||||
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(cfg, repo)
|
||||
|
||||
ctx := context.Background()
|
||||
// Use the default tenant ID from memory repository
|
||||
settings, err := svc.GetSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if settings.Enabled {
|
||||
t.Fatal("expected SMS to be disabled by default")
|
||||
}
|
||||
if !settings.Available {
|
||||
t.Fatal("expected SMS to be available when configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRequiresProOrBusiness(t *testing.T) {
|
||||
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(cfg, repo)
|
||||
|
||||
ctx := context.Background()
|
||||
// Memory repo tenant is "pro" by default. Change to starter by modifying tenant.
|
||||
_, err := svc.UpdateSettings(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.UpdateSMSSettingsRequest{Enabled: true})
|
||||
// The memory repo tenant is "pro", so this should succeed
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for pro tenant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageRequiresEnabledSMS(t *testing.T) {
|
||||
cfg := config.Config{SMSManagerAPIKey: "test-key"}
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(cfg, repo)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := svc.SendMessage(ctx, "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", domain.SendSMSRequest{To: "+420777123456", Body: "Hello"})
|
||||
if err != ErrSMSNotEnabled {
|
||||
t.Fatalf("expected ErrSMSNotEnabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhone(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"+420 777 123 456", "420777123456"},
|
||||
{"420777123456", "420777123456"},
|
||||
{"777123456", "420777123456"},
|
||||
{" 777-123-456 ", "420777123456"},
|
||||
{"+49 151 12345678", "4915112345678"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := normalizePhone(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("normalizePhone(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) {
|
||||
return s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
}
|
||||
|
||||
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS tenant_sms_settings (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
sender_name text NOT NULL DEFAULT '',
|
||||
monthly_limit integer,
|
||||
stripe_subscription_item_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_usage_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
recipient_phone text NOT NULL,
|
||||
message_body text NOT NULL,
|
||||
external_message_id text,
|
||||
external_request_id text,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
cost_cents integer NOT NULL DEFAULT 150,
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_monthly_reports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
year_month text NOT NULL,
|
||||
message_count integer NOT NULL DEFAULT 0,
|
||||
total_cost_cents integer NOT NULL DEFAULT 0,
|
||||
stripe_invoice_id text,
|
||||
invoice_sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, year_month)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_month ON sms_usage_logs (tenant_id, date_trunc('month', created_at));
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_usage_tenant_created ON sms_usage_logs (tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_reports_tenant ON sms_monthly_reports (tenant_id, year_month DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_sms_reports_tenant;
|
||||
DROP INDEX IF EXISTS idx_sms_usage_tenant_created;
|
||||
DROP INDEX IF EXISTS idx_sms_usage_tenant_month;
|
||||
DROP TABLE IF EXISTS sms_monthly_reports;
|
||||
DROP TABLE IF EXISTS sms_usage_logs;
|
||||
DROP TABLE IF EXISTS tenant_sms_settings;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS customer_phone text;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE bookings DROP COLUMN IF EXISTS customer_phone;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createSignal, createMemo, Show, For } from "solid-js";
|
||||
import { useI18n } from "../../providers/i18n-provider";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "./icons";
|
||||
|
||||
export function CalendarView(props: { bookings: any[]; locale?: string; onBookingClick?: (b: any) => void }) {
|
||||
const i18n = useI18n();
|
||||
const [currentDate, setCurrentDate] = createSignal(new Date());
|
||||
const [selectedDay, setSelectedDay] = createSignal<number | null>(null);
|
||||
const [modalDay, setModalDay] = createSignal<number | null>(null);
|
||||
const [isAnimating, setIsAnimating] = createSignal(false);
|
||||
const isCs = () => props.locale === "cs";
|
||||
|
||||
const weekDays = isCs()
|
||||
? ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"]
|
||||
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const monthYear = createMemo(() =>
|
||||
new Intl.DateTimeFormat(isCs() ? "cs-CZ" : "en-US", { month: "long", year: "numeric" }).format(currentDate())
|
||||
);
|
||||
|
||||
const calendarDays = createMemo(() => {
|
||||
const date = currentDate();
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
// Adjust so Monday = 0
|
||||
const firstDay = (firstDayOfMonth + 6) % 7;
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < firstDay; i++) days.push({ day: null, bookings: [], isToday: false });
|
||||
|
||||
const today = new Date();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayBookings = props.bookings.filter((b) => {
|
||||
const bd = new Date(b.startsAt);
|
||||
return bd.getDate() === day && bd.getMonth() === month && bd.getFullYear() === year;
|
||||
});
|
||||
const isToday = today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
|
||||
days.push({ day, bookings: dayBookings, isToday });
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
const changeMonth = (direction: number) => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
|
||||
setIsAnimating(false);
|
||||
setSelectedDay(null);
|
||||
setModalDay(null);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleDayClick = (day: number | null) => {
|
||||
if (!day) return;
|
||||
const dayData = calendarDays().find((d) => d.day === day);
|
||||
if (dayData && dayData.bookings.length > 0) {
|
||||
setModalDay(day);
|
||||
} else {
|
||||
setSelectedDay(selectedDay() === day ? null : day);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookingClick = (booking: any) => {
|
||||
props.onBookingClick?.(booking);
|
||||
setModalDay(null);
|
||||
};
|
||||
|
||||
const modalBookings = createMemo(() => {
|
||||
const day = modalDay();
|
||||
if (!day) return [];
|
||||
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="surface-card p-6 shadow-sm hover:shadow-md transition-all duration-500">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-ink">{monthYear()}</h3>
|
||||
<p class="text-sm text-ink-muted mt-0.5">
|
||||
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
aria-label={i18n.t("dashboard.prevMonth")}
|
||||
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
aria-label={i18n.t("dashboard.nextMonth")}
|
||||
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||
{weekDays.map((day) => (
|
||||
<div class="text-center text-[10px] sm:text-xs font-semibold text-ink-subtle uppercase tracking-wider py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class={`grid grid-cols-7 gap-1 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
|
||||
{calendarDays().map(({ day, bookings, isToday }, index) => (
|
||||
<button
|
||||
onClick={() => handleDayClick(day)}
|
||||
class={`
|
||||
aspect-square p-1 sm:p-1.5 rounded-xl border transition-all duration-200 relative overflow-hidden
|
||||
${day ? "bg-canvas border-border/60 hover:border-accent/40 hover:shadow-sm" : "bg-transparent border-transparent pointer-events-none"}
|
||||
${isToday ? "ring-1 ring-accent bg-accent-subtle/40" : ""}
|
||||
${selectedDay() === day ? "ring-2 ring-accent ring-offset-1 ring-offset-canvas" : ""}
|
||||
${bookings.length > 0 && !isToday ? "bg-accent-subtle/20" : ""}
|
||||
`}
|
||||
style={{ "animation-delay": `${index * 15}ms` }}
|
||||
disabled={!day}
|
||||
>
|
||||
{day && (
|
||||
<>
|
||||
<span class={`text-xs sm:text-sm font-medium ${isToday ? "text-accent" : "text-ink"}`}>{day}</span>
|
||||
{bookings.length > 0 && (
|
||||
<div class="absolute bottom-1 left-1 right-1 flex gap-0.5 justify-center">
|
||||
{bookings.slice(0, 3).map((b: any) => (
|
||||
<span
|
||||
class={`w-1 h-1 rounded-full ${
|
||||
b.status === "confirmed" ? "bg-ink/60" : b.status === "pending" ? "bg-ink/40" : "bg-ink/20"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{bookings.length > 3 && (
|
||||
<span class="text-[7px] font-bold text-ink/50 leading-none">+</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Inline selected day preview for empty days */}
|
||||
<Show when={selectedDay() && !modalDay()}>
|
||||
<div class="mt-4 p-4 bg-canvas-subtle/50 rounded-xl border border-border/60 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-ink">
|
||||
{selectedDay()}. {monthYear()}
|
||||
</span>
|
||||
<button onClick={() => setSelectedDay(null)} class="text-xs text-ink-muted hover:text-ink">
|
||||
{i18n.t("dashboard.close")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{i18n.t("dashboard.calendar.noBookings")}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Day bookings modal */}
|
||||
<Show when={modalDay()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={() => setModalDay(null)} />
|
||||
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden animate-scale-in">
|
||||
<div class="p-5 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-ink">
|
||||
{modalDay()}. {monthYear()}
|
||||
</h3>
|
||||
<p class="text-xs text-ink-muted mt-0.5">
|
||||
{modalBookings().length} {isCs() ? "rezervací" : "bookings"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModalDay(null)}
|
||||
class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"
|
||||
aria-label={i18n.t("dashboard.close")}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 overflow-y-auto max-h-[60vh] space-y-3">
|
||||
<For each={modalBookings()}>
|
||||
{(booking) => (
|
||||
<button
|
||||
onClick={() => handleBookingClick(booking)}
|
||||
class="w-full text-left flex items-center gap-3 p-3 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all"
|
||||
>
|
||||
<div
|
||||
class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
|
||||
booking.status === "confirmed"
|
||||
? "bg-accent text-canvas"
|
||||
: booking.status === "pending"
|
||||
? "bg-canvas-muted text-ink"
|
||||
: "bg-canvas-subtle text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{(booking.customerName ?? "?")
|
||||
.split(" ")
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-ink truncate">{booking.customerName}</p>
|
||||
<p class="text-xs text-ink-muted">{booking.service}</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-xs font-medium text-ink">
|
||||
{new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
<span
|
||||
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
booking.status === "confirmed"
|
||||
? "bg-accent/10 text-accent"
|
||||
: booking.status === "pending"
|
||||
? "bg-canvas-muted text-ink-muted"
|
||||
: "bg-canvas-subtle text-ink-subtle"
|
||||
}`}
|
||||
>
|
||||
{i18n.t(`dashboard.${booking.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -176,3 +176,43 @@ export const MailIcon = () => (
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EyeIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EditIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Trash2Icon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const XCircleIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MoreHorizontalIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||
);
|
||||
|
||||
export const BarChart3Icon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
|
||||
);
|
||||
|
||||
export const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
);
|
||||
|
||||
export const GlobeIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createSignal, createMemo, Show, For } from "solid-js";
|
||||
import { useI18n } from "../../providers/i18n-provider";
|
||||
import { BellIcon } from "./icons";
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
type: "booking" | "reminder" | "upgrade" | "trial" | "system";
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) {
|
||||
const i18n = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const notifications = createMemo<NotificationItem[]>(() => {
|
||||
const items: NotificationItem[] = [];
|
||||
const cs = i18n.locale() === "cs";
|
||||
const bookings = props.bookings ?? [];
|
||||
|
||||
// Recent bookings as notifications
|
||||
bookings.slice(0, 3).forEach((b: any, i: number) => {
|
||||
items.push({
|
||||
id: `booking-${i}`,
|
||||
type: "booking",
|
||||
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
|
||||
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString()}`,
|
||||
time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
|
||||
read: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Plan/trial notifications
|
||||
const billing = props.billing;
|
||||
if (billing?.subscriptionStatus === "trialing") {
|
||||
const daysLeft = billing?.trialDaysRemaining ?? 3;
|
||||
items.push({
|
||||
id: "trial",
|
||||
type: "trial",
|
||||
title: cs ? "Zkušební doba končí" : "Trial ending",
|
||||
message: cs ? `Zbývá ${daysLeft} dní` : `${daysLeft} days remaining`,
|
||||
time: "",
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
items.push({
|
||||
id: "welcome",
|
||||
type: "system",
|
||||
title: cs ? "Vítejte v Bookra" : "Welcome to Bookra",
|
||||
message: cs ? "Začněte vytvořením první rezervace." : "Start by creating your first booking.",
|
||||
time: "",
|
||||
read: true,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const [readIds, setReadIds] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const filteredNotifications = createMemo(() =>
|
||||
notifications().map((n) => ({ ...n, read: n.read || readIds().has(n.id) }))
|
||||
);
|
||||
|
||||
const unreadCount = createMemo(() => filteredNotifications().filter((n) => !n.read).length);
|
||||
|
||||
const markAllRead = () => {
|
||||
setReadIds(new Set(filteredNotifications().map((n) => n.id)));
|
||||
};
|
||||
|
||||
const markRead = (id: string) => {
|
||||
setReadIds((prev) => { const next = new Set(prev); next.add(id); return next; });
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
const base = "w-8 h-8 rounded-full flex items-center justify-center shrink-0";
|
||||
switch (type) {
|
||||
case "booking":
|
||||
return (
|
||||
<div class={`${base} bg-accent-subtle text-accent`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
</div>
|
||||
);
|
||||
case "reminder":
|
||||
return (
|
||||
<div class={`${base} bg-canvas-muted text-ink-muted`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
);
|
||||
case "upgrade":
|
||||
case "trial":
|
||||
return (
|
||||
<div class={`${base} bg-canvas-muted text-ink-muted`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div class={`${base} bg-canvas-muted text-ink-muted`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open())}
|
||||
class="p-2 text-ink-subtle hover:text-ink hover:bg-canvas-subtle rounded-xl transition-all relative"
|
||||
aria-label={i18n.t("dashboard.notifications")}
|
||||
>
|
||||
<BellIcon />
|
||||
<Show when={unreadCount() > 0}>
|
||||
<span class="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full" />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={open()}>
|
||||
<div class="absolute right-0 top-full mt-2 w-80 bg-canvas rounded-2xl shadow-xl border border-border z-50 overflow-hidden animate-scale-in">
|
||||
<div class="p-4 border-b border-border flex items-center justify-between">
|
||||
<h3 class="font-semibold text-ink">{i18n.t("dashboard.notifications.title")}</h3>
|
||||
<Show when={unreadCount() > 0}>
|
||||
<button onClick={markAllRead} class="text-xs text-accent hover:text-accent-hover font-medium">
|
||||
{i18n.t("dashboard.markAllRead")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<Show
|
||||
when={filteredNotifications().length > 0}
|
||||
fallback={
|
||||
<div class="p-6 text-center text-sm text-ink-muted">
|
||||
{i18n.t("dashboard.noNotifications")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredNotifications()}>
|
||||
{(n) => (
|
||||
<button
|
||||
onClick={() => markRead(n.id)}
|
||||
class={`w-full text-left flex items-start gap-3 p-4 hover:bg-canvas-subtle/50 transition-colors border-b border-border/50 last:border-0 ${n.read ? "opacity-60" : ""}`}
|
||||
>
|
||||
{typeIcon(n.type)}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-ink truncate">{n.title}</p>
|
||||
<p class="text-xs text-ink-muted mt-0.5">{n.message}</p>
|
||||
<Show when={n.time}>
|
||||
<p class="text-[10px] text-ink-subtle mt-1">{n.time}</p>
|
||||
</Show>
|
||||
</div>
|
||||
{!n.read && <span class="w-2 h-2 rounded-full bg-accent shrink-0 mt-1" />}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createMemo } from "solid-js";
|
||||
import { useI18n } from "../../providers/i18n-provider";
|
||||
|
||||
interface DataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
|
||||
const i18n = useI18n();
|
||||
|
||||
const chartData = createMemo(() => {
|
||||
if (props.data && props.data.length > 0) return props.data;
|
||||
|
||||
// Build last 7 days from bookings
|
||||
const days: DataPoint[] = [];
|
||||
const bookings = props.bookings ?? [];
|
||||
const today = new Date();
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().split("T")[0];
|
||||
const count = bookings.filter((b: any) => {
|
||||
const bd = new Date(b.startsAt).toISOString().split("T")[0];
|
||||
return bd === dateStr;
|
||||
}).length;
|
||||
days.push({
|
||||
label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }),
|
||||
value: count,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
const maxValue = createMemo(() => Math.max(1, ...chartData().map((d) => d.value)));
|
||||
const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0));
|
||||
|
||||
return (
|
||||
<div class="surface-card p-6">
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3>
|
||||
<p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold text-ink tracking-tight">{total()}</p>
|
||||
<p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2 sm:gap-3 h-40">
|
||||
{chartData().map((point, i) => {
|
||||
const heightPct = Math.round((point.value / maxValue()) * 100);
|
||||
return (
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full flex-1 flex items-end">
|
||||
<div
|
||||
class="w-full rounded-t-md bg-accent/80 hover:bg-accent transition-all duration-300 relative group"
|
||||
style={{ height: `${Math.max(heightPct, 4)}%` }}
|
||||
>
|
||||
<div class="absolute -top-7 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-md bg-ink text-canvas text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
{point.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
|
||||
import { apiClient } from "../lib/api-client";
|
||||
import { Input } from "./ui/input";
|
||||
import { useI18n } from "../providers/i18n-provider";
|
||||
|
||||
interface SMSSettingsData {
|
||||
enabled: boolean;
|
||||
senderName: string;
|
||||
monthlyLimit: number;
|
||||
messagesSent: number;
|
||||
totalCostCents: number;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
interface SMSReport {
|
||||
yearMonth: string;
|
||||
messageCount: number;
|
||||
totalCostCents: number;
|
||||
stripeInvoiceId?: string;
|
||||
invoiceSentAt?: string;
|
||||
}
|
||||
|
||||
interface SMSLog {
|
||||
id: string;
|
||||
recipientPhone: string;
|
||||
status: string;
|
||||
costCents: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function formatCents(cents: number) {
|
||||
return `${(cents / 100).toFixed(2)} Kč`;
|
||||
}
|
||||
|
||||
function formatMonth(yearMonth: string) {
|
||||
const [y, m] = yearMonth.split("-");
|
||||
return `${m}/${y}`;
|
||||
}
|
||||
|
||||
interface SMSSettingsProps {
|
||||
token: Accessor<string | null | undefined>;
|
||||
}
|
||||
|
||||
export function SMSSettings(props: SMSSettingsProps) {
|
||||
const i18n = useI18n();
|
||||
const token = props.token;
|
||||
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [notice, setNotice] = createSignal<string | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
const [settings, { refetch: refetchSettings }] = createResource(token, async (bearer) => {
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
return {
|
||||
enabled: false,
|
||||
senderName: "",
|
||||
monthlyLimit: 0,
|
||||
messagesSent: 0,
|
||||
totalCostCents: 0,
|
||||
available: true,
|
||||
} as SMSSettingsData;
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/sms/settings", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
});
|
||||
return response.data as SMSSettingsData;
|
||||
});
|
||||
|
||||
const [reports] = createResource(token, async (bearer) => {
|
||||
if (!bearer || bearer.startsWith("demo.")) return [] as SMSReport[];
|
||||
const response = await (apiClient as any).GET("/v1/sms/invoices", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
});
|
||||
return (response.data as any)?.reports ?? [];
|
||||
});
|
||||
|
||||
const [logs] = createResource(token, async (bearer) => {
|
||||
if (!bearer || bearer.startsWith("demo.")) return [] as SMSLog[];
|
||||
const response = await (apiClient as any).GET("/v1/sms/history", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
});
|
||||
return (response.data as any)?.logs ?? [];
|
||||
});
|
||||
|
||||
const handleToggle = async () => {
|
||||
const current = settings();
|
||||
if (!current) return;
|
||||
const bearer = token();
|
||||
if (!bearer) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/sms/settings", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: {
|
||||
enabled: !current.enabled,
|
||||
senderName: current.senderName,
|
||||
monthlyLimit: current.monthlyLimit,
|
||||
},
|
||||
});
|
||||
await refetchSettings();
|
||||
setNotice(
|
||||
i18n.locale() === "cs"
|
||||
? `SMS ${!current.enabled ? "aktivováno" : "deaktivováno"}`
|
||||
: `SMS ${!current.enabled ? "enabled" : "disabled"}`
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.error ||
|
||||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const current = settings();
|
||||
if (!current) return;
|
||||
const bearer = token();
|
||||
if (!bearer) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/sms/settings", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: {
|
||||
enabled: current.enabled,
|
||||
senderName: current.senderName,
|
||||
monthlyLimit: current.monthlyLimit,
|
||||
},
|
||||
});
|
||||
await refetchSettings();
|
||||
setNotice(i18n.locale() === "cs" ? "Nastavení uloženo." : "Settings saved.");
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.error ||
|
||||
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cs = () => i18n.locale() === "cs";
|
||||
|
||||
return (
|
||||
<div class="surface-card p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-ink">{cs() ? "SMS zprávy" : "SMS Messages"}</h3>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{cs()
|
||||
? "1.50 Kč / SMS. Fakturováno měsíčně přes Stripe."
|
||||
: "1.50 CZK / SMS. Billed monthly via Stripe."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!settings.loading} fallback={<div class="text-ink-muted">{cs() ? "Načítání..." : "Loading..."}</div>}>
|
||||
<Show when={settings()?.available === false}>
|
||||
<div class="p-4 bg-canvas-subtle rounded-xl text-ink-muted text-sm">
|
||||
{cs()
|
||||
? "SMS není v této instanci nakonfigurováno. Kontaktujte administrátora."
|
||||
: "SMS is not configured for this instance. Contact your administrator."}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={settings()?.available === true}>
|
||||
<Show when={notice()}>
|
||||
<div class="mb-4 p-3 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{notice()}</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<div class="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error()}</div>
|
||||
</Show>
|
||||
|
||||
{/* Toggle */}
|
||||
<div class="flex items-center justify-between mb-6 p-4 bg-canvas-subtle rounded-xl">
|
||||
<div>
|
||||
<p class="font-medium text-ink">{cs() ? "SMS odesílání" : "SMS sending"}</p>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{settings()?.enabled
|
||||
? (cs() ? "Aktivní — účtováno za každou zprávu" : "Active — charged per message")
|
||||
: (cs() ? "Neaktivní" : "Inactive")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={saving()}
|
||||
class={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
|
||||
settings()?.enabled ? "bg-accent" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
settings()?.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={settings()?.enabled}>
|
||||
<form onSubmit={handleSaveSettings} class="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
label={cs() ? "Jméno odesílatele (max 11 znaků)" : "Sender name (max 11 chars)"}
|
||||
value={settings()?.senderName || ""}
|
||||
onInput={(e) => {
|
||||
const s = settings();
|
||||
if (s) s.senderName = e.currentTarget.value;
|
||||
}}
|
||||
maxLength={11}
|
||||
placeholder="Bookra"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label={cs() ? "Měsíční limit (0 = bez limitu)" : "Monthly limit (0 = unlimited)"}
|
||||
value={settings()?.monthlyLimit || 0}
|
||||
onInput={(e) => {
|
||||
const s = settings();
|
||||
if (s) s.monthlyLimit = parseInt(e.currentTarget.value) || 0;
|
||||
}}
|
||||
min={0}
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving()}
|
||||
class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving() ? (cs() ? "Ukládání..." : "Saving...") : cs() ? "Uložit" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Current month stats */}
|
||||
<div class="mt-6 p-4 bg-canvas-subtle rounded-xl">
|
||||
<h4 class="text-sm font-medium text-ink mb-3">
|
||||
{cs() ? "Aktuální měsíc" : "Current month"}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-ink">{settings()?.messagesSent ?? 0}</p>
|
||||
<p class="text-xs text-ink-muted">{cs() ? "Odeslaných zpráv" : "Messages sent"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-ink">{formatCents(settings()?.totalCostCents ?? 0)}</p>
|
||||
<p class="text-xs text-ink-muted">{cs() ? "Celková cena" : "Total cost"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent logs */}
|
||||
<Show when={logs() && logs()!.length > 0}>
|
||||
<div class="mt-6">
|
||||
<h4 class="text-sm font-medium text-ink mb-3">
|
||||
{cs() ? "Historie odesílání" : "Send history"}
|
||||
</h4>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<For each={logs()}>
|
||||
{(log) => (
|
||||
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-ink-muted">{log.recipientPhone}</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded-full text-xs ${
|
||||
log.status === "sent"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-ink-muted">{formatCents(log.costCents)}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Monthly invoice reports */}
|
||||
<Show when={reports() && reports()!.length > 0}>
|
||||
<div class="mt-6">
|
||||
<h4 class="text-sm font-medium text-ink mb-3">
|
||||
{cs() ? "Fakturační přehledy" : "Invoice reports"}
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<For each={reports()}>
|
||||
{(report) => (
|
||||
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-ink">{formatMonth(report.yearMonth)}</span>
|
||||
<span class="text-ink-muted ml-2">{report.messageCount} SMS</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-ink">{formatCents(report.totalCostCents)}</span>
|
||||
<Show when={report.stripeInvoiceId}>
|
||||
<span class="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
||||
Stripe
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -322,6 +322,88 @@ const dictionaries = {
|
||||
"dashboard.onboarding.timezone": "Časové pásmo",
|
||||
"dashboard.onboarding.submit": "Vytvořit prostor",
|
||||
"dashboard.onboarding.pending": "Vytvářím prostor...",
|
||||
"dashboard.revenueTitle": "Trend rezervací",
|
||||
"dashboard.revenueSubtitle": "Počet rezervací za posledních 7 dní",
|
||||
"dashboard.noNotifications": "Žádná nová oznámení",
|
||||
"dashboard.markAllRead": "Označit vše jako přečtené",
|
||||
"dashboard.notifications.title": "Oznámení",
|
||||
"dashboard.demoBanner": "Demo režim",
|
||||
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
||||
"dashboard.demoBannerCTA": "Vytvořit účet",
|
||||
"dashboard.tryDemo": "Vyzkoušet demo",
|
||||
"dashboard.language": "Jazyk",
|
||||
"dashboard.calendar.noBookings": "Tento den nemáte žádné rezervace.",
|
||||
"dashboard.calendar.bookingsCount": "rezervací",
|
||||
"dashboard.chart.bookingsTrend": "Rezervace",
|
||||
"dashboard.chart.revenueTrend": "Obrat",
|
||||
"dashboard.recentActivity.empty": "Zatím žádná aktivita. Rezervace se zobrazí zde.",
|
||||
"dashboard.filter.all": "Vše",
|
||||
"dashboard.filter.today": "Dnes",
|
||||
"dashboard.filter.week": "Týden",
|
||||
"dashboard.filter.month": "Měsíc",
|
||||
"dashboard.search.placeholder": "Hledat...",
|
||||
"dashboard.search.bookings": "Hledat podle jména, služby nebo reference...",
|
||||
"dashboard.search.customers": "Hledat podle jména, emailu nebo telefonu...",
|
||||
"dashboard.actions": "Akce",
|
||||
"dashboard.customer.email": "E-mail",
|
||||
"dashboard.customer.phone": "Telefon",
|
||||
"dashboard.customer.totalBookings": "Celkem rezervací",
|
||||
"dashboard.customer.lastVisit": "Poslední návštěva",
|
||||
"dashboard.customer.status": "Stav",
|
||||
"dashboard.customer.notes": "Poznámky",
|
||||
"dashboard.customer.noNotes": "Žádné poznámky",
|
||||
"dashboard.customer.bookings": "Rezervace zákazníka",
|
||||
"dashboard.customer.noBookings": "Žádné rezervace",
|
||||
"dashboard.zone.add": "Přidat zónu",
|
||||
"dashboard.zone.name": "Název",
|
||||
"dashboard.zone.type": "Typ",
|
||||
"dashboard.zone.capacity": "Kapacita",
|
||||
"dashboard.zone.limitReached": "Dosáhli jste limitu",
|
||||
"dashboard.zone.rooms": "Místnost",
|
||||
"dashboard.zone.private": "Soukromá místnost",
|
||||
"dashboard.zone.hall": "Sál",
|
||||
"dashboard.zone.blockedDays": "Blokované dny",
|
||||
"dashboard.zone.workingHours": "Pracovní doba",
|
||||
"dashboard.zone.open": "Otevřeno",
|
||||
"dashboard.zone.close": "Zavřeno",
|
||||
"dashboard.zone.addBlocked": "Přidat blokovaný den",
|
||||
"dashboard.zone.reason": "Důvod",
|
||||
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
|
||||
"dashboard.billing.planUsage": "Využití plánu",
|
||||
"dashboard.billing.locations": "Lokace",
|
||||
"dashboard.billing.bookings": "Rezervace",
|
||||
"dashboard.billing.staff": "Zaměstnanci",
|
||||
"dashboard.billing.period": "Období",
|
||||
"dashboard.billing.currentPlan": "Aktuální plán",
|
||||
"dashboard.billing.nextBilling": "Další fakturace",
|
||||
"dashboard.settings.businessInfo": "Informace o podniku",
|
||||
"dashboard.settings.branding": "Branding a vzhled",
|
||||
"dashboard.settings.emailNotifications": "E-mailová oznámení",
|
||||
"dashboard.settings.emailSubject": "Předmět",
|
||||
"dashboard.settings.emailBody": "Obsah",
|
||||
"dashboard.settings.save": "Uložit",
|
||||
"dashboard.bookingModal.customer": "Zákazník",
|
||||
"dashboard.bookingModal.service": "Služba",
|
||||
"dashboard.bookingModal.location": "Místo",
|
||||
"dashboard.bookingModal.dateTime": "Datum a čas",
|
||||
"dashboard.bookingModal.duration": "Délka",
|
||||
"dashboard.bookingModal.status": "Stav",
|
||||
"dashboard.bookingModal.reference": "Reference",
|
||||
"dashboard.bookingModal.notes": "Poznámky",
|
||||
"dashboard.bookingModal.reschedule": "Přeplánovat",
|
||||
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
|
||||
"dashboard.bookingModal.createdAt": "Vytvořeno",
|
||||
"dashboard.bookingModal.assignedTo": "Přiřazeno",
|
||||
"dashboard.bookingModal.noAssign": "Nepřiřazeno",
|
||||
"dashboard.bookingModal.email": "E-mail",
|
||||
"dashboard.bookingModal.phone": "Telefon",
|
||||
"dashboard.empty.title": "Zatím prázdné",
|
||||
"dashboard.empty.bookingsDesc": "Žádné rezervace neodpovídají vašemu filtru.",
|
||||
"dashboard.empty.customersDesc": "Zatím nemáte žádné zákazníky.",
|
||||
"dashboard.notification.newBooking": "Nová rezervace od",
|
||||
"dashboard.notification.reminder": "Připomínka: rezervace u",
|
||||
"dashboard.notification.upgrade": "Blížíte se limitu plánu",
|
||||
"dashboard.notification.trialEnding": "Vaše zkušební doba končí za 3 dny",
|
||||
"booking.title": "Rezervace",
|
||||
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
|
||||
"booking.slots": "Dostupné termíny",
|
||||
@@ -340,6 +422,7 @@ const dictionaries = {
|
||||
"booking.customer.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
|
||||
"booking.customer.name": "Jméno",
|
||||
"booking.customer.email": "E-mail",
|
||||
"booking.customer.phone": "Telefon",
|
||||
"booking.customer.notes": "Poznámka",
|
||||
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
||||
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
||||
@@ -719,6 +802,88 @@ const dictionaries = {
|
||||
"dashboard.onboarding.timezone": "Timezone",
|
||||
"dashboard.onboarding.submit": "Create workspace",
|
||||
"dashboard.onboarding.pending": "Creating workspace...",
|
||||
"dashboard.revenueTitle": "Booking trend",
|
||||
"dashboard.revenueSubtitle": "Bookings over the last 7 days",
|
||||
"dashboard.noNotifications": "No new notifications",
|
||||
"dashboard.markAllRead": "Mark all as read",
|
||||
"dashboard.notifications.title": "Notifications",
|
||||
"dashboard.demoBanner": "Demo mode",
|
||||
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
||||
"dashboard.demoBannerCTA": "Create account",
|
||||
"dashboard.tryDemo": "Try demo",
|
||||
"dashboard.language": "Language",
|
||||
"dashboard.calendar.noBookings": "No bookings for this day.",
|
||||
"dashboard.calendar.bookingsCount": "bookings",
|
||||
"dashboard.chart.bookingsTrend": "Bookings",
|
||||
"dashboard.chart.revenueTrend": "Revenue",
|
||||
"dashboard.recentActivity.empty": "No activity yet. Bookings will appear here.",
|
||||
"dashboard.filter.all": "All",
|
||||
"dashboard.filter.today": "Today",
|
||||
"dashboard.filter.week": "Week",
|
||||
"dashboard.filter.month": "Month",
|
||||
"dashboard.search.placeholder": "Search...",
|
||||
"dashboard.search.bookings": "Search by name, service or reference...",
|
||||
"dashboard.search.customers": "Search by name, email or phone...",
|
||||
"dashboard.actions": "Actions",
|
||||
"dashboard.customer.email": "Email",
|
||||
"dashboard.customer.phone": "Phone",
|
||||
"dashboard.customer.totalBookings": "Total bookings",
|
||||
"dashboard.customer.lastVisit": "Last visit",
|
||||
"dashboard.customer.status": "Status",
|
||||
"dashboard.customer.notes": "Notes",
|
||||
"dashboard.customer.noNotes": "No notes",
|
||||
"dashboard.customer.bookings": "Customer bookings",
|
||||
"dashboard.customer.noBookings": "No bookings",
|
||||
"dashboard.zone.add": "Add zone",
|
||||
"dashboard.zone.name": "Name",
|
||||
"dashboard.zone.type": "Type",
|
||||
"dashboard.zone.capacity": "Capacity",
|
||||
"dashboard.zone.limitReached": "Limit reached",
|
||||
"dashboard.zone.rooms": "Room",
|
||||
"dashboard.zone.private": "Private room",
|
||||
"dashboard.zone.hall": "Hall",
|
||||
"dashboard.zone.blockedDays": "Blocked days",
|
||||
"dashboard.zone.workingHours": "Working hours",
|
||||
"dashboard.zone.open": "Open",
|
||||
"dashboard.zone.close": "Close",
|
||||
"dashboard.zone.addBlocked": "Add blocked day",
|
||||
"dashboard.zone.reason": "Reason",
|
||||
"dashboard.zone.noZones": "No zones yet. Add your first one.",
|
||||
"dashboard.billing.planUsage": "Plan usage",
|
||||
"dashboard.billing.locations": "Locations",
|
||||
"dashboard.billing.bookings": "Bookings",
|
||||
"dashboard.billing.staff": "Staff",
|
||||
"dashboard.billing.period": "Period",
|
||||
"dashboard.billing.currentPlan": "Current plan",
|
||||
"dashboard.billing.nextBilling": "Next billing",
|
||||
"dashboard.settings.businessInfo": "Business information",
|
||||
"dashboard.settings.branding": "Branding & appearance",
|
||||
"dashboard.settings.emailNotifications": "Email notifications",
|
||||
"dashboard.settings.emailSubject": "Subject",
|
||||
"dashboard.settings.emailBody": "Body",
|
||||
"dashboard.settings.save": "Save",
|
||||
"dashboard.bookingModal.customer": "Customer",
|
||||
"dashboard.bookingModal.service": "Service",
|
||||
"dashboard.bookingModal.location": "Location",
|
||||
"dashboard.bookingModal.dateTime": "Date & time",
|
||||
"dashboard.bookingModal.duration": "Duration",
|
||||
"dashboard.bookingModal.status": "Status",
|
||||
"dashboard.bookingModal.reference": "Reference",
|
||||
"dashboard.bookingModal.notes": "Notes",
|
||||
"dashboard.bookingModal.reschedule": "Reschedule",
|
||||
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
|
||||
"dashboard.bookingModal.createdAt": "Created",
|
||||
"dashboard.bookingModal.assignedTo": "Assigned to",
|
||||
"dashboard.bookingModal.noAssign": "Not assigned",
|
||||
"dashboard.bookingModal.email": "Email",
|
||||
"dashboard.bookingModal.phone": "Phone",
|
||||
"dashboard.empty.title": "Nothing here yet",
|
||||
"dashboard.empty.bookingsDesc": "No bookings match your filter.",
|
||||
"dashboard.empty.customersDesc": "You don't have any customers yet.",
|
||||
"dashboard.notification.newBooking": "New booking from",
|
||||
"dashboard.notification.reminder": "Reminder: booking at",
|
||||
"dashboard.notification.upgrade": "You're nearing your plan limit",
|
||||
"dashboard.notification.trialEnding": "Your trial ends in 3 days",
|
||||
"booking.title": "Book a visit",
|
||||
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
|
||||
"booking.slots": "Available times",
|
||||
@@ -737,6 +902,7 @@ const dictionaries = {
|
||||
"booking.customer.body": "These details are used for confirmation and reminders.",
|
||||
"booking.customer.name": "Name",
|
||||
"booking.customer.email": "Email",
|
||||
"booking.customer.phone": "Phone",
|
||||
"booking.customer.notes": "Note",
|
||||
"booking.customerRequired": "Add your name and email before booking.",
|
||||
"booking.failed": "Booking failed",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ export function PublicBookingRoute() {
|
||||
const [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
|
||||
const [customerName, setCustomerName] = createSignal("");
|
||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||
const [customerPhone, setCustomerPhone] = createSignal("");
|
||||
const [notes, setNotes] = createSignal("");
|
||||
const [highlightContact, setHighlightContact] = createSignal(false);
|
||||
let contactFormRef: HTMLDivElement | undefined;
|
||||
@@ -59,6 +60,7 @@ export function PublicBookingRoute() {
|
||||
locationId: slot.locationId ?? undefined,
|
||||
customerName: customerName().trim(),
|
||||
customerEmail: customerEmail().trim(),
|
||||
customerPhone: customerPhone().trim() || undefined,
|
||||
notes: notes().trim(),
|
||||
startsAt: slot.startsAt,
|
||||
endsAt: slot.endsAt,
|
||||
@@ -132,6 +134,13 @@ export function PublicBookingRoute() {
|
||||
type="email"
|
||||
value={customerEmail()}
|
||||
/>
|
||||
<Input
|
||||
autocomplete="tel"
|
||||
label={i18n.t("booking.customer.phone")}
|
||||
onInput={(event) => setCustomerPhone(event.currentTarget.value)}
|
||||
type="tel"
|
||||
value={customerPhone()}
|
||||
/>
|
||||
<Textarea
|
||||
label={i18n.t("booking.customer.notes")}
|
||||
onInput={(event) => setNotes(event.currentTarget.value)}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/bookra-character.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/paddle.ts","./src/lib/sentry.ts","./src/lib/stripe.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/dashboard-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user