mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-07-29 06:53:47 +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:
@@ -26,6 +26,19 @@ BOOKRA_STRIPE_PRO_USD_YEARLY_PRICE_ID=price_1TVAnjGrjyNQaOSGvAANw64k
|
|||||||
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd
|
BOOKRA_STRIPE_BUSINESS_CZK_YEARLY_PRICE_ID=price_1TVAmsGrjyNQaOSGL7Sl5cCd
|
||||||
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua
|
BOOKRA_STRIPE_BUSINESS_USD_YEARLY_PRICE_ID=price_1TVAo7GrjyNQaOSGB8LSCOua
|
||||||
|
|
||||||
|
# Stripe SMS prices — one per market/currency (create as standard prices, not metered)
|
||||||
|
# These are used for monthly aggregate billing (invoice item at month end)
|
||||||
|
# Czech: 1.50 CZK per SMS | US: ~$0.065 per SMS | EUR: ~€0.060 | GBP: ~£0.050 | PLN: ~0.27zł
|
||||||
|
BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx
|
||||||
|
BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_xxx
|
||||||
|
BOOKRA_STRIPE_SMS_EUR_PRICE_ID=
|
||||||
|
BOOKRA_STRIPE_SMS_GBP_PRICE_ID=
|
||||||
|
BOOKRA_STRIPE_SMS_PLN_PRICE_ID=
|
||||||
|
|
||||||
|
# SMS Manager.cz API (optional - SMS feature)
|
||||||
|
BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key
|
||||||
|
BOOKRA_SMSMANAGER_BASE_URL=https://api.smsmngr.com/v2
|
||||||
|
|
||||||
# Legacy price IDs (fallback)
|
# Legacy price IDs (fallback)
|
||||||
BOOKRA_STRIPE_STARTER_CZK_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
|
BOOKRA_STRIPE_STARTER_CZK_PRICE_ID=price_1TV6Z6GrjyNQaOSGZFcmltqI
|
||||||
BOOKRA_STRIPE_STARTER_USD_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
|
BOOKRA_STRIPE_STARTER_USD_PRICE_ID=price_1TV6cAGrjyNQaOSGXBhOq3Dk
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Bookra Project Notes
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
- `npm run build:frontend` — Build SolidJS frontend
|
||||||
|
- `npm run build:backend` — Build Go backend
|
||||||
|
- `npm run test` — Run backend tests
|
||||||
|
- `npm run verify` — Full verification (client gen, lint, test, build)
|
||||||
|
|
||||||
|
## Database
|
||||||
|
- Uses PostgreSQL via Neon (pooled URL for app, direct URL for migrations)
|
||||||
|
- Migrations with Goose: `npm run db:migrate:up`
|
||||||
|
- SQLC for typed queries: `npm run db:generate`
|
||||||
|
|
||||||
|
## SMS Feature
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Optional add-on, off by default
|
||||||
|
- Only available on **Pro** and **Business** plans
|
||||||
|
- Uses SMS Manager.cz JSON API v2 (`https://api.smsmngr.com/v2`)
|
||||||
|
- Metered billing via Stripe (1.50 CZK per SMS)
|
||||||
|
- Tracks usage locally in `sms_usage_logs` table
|
||||||
|
|
||||||
|
### Database Tables
|
||||||
|
- `tenant_sms_settings` — per-tenant SMS config (enabled, sender, limit, stripe item ID)
|
||||||
|
- `sms_usage_logs` — every SMS sent with cost tracking
|
||||||
|
- `sms_monthly_reports` — aggregated monthly usage for invoices
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `GET /v1/sms/settings` — Get SMS settings + current month stats
|
||||||
|
- `POST /v1/sms/settings` — Enable/disable SMS, configure sender/limit
|
||||||
|
- `POST /v1/sms/send` — Send an SMS (tracked & billed)
|
||||||
|
- `GET /v1/sms/usage` — Usage for a specific month
|
||||||
|
- `GET /v1/sms/history` — Recent SMS logs
|
||||||
|
- `GET /v1/sms/invoices` — Monthly invoice reports
|
||||||
|
- `POST /v1/internal/jobs/sms/invoices` — Cron endpoint to generate monthly reports & emails
|
||||||
|
|
||||||
|
### What to Configure on Stripe
|
||||||
|
1. **Create standard Prices** for SMS in each currency:
|
||||||
|
- Product: "SMS Messages"
|
||||||
|
- Price: 1.50 CZK per unit (or equivalent in USD/EUR)
|
||||||
|
- Billing mode: **Standard** one-time (not metered)
|
||||||
|
- No free trial
|
||||||
|
|
||||||
|
2. **Environment variables** to add:
|
||||||
|
```
|
||||||
|
BOOKRA_STRIPE_SMS_CZK_PRICE_ID=price_xxx
|
||||||
|
BOOKRA_STRIPE_SMS_USD_PRICE_ID=price_yyy
|
||||||
|
BOOKRA_STRIPE_SMS_EUR_PRICE_ID=price_zzz
|
||||||
|
BOOKRA_SMSMANAGER_API_KEY=your_smsmanager_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stripe CLI Commands (for testing)
|
||||||
|
```bash
|
||||||
|
# Login to Stripe
|
||||||
|
stripe login
|
||||||
|
|
||||||
|
# Create test product
|
||||||
|
stripe products create --name="SMS Messages"
|
||||||
|
|
||||||
|
# Create prices in each currency (replace prod_xxx with actual product ID)
|
||||||
|
stripe prices create --product=prod_xxx --unit-amount=150 --currency=czk
|
||||||
|
stripe prices create --product=prod_xxx --unit-amount=6 --currency=usd
|
||||||
|
stripe prices create --product=prod_xxx --unit-amount=6 --currency=eur
|
||||||
|
|
||||||
|
# Listen to webhooks locally
|
||||||
|
stripe listen --forward-to http://localhost:8080/v1/webhooks/stripe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monthly Invoice Flow
|
||||||
|
- At month end, a background job (`POST /v1/internal/jobs/sms/invoices`) aggregates all SMS usage per tenant
|
||||||
|
- It creates a Stripe `invoiceitem` with quantity = messages sent × 1.50 CZK (or configured currency price)
|
||||||
|
- The item is added to the customer's next subscription invoice automatically
|
||||||
|
- A usage summary email is sent showing: messages sent, total cost, and invoice details
|
||||||
|
- Reports are visible in-app under Settings > SMS Messages > Invoice reports
|
||||||
|
|
||||||
|
### Taxes
|
||||||
|
- The 1.50 CZK is the base unit price
|
||||||
|
- Stripe handles tax calculation based on the customer's location and your tax settings
|
||||||
|
- Displayed prices in the app show pre-tax amounts; the final invoice includes tax
|
||||||
|
|
||||||
|
### No Free Trial
|
||||||
|
- SMS is charged from the first message
|
||||||
|
- No trial period — usage is aggregated and invoiced monthly
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
"bookra/apps/backend/internal/httpx"
|
"bookra/apps/backend/internal/httpx"
|
||||||
"bookra/apps/backend/internal/notifications"
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
"bookra/apps/backend/internal/sms"
|
||||||
"bookra/apps/backend/internal/tenancy"
|
"bookra/apps/backend/internal/tenancy"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
@@ -36,6 +38,7 @@ type Server struct {
|
|||||||
adminService *admin.Service
|
adminService *admin.Service
|
||||||
billingService *billing.Service
|
billingService *billing.Service
|
||||||
notificationService *notifications.Service
|
notificationService *notifications.Service
|
||||||
|
smsService *sms.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
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)
|
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||||
|
smsService := sms.NewService(cfg, repository)
|
||||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||||
|
|
||||||
server := &Server{
|
server := &Server{
|
||||||
@@ -64,6 +68,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
billingService: billingService,
|
billingService: billingService,
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
|
smsService: smsService,
|
||||||
}
|
}
|
||||||
|
|
||||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
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"`
|
LocationID *string `json:"locationId"`
|
||||||
CustomerName string `json:"customerName" binding:"required"`
|
CustomerName string `json:"customerName" binding:"required"`
|
||||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||||
|
CustomerPhone string `json:"customerPhone"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
StartsAt string `json:"startsAt" binding:"required"`
|
StartsAt string `json:"startsAt" binding:"required"`
|
||||||
EndsAt string `json:"endsAt" 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)
|
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) {
|
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
||||||
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -780,6 +780,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
|||||||
WidgetEmbedding: true,
|
WidgetEmbedding: true,
|
||||||
UmamiTracking: false,
|
UmamiTracking: false,
|
||||||
APIAccess: false,
|
APIAccess: false,
|
||||||
|
SMSAvailable: false,
|
||||||
}
|
}
|
||||||
case "business":
|
case "business":
|
||||||
// Business: Unlimited everything, API access, dedicated manager
|
// Business: Unlimited everything, API access, dedicated manager
|
||||||
@@ -793,6 +794,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
|||||||
UmamiTracking: true,
|
UmamiTracking: true,
|
||||||
APIAccess: true,
|
APIAccess: true,
|
||||||
DedicatedManager: true,
|
DedicatedManager: true,
|
||||||
|
SMSAvailable: true,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
|
// Pro: 3 locations, 10 staff, unlimited bookings, email reminders, analytics
|
||||||
@@ -805,6 +807,7 @@ func entitlementsForPlan(planCode string) domain.PlanEntitlements {
|
|||||||
WidgetEmbedding: true,
|
WidgetEmbedding: true,
|
||||||
UmamiTracking: true,
|
UmamiTracking: true,
|
||||||
APIAccess: false,
|
APIAccess: false,
|
||||||
|
SMSAvailable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestCreateCheckoutRequiresPaddleConfig(t *testing.T) {
|
|||||||
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||||
Subject: "demo-owner",
|
Subject: "demo-owner",
|
||||||
Email: "[email protected]",
|
Email: "[email protected]",
|
||||||
}, "pro", "czk")
|
}, "pro", "czk", "monthly")
|
||||||
if err != ErrPaddleNotConfigured {
|
if err != ErrPaddleNotConfigured {
|
||||||
t.Fatalf("expected ErrPaddleNotConfigured, got response=%v err=%v", response, err)
|
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{
|
response, err := service.CreateCheckoutSession(context.Background(), domain.Principal{
|
||||||
Subject: "demo-owner",
|
Subject: "demo-owner",
|
||||||
Email: "[email protected]",
|
Email: "[email protected]",
|
||||||
}, "pro", "czk")
|
}, "pro", "czk", "monthly")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create checkout: %v", err)
|
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)
|
customerName := strings.TrimSpace(request.CustomerName)
|
||||||
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
customerEmail := strings.TrimSpace(request.CustomerEmail)
|
||||||
|
customerPhone := strings.TrimSpace(request.CustomerPhone)
|
||||||
notes := strings.TrimSpace(request.Notes)
|
notes := strings.TrimSpace(request.Notes)
|
||||||
if len(customerName) < 2 || len(customerName) > 120 {
|
if len(customerName) < 2 || len(customerName) > 120 {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
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 {
|
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
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 {
|
if len(notes) > 1000 {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking)
|
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,
|
BookingMode: request.BookingMode,
|
||||||
CustomerName: customerName,
|
CustomerName: customerName,
|
||||||
CustomerEmail: customerEmail,
|
CustomerEmail: customerEmail,
|
||||||
|
CustomerPhone: customerPhone,
|
||||||
StartsAt: startsAt.UTC(),
|
StartsAt: startsAt.UTC(),
|
||||||
EndsAt: endsAt.UTC(),
|
EndsAt: endsAt.UTC(),
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type Config struct {
|
|||||||
UmamiAPIKey string
|
UmamiAPIKey string
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
|
SMSManagerAPIKey string
|
||||||
|
SMSManagerBaseURL string
|
||||||
|
StripeSMSPriceMatrix map[string]string // currency -> price ID (czk, usd, eur, gbp, ...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -68,6 +71,9 @@ func Load() (Config, error) {
|
|||||||
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
UmamiAPIKey: strings.TrimSpace(os.Getenv("BOOKRA_UMAMI_API_KEY")),
|
||||||
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
|
SentryDSN: strings.TrimSpace(os.Getenv("BOOKRA_SENTRY_DSN")),
|
||||||
DemoMode: boolFromEnv("BOOKRA_DEMO_MODE", false),
|
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 == "" {
|
if cfg.FrontendURL == "" {
|
||||||
@@ -158,6 +164,25 @@ func (cfg Config) BillingWebhookConfigured() bool {
|
|||||||
return cfg.StripeWebhookConfigured() || cfg.PaddleWebhookConfigured()
|
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 {
|
func paddlePriceMatrixFromEnv() map[string]map[string]string {
|
||||||
matrix := map[string]map[string]string{
|
matrix := map[string]map[string]string{
|
||||||
"starter": {},
|
"starter": {},
|
||||||
@@ -223,6 +248,15 @@ func boolFromEnv(key string, fallback bool) bool {
|
|||||||
return value == "true" || value == "1" || value == "yes" || value == "on"
|
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 {
|
func uniqueStrings(values []string) []string {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
out := make([]string, 0, len(values))
|
out := make([]string, 0, len(values))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
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
|
FROM bookings
|
||||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||||
ORDER BY starts_at ASC
|
ORDER BY starts_at ASC
|
||||||
@@ -30,6 +30,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
|||||||
&record.LocationID,
|
&record.LocationID,
|
||||||
&record.CustomerName,
|
&record.CustomerName,
|
||||||
&record.CustomerEmail,
|
&record.CustomerEmail,
|
||||||
|
&record.CustomerPhone,
|
||||||
&record.StartsAt,
|
&record.StartsAt,
|
||||||
&record.EndsAt,
|
&record.EndsAt,
|
||||||
&record.Status,
|
&record.Status,
|
||||||
@@ -47,10 +48,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
|||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO bookings (
|
INSERT INTO bookings (
|
||||||
tenant_id, service_id, class_session_id, staff_id, location_id,
|
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
|
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
|
RETURNING id, reference, status
|
||||||
`,
|
`,
|
||||||
params.TenantID,
|
params.TenantID,
|
||||||
@@ -61,6 +62,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
|||||||
params.BookingMode,
|
params.BookingMode,
|
||||||
params.CustomerName,
|
params.CustomerName,
|
||||||
params.CustomerEmail,
|
params.CustomerEmail,
|
||||||
|
params.CustomerPhone,
|
||||||
params.StartsAt,
|
params.StartsAt,
|
||||||
params.EndsAt,
|
params.EndsAt,
|
||||||
params.Status,
|
params.Status,
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ type Repository interface {
|
|||||||
// Working Hours
|
// Working Hours
|
||||||
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
|
||||||
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) 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 {
|
type TenantRecord struct {
|
||||||
@@ -229,6 +241,7 @@ type BookingRecord struct {
|
|||||||
LocationID *string
|
LocationID *string
|
||||||
CustomerName string
|
CustomerName string
|
||||||
CustomerEmail string
|
CustomerEmail string
|
||||||
|
CustomerPhone string
|
||||||
StartsAt time.Time
|
StartsAt time.Time
|
||||||
EndsAt time.Time
|
EndsAt time.Time
|
||||||
Status string
|
Status string
|
||||||
@@ -244,6 +257,7 @@ type CreateBookingParams struct {
|
|||||||
BookingMode string
|
BookingMode string
|
||||||
CustomerName string
|
CustomerName string
|
||||||
CustomerEmail string
|
CustomerEmail string
|
||||||
|
CustomerPhone string
|
||||||
StartsAt time.Time
|
StartsAt time.Time
|
||||||
EndsAt time.Time
|
EndsAt time.Time
|
||||||
Status string
|
Status string
|
||||||
@@ -414,6 +428,44 @@ type UpdateWorkingHoursParams struct {
|
|||||||
IsOpen *bool
|
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 {
|
type PGRepository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -428,46 +480,14 @@ func NewRepository(pools *Pools, demoMode bool) Repository {
|
|||||||
return NewMemoryRepository()
|
return NewMemoryRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
|
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
|
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// CUSTOMER 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
|
var rec BookingRecord
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
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
|
FROM bookings
|
||||||
WHERE reference = $1
|
WHERE reference = $1
|
||||||
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
`, 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
|
return rec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,8 +618,6 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
|
|||||||
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
|
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type MemoryRepository struct {
|
type MemoryRepository struct {
|
||||||
tenant TenantRecord
|
tenant TenantRecord
|
||||||
membership TenantMembershipRecord
|
membership TenantMembershipRecord
|
||||||
@@ -617,6 +635,9 @@ type MemoryRepository struct {
|
|||||||
blockedDays []BlockedDayRecord
|
blockedDays []BlockedDayRecord
|
||||||
customers []CustomerRecord
|
customers []CustomerRecord
|
||||||
workingHours []WorkingHoursRecord
|
workingHours []WorkingHoursRecord
|
||||||
|
smsSettings TenantSMSSettingsRecord
|
||||||
|
smsLogs []SMSUsageLogRecord
|
||||||
|
smsReports []SMSMonthlyReportRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryRepository() *MemoryRepository {
|
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"`
|
LocationID *string `json:"locationId,omitempty"`
|
||||||
CustomerName string `json:"customerName"`
|
CustomerName string `json:"customerName"`
|
||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
|
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
StartsAt string `json:"startsAt"`
|
StartsAt string `json:"startsAt"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
@@ -153,6 +154,7 @@ type PlanEntitlements struct {
|
|||||||
UmamiTracking bool `json:"umamiTracking"`
|
UmamiTracking bool `json:"umamiTracking"`
|
||||||
APIAccess bool `json:"apiAccess"`
|
APIAccess bool `json:"apiAccess"`
|
||||||
DedicatedManager bool `json:"dedicatedManager"`
|
DedicatedManager bool `json:"dedicatedManager"`
|
||||||
|
SMSAvailable bool `json:"smsAvailable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlanPricing struct {
|
type PlanPricing struct {
|
||||||
@@ -447,3 +449,58 @@ type EmailNotification struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
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)
|
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
|
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 {
|
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
|
||||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||||
if err != nil {
|
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}
|
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) {
|
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
|
||||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||||
if err != nil {
|
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"/>
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||||
</svg>
|
</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.timezone": "Časové pásmo",
|
||||||
"dashboard.onboarding.submit": "Vytvořit prostor",
|
"dashboard.onboarding.submit": "Vytvořit prostor",
|
||||||
"dashboard.onboarding.pending": "Vytvářím 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.title": "Rezervace",
|
||||||
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
|
"booking.body": "Vyberte dostupný termín, doplňte kontaktní údaje a potvrzení přijde e-mailem.",
|
||||||
"booking.slots": "Dostupné termíny",
|
"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.body": "Tyto údaje použijeme pro potvrzení rezervace a připomenutí.",
|
||||||
"booking.customer.name": "Jméno",
|
"booking.customer.name": "Jméno",
|
||||||
"booking.customer.email": "E-mail",
|
"booking.customer.email": "E-mail",
|
||||||
|
"booking.customer.phone": "Telefon",
|
||||||
"booking.customer.notes": "Poznámka",
|
"booking.customer.notes": "Poznámka",
|
||||||
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
||||||
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
||||||
@@ -719,6 +802,88 @@ const dictionaries = {
|
|||||||
"dashboard.onboarding.timezone": "Timezone",
|
"dashboard.onboarding.timezone": "Timezone",
|
||||||
"dashboard.onboarding.submit": "Create workspace",
|
"dashboard.onboarding.submit": "Create workspace",
|
||||||
"dashboard.onboarding.pending": "Creating 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.title": "Book a visit",
|
||||||
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
|
"booking.body": "Choose an available time, add your contact details, and receive confirmation by email.",
|
||||||
"booking.slots": "Available times",
|
"booking.slots": "Available times",
|
||||||
@@ -737,6 +902,7 @@ const dictionaries = {
|
|||||||
"booking.customer.body": "These details are used for confirmation and reminders.",
|
"booking.customer.body": "These details are used for confirmation and reminders.",
|
||||||
"booking.customer.name": "Name",
|
"booking.customer.name": "Name",
|
||||||
"booking.customer.email": "Email",
|
"booking.customer.email": "Email",
|
||||||
|
"booking.customer.phone": "Phone",
|
||||||
"booking.customer.notes": "Note",
|
"booking.customer.notes": "Note",
|
||||||
"booking.customerRequired": "Add your name and email before booking.",
|
"booking.customerRequired": "Add your name and email before booking.",
|
||||||
"booking.failed": "Booking failed",
|
"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 [submittingSlot, setSubmittingSlot] = createSignal<string | null>(null);
|
||||||
const [customerName, setCustomerName] = createSignal("");
|
const [customerName, setCustomerName] = createSignal("");
|
||||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||||
|
const [customerPhone, setCustomerPhone] = createSignal("");
|
||||||
const [notes, setNotes] = createSignal("");
|
const [notes, setNotes] = createSignal("");
|
||||||
const [highlightContact, setHighlightContact] = createSignal(false);
|
const [highlightContact, setHighlightContact] = createSignal(false);
|
||||||
let contactFormRef: HTMLDivElement | undefined;
|
let contactFormRef: HTMLDivElement | undefined;
|
||||||
@@ -59,6 +60,7 @@ export function PublicBookingRoute() {
|
|||||||
locationId: slot.locationId ?? undefined,
|
locationId: slot.locationId ?? undefined,
|
||||||
customerName: customerName().trim(),
|
customerName: customerName().trim(),
|
||||||
customerEmail: customerEmail().trim(),
|
customerEmail: customerEmail().trim(),
|
||||||
|
customerPhone: customerPhone().trim() || undefined,
|
||||||
notes: notes().trim(),
|
notes: notes().trim(),
|
||||||
startsAt: slot.startsAt,
|
startsAt: slot.startsAt,
|
||||||
endsAt: slot.endsAt,
|
endsAt: slot.endsAt,
|
||||||
@@ -132,6 +134,13 @@ export function PublicBookingRoute() {
|
|||||||
type="email"
|
type="email"
|
||||||
value={customerEmail()}
|
value={customerEmail()}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
autocomplete="tel"
|
||||||
|
label={i18n.t("booking.customer.phone")}
|
||||||
|
onInput={(event) => setCustomerPhone(event.currentTarget.value)}
|
||||||
|
type="tel"
|
||||||
|
value={customerPhone()}
|
||||||
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label={i18n.t("booking.customer.notes")}
|
label={i18n.t("booking.customer.notes")}
|
||||||
onInput={(event) => setNotes(event.currentTarget.value)}
|
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